mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-01-12 15:22:55 +01:00
Initial commit
Co-Authored-By: Eric Tuvesson <eric.tuvesson@gmail.com> Co-Authored-By: mikaeltellhed <2311083+mikaeltellhed@users.noreply.github.com> Co-Authored-By: kotte <14197736+mrtamagotchi@users.noreply.github.com> Co-Authored-By: Anders Larsson <64838990+anders-topp@users.noreply.github.com> Co-Authored-By: Johan <4934465+joolsus@users.noreply.github.com> Co-Authored-By: Tore Knudsen <18231882+torekndsn@users.noreply.github.com> Co-Authored-By: victoratndl <99176179+victoratndl@users.noreply.github.com>
This commit is contained in:
753
packages/noodl-editor/src/main/main.js
Normal file
753
packages/noodl-editor/src/main/main.js
Normal file
@@ -0,0 +1,753 @@
|
||||
const electron = require('electron');
|
||||
const { app, dialog } = electron;
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const AutoUpdater = require('./src/autoupdater');
|
||||
const FloatingWindow = require('./src/floating-window');
|
||||
const startServer = require('./src/web-server');
|
||||
const { startCloudFunctionServer, closeRuntimeWhenWindowCloses } = require('./src/cloud-function-server');
|
||||
const DesignToolImportServer = require('./src/design-tool-import-server');
|
||||
const jsonstorage = require('../shared/utils/jsonstorage');
|
||||
const StorageApi = require('./src/StorageApi');
|
||||
|
||||
const { handleProjectMerge } = require('./src/merge-driver');
|
||||
|
||||
//fixes problem with reloading the viewer when it's
|
||||
//running in a separate browser window (file:// cross origin warning)
|
||||
app.commandLine.appendSwitch('disable-site-isolation-trials');
|
||||
|
||||
var args = process.argv || [];
|
||||
|
||||
function launchApp() {
|
||||
const { Menu, BrowserWindow, ipcMain, shell } = electron;
|
||||
const Config = require('../shared/config/config');
|
||||
|
||||
require('@electron/remote/main').initialize();
|
||||
|
||||
const appPath = app.getAppPath();
|
||||
|
||||
app.setAsDefaultProtocolClient('noodl');
|
||||
|
||||
let win;
|
||||
|
||||
const gotTheLock = app.requestSingleInstanceLock();
|
||||
|
||||
if (!gotTheLock) {
|
||||
console.log(`
|
||||
-------------------------------
|
||||
Noodl is already running.
|
||||
-------------------------------
|
||||
|
||||
`);
|
||||
app.quit();
|
||||
return;
|
||||
} else {
|
||||
// Someone tried to run a second instance, we should focus our window.
|
||||
app.on('second-instance', (event, argv, workingDirectory) => {
|
||||
if (win) {
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.focus();
|
||||
|
||||
console.log('second-instance', event, argv);
|
||||
|
||||
var args = argv || [];
|
||||
for (var i = 0; i < args.length; i++) {
|
||||
if (args[i].indexOf('noodl:') === 0) {
|
||||
process.env.noodlURI = args[i];
|
||||
win.webContents.send('open-noodl-uri', args[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
//chech if local docs are running
|
||||
//If they are running, use those instead of noodl docs domain
|
||||
const version = app.getVersion().split('.').slice(0, 2).join('.');
|
||||
require('http')
|
||||
.get(`http://127.0.0.1:3000/${version}/version.json`, (res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
global.useLocalDocs = false;
|
||||
return;
|
||||
}
|
||||
|
||||
let rawData = '';
|
||||
res.on('data', (chunk) => {
|
||||
rawData += chunk;
|
||||
});
|
||||
res.on('end', () => {
|
||||
try {
|
||||
// Check if the JSON have:
|
||||
// > "kind": "noodl-docs"
|
||||
const json = JSON.parse(rawData);
|
||||
global.useLocalDocs = json.kind === 'noodl-docs';
|
||||
|
||||
if (global.useLocalDocs) {
|
||||
console.log('> Using local docs');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e.message);
|
||||
global.useLocalDocs = false;
|
||||
}
|
||||
});
|
||||
})
|
||||
.on('error', () => {
|
||||
global.useLocalDocs = false;
|
||||
});
|
||||
|
||||
const viewerWindow = new FloatingWindow();
|
||||
//const messageTrackerWindow = new FloatingWindow();
|
||||
|
||||
function guid() {
|
||||
function s4() {
|
||||
return Math.floor((1 + Math.random()) * 0x10000)
|
||||
.toString(16)
|
||||
.substring(1);
|
||||
}
|
||||
return s4() + s4() + '-' + s4() + '-' + s4() + '-' + s4() + '-' + s4() + s4() + s4();
|
||||
}
|
||||
|
||||
const _editorAPICallbacks = {};
|
||||
|
||||
function makeEditorAPIRequest(api, args, callback) {
|
||||
const t = guid();
|
||||
_editorAPICallbacks[t] = (r) => {
|
||||
callback(r.response);
|
||||
};
|
||||
if (win && win.webContents && !win.webContents.isDestroyed()) {
|
||||
win.webContents.send('editor-api-request', { api: api, token: t, args: args });
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.on('editor-api-response', function (event, args) {
|
||||
const token = args.token;
|
||||
|
||||
if (!_editorAPICallbacks[token]) return;
|
||||
_editorAPICallbacks[token](args);
|
||||
delete _editorAPICallbacks[token];
|
||||
});
|
||||
|
||||
function projectGetSettings(callback) {
|
||||
makeEditorAPIRequest('projectGetSettings', undefined, callback);
|
||||
}
|
||||
|
||||
function projectGetInfo(callback) {
|
||||
makeEditorAPIRequest('projectGetInfo', undefined, callback);
|
||||
}
|
||||
|
||||
function projectGetComponentBundleExport(name, callback) {
|
||||
makeEditorAPIRequest('projectGetComponentBundleExport', { name }, callback);
|
||||
}
|
||||
|
||||
function cloudServicesGetActive(callback) {
|
||||
makeEditorAPIRequest('cloudServicesGetActive', undefined, callback);
|
||||
}
|
||||
|
||||
process.env.exePath = app.getPath('exe');
|
||||
let reopenWindow = false;
|
||||
|
||||
function createWindow() {
|
||||
win = new BrowserWindow({
|
||||
width: 1368,
|
||||
height: 900,
|
||||
acceptFirstMouse: true,
|
||||
backgroundColor: '#131313',
|
||||
center: true,
|
||||
frame: false,
|
||||
minWidth: 600,
|
||||
minHeight: 300,
|
||||
titleBarStyle: 'hidden',
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false,
|
||||
webviewTag: true
|
||||
},
|
||||
show: false
|
||||
});
|
||||
|
||||
require('@electron/remote/main').enable(win.webContents);
|
||||
|
||||
if (!Config.devMode) {
|
||||
AutoUpdater.setupAutoUpdate(win);
|
||||
}
|
||||
|
||||
win.loadURL('file:///' + appPath + '/src/editor/index.html');
|
||||
|
||||
// Make sure <a href target="_blank"> and window.open opens in external browser
|
||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url);
|
||||
return { action: 'deny' }; //deny a new electron window
|
||||
});
|
||||
|
||||
win.once('ready-to-show', () => {
|
||||
win.show();
|
||||
});
|
||||
|
||||
win.on('closed', () => {
|
||||
win = null;
|
||||
clearTimeout(saveWindowSettingsTimeout);
|
||||
if (reopenWindow) {
|
||||
reopenWindow = false;
|
||||
createWindow();
|
||||
closeRuntimeWhenWindowCloses(win);
|
||||
}
|
||||
});
|
||||
|
||||
win.webContents.on('render-process-gone', (event, details) => {
|
||||
if (details.reason === 'crashed') {
|
||||
console.log('Editor window process crashed');
|
||||
closeViewer();
|
||||
|
||||
dialog.showMessageBoxSync({
|
||||
message: 'Oh No! Noodl has crashed :( Click OK to restart',
|
||||
type: 'error'
|
||||
});
|
||||
|
||||
win.close();
|
||||
win = null;
|
||||
reopenWindow = true;
|
||||
}
|
||||
});
|
||||
|
||||
process.env.noodlURI && win.webContents.send('open-noodl-uri', process.env.noodlURI);
|
||||
|
||||
DesignToolImportServer.setWindow(win);
|
||||
StorageApi.setup(win);
|
||||
}
|
||||
|
||||
function closeViewer() {
|
||||
if (!viewerWindow.isOpen()) {
|
||||
return;
|
||||
}
|
||||
|
||||
viewerWindow.close();
|
||||
win && win.webContents.send('viewer-closed');
|
||||
}
|
||||
|
||||
function openViewer(sender, eventArgs) {
|
||||
if (viewerWindow.isOpen()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parentBounds = win.getBounds();
|
||||
|
||||
// TODO: There seems to be an issue with Electron that it doesn't respect
|
||||
// the minWidth,minHeight on multi monitor screens.
|
||||
const minWidth = 320;
|
||||
const minHeight = 568;
|
||||
|
||||
const height = Math.max(minHeight, parentBounds.height - 200);
|
||||
const width = Math.max(minWidth, Math.floor(((height - 37) * 9) / 16));
|
||||
|
||||
viewerWindow.open({
|
||||
x: parentBounds.width - width - 50,
|
||||
y: 80,
|
||||
parent: win,
|
||||
width,
|
||||
height,
|
||||
minWidth,
|
||||
minHeight,
|
||||
url: 'file:///' + appPath + '/src/frames/viewer-frame/index.html',
|
||||
alwaysShadow: true
|
||||
});
|
||||
|
||||
viewerWindow.window.webContents.once('did-finish-load', () => {
|
||||
viewerWindow.send('viewer-cookies', eventArgs.cookies);
|
||||
|
||||
if (eventArgs.zoomFactor) {
|
||||
viewerWindow.send('viewer-set-zoom-factor', eventArgs.zoomFactor);
|
||||
}
|
||||
|
||||
if (eventArgs.route) {
|
||||
viewerWindow.send('viewer-set-route', eventArgs.route);
|
||||
}
|
||||
|
||||
if (eventArgs.viewportSize) {
|
||||
viewerWindow.send('viewer-set-viewport-size', eventArgs.viewportSize);
|
||||
}
|
||||
|
||||
viewerWindow.send('viewer-set-inspect-mode', eventArgs.inspectMode);
|
||||
viewerWindow.send('viewer-select-node', eventArgs.selectedNodeId);
|
||||
});
|
||||
|
||||
// viewerWindow.openDevTools();
|
||||
}
|
||||
|
||||
var floatingWindows = {};
|
||||
function closeFloatingWindow(options) {
|
||||
if (!floatingWindows[options.id]) return;
|
||||
|
||||
floatingWindows[options.id].close();
|
||||
if (options.sendCloseEvent) {
|
||||
win && win.webContents.send('floating-window-closed', { id: options.id });
|
||||
}
|
||||
}
|
||||
|
||||
function openFloatingWindow(options) {
|
||||
if (!floatingWindows[options.id]) floatingWindows[options.id] = new FloatingWindow();
|
||||
const floatingWindow = floatingWindows[options.id];
|
||||
|
||||
const parentBounds = win.getBounds();
|
||||
|
||||
const width = options.width || 800;
|
||||
const height = options.height || 600;
|
||||
|
||||
floatingWindow.open({
|
||||
x: parentBounds.width - width - 50,
|
||||
y: 80,
|
||||
width,
|
||||
height,
|
||||
parent: win,
|
||||
minWidth: options.minWidth || 120,
|
||||
minHeight: options.minHeight || 175,
|
||||
url: options.url.replace('{{appPath}}', appPath)
|
||||
});
|
||||
|
||||
// floatingWindow.openDevTools();
|
||||
|
||||
floatingWindow.window.webContents.once('did-finish-load', () => {
|
||||
floatingWindow.send('floating-window-options', options.id, options.options);
|
||||
});
|
||||
|
||||
floatingWindow.forwardIpcEvents(['editor-api-response']);
|
||||
|
||||
return floatingWindow;
|
||||
}
|
||||
|
||||
let saveWindowSettingsTimeout;
|
||||
function onMainWindowBoundsChanged() {
|
||||
clearTimeout(saveWindowSettingsTimeout);
|
||||
saveWindowSettingsTimeout = setTimeout(() => {
|
||||
win && jsonstorage.set('windowBounds', win.getBounds());
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function resizeMainWindow(options) {
|
||||
win.off('resize', onMainWindowBoundsChanged);
|
||||
win.off('move', onMainWindowBoundsChanged);
|
||||
|
||||
if (options.size === 'editor') {
|
||||
jsonstorage.get('windowBounds', (bounds) => {
|
||||
win.setResizable(true);
|
||||
win.setMaximizable(true);
|
||||
win.setMinimizable(true);
|
||||
|
||||
// We cannot require the screen module until the app is ready.
|
||||
const { screen } = require('electron');
|
||||
const primaryDisplay = screen.getPrimaryDisplay();
|
||||
|
||||
if (
|
||||
bounds &&
|
||||
bounds.width &&
|
||||
bounds.height &&
|
||||
bounds.x + bounds.width < primaryDisplay.workAreaSize.width &&
|
||||
bounds.y + bounds.height < primaryDisplay.workAreaSize.height
|
||||
) {
|
||||
win.setPosition(bounds.x, bounds.y);
|
||||
win.setSize(bounds.width, bounds.height);
|
||||
} else {
|
||||
win.setSize(1368, 900);
|
||||
if (options.center) win.center();
|
||||
}
|
||||
|
||||
win.on('move', onMainWindowBoundsChanged);
|
||||
win.on('resize', onMainWindowBoundsChanged);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const buildNumber = JSON.parse(fs.readFileSync(appPath + '/package.json')).buildNumber;
|
||||
|
||||
let submenu = [
|
||||
{
|
||||
label: 'About Application',
|
||||
click: () => {
|
||||
require('about-window').default({
|
||||
icon_path: appPath + '/src/assets/images/icon.png',
|
||||
copyright: 'Copyright (c) 2023 Future Platforms AB',
|
||||
description: buildNumber ? 'Build ' + buildNumber : undefined
|
||||
});
|
||||
}
|
||||
},
|
||||
{ type: 'separator' }
|
||||
];
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
submenu = submenu.concat([{ role: 'hide' }, { role: 'hideothers' }, { role: 'unhide' }, { type: 'separator' }]);
|
||||
}
|
||||
|
||||
submenu.push({
|
||||
label: 'Quit',
|
||||
accelerator: 'Command+Q',
|
||||
click: function () {
|
||||
closeViewer();
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
function setupMenu() {
|
||||
var template = [
|
||||
{
|
||||
label: 'Application',
|
||||
submenu: submenu
|
||||
},
|
||||
{
|
||||
label: 'Edit',
|
||||
submenu: [
|
||||
{ label: 'Undo', accelerator: 'CmdOrCtrl+Z', selector: 'undo:' },
|
||||
{ label: 'Redo', accelerator: 'Shift+CmdOrCtrl+Z', selector: 'redo:' },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Cut', accelerator: 'CmdOrCtrl+X', selector: 'cut:' },
|
||||
{ label: 'Copy', accelerator: 'CmdOrCtrl+C', selector: 'copy:' },
|
||||
{ label: 'Paste', accelerator: 'CmdOrCtrl+V', selector: 'paste:' },
|
||||
{ label: 'Select All', accelerator: 'CmdOrCtrl+A', selector: 'selectAll:' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// if(Config.devMode) {
|
||||
template.push({
|
||||
label: 'Dev',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Open Editor Devtools',
|
||||
accelerator: 'CmdOrCtrl+E',
|
||||
click: () => {
|
||||
if (!win) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (win.isDevToolsOpened()) {
|
||||
win.closeDevTools();
|
||||
}
|
||||
|
||||
win.openDevTools();
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
// }
|
||||
|
||||
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
|
||||
}
|
||||
|
||||
function forwardIpcEventsToEditorWindow(events) {
|
||||
for (const eventName of events) {
|
||||
ipcMain.on(eventName, (e, ...args) => {
|
||||
win && win.webContents.send(eventName, ...args);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function setupAskForMediaAccessIpc() {
|
||||
const { systemPreferences } = require('electron');
|
||||
|
||||
ipcMain.on('request-media-access', function (event, mediaTypes) {
|
||||
console.log('Requesting media access ' + mediaTypes);
|
||||
|
||||
//MacOS is the only platform with this API. For Windows we can just return true.
|
||||
if (systemPreferences.askForMediaAccess) {
|
||||
let promises = [];
|
||||
if (mediaTypes.indexOf('video') !== -1) promises.push(systemPreferences.askForMediaAccess('camera'));
|
||||
if (mediaTypes.indexOf('audio') !== -1) promises.push(systemPreferences.askForMediaAccess('microphone'));
|
||||
|
||||
Promise.all(promises)
|
||||
.then((results) => {
|
||||
let isAllowed = true;
|
||||
results.forEach(function (r) {
|
||||
isAllowed = isAllowed && r;
|
||||
});
|
||||
event.reply('request-media-access-reply', isAllowed);
|
||||
})
|
||||
.catch((error) => {
|
||||
event.reply('request-media-access-reply', false);
|
||||
});
|
||||
} else {
|
||||
event.reply('request-media-access-reply', true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setupViewerIpc() {
|
||||
// Using a timer to hackily prevent
|
||||
// the viewer from flashing when subjected
|
||||
// to consecutive hide+show after another
|
||||
let showTimer;
|
||||
|
||||
ipcMain.on('viewer-attach', () => {
|
||||
closeViewer();
|
||||
});
|
||||
|
||||
ipcMain.on('viewer-show', () => {
|
||||
showTimer = setTimeout(() => {
|
||||
viewerWindow.show();
|
||||
win && win.focus();
|
||||
}, 10);
|
||||
});
|
||||
|
||||
ipcMain.on('viewer-hide', () => {
|
||||
if (viewerWindow.dockedInParent) {
|
||||
clearTimeout(showTimer);
|
||||
viewerWindow.hide();
|
||||
win && win.focus();
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on('viewer-detach', openViewer);
|
||||
ipcMain.on('project-closed', closeViewer);
|
||||
|
||||
forwardIpcEventsToEditorWindow([
|
||||
'viewer-refreshed',
|
||||
'viewer-attach',
|
||||
'viewer-detach',
|
||||
'viewer-navigation-state',
|
||||
'viewer-capture-thumb-reply',
|
||||
'viewer-inspect-node'
|
||||
]);
|
||||
|
||||
//events to forward from main window to viewer
|
||||
viewerWindow.forwardIpcEvents([
|
||||
'viewer-open-devtools',
|
||||
'viewer-refresh',
|
||||
'viewer-focus',
|
||||
'viewer-inspect',
|
||||
'viewer-inspect-selected',
|
||||
'viewer-set-zoom-factor',
|
||||
'viewer-navigate-forward',
|
||||
'viewer-navigate-back',
|
||||
'viewer-set-route',
|
||||
'viewer-set-viewport-size',
|
||||
'viewer-set-inspect-mode',
|
||||
'viewer-select-node',
|
||||
'viewer-capture-thumb',
|
||||
'viewer-show-inspect-menu',
|
||||
'editor-api-response'
|
||||
]);
|
||||
}
|
||||
|
||||
// This method will be called when Electron has finished
|
||||
// initialization and is ready to create browser windows.
|
||||
// Some APIs can only be used after this event occurs.
|
||||
app.on('ready', function () {
|
||||
createWindow();
|
||||
|
||||
setupViewerIpc();
|
||||
|
||||
setupAskForMediaAccessIpc();
|
||||
|
||||
forwardIpcEventsToEditorWindow(['editor-api-request', 'editor-api-response']);
|
||||
|
||||
setupFloatingWindowIpc();
|
||||
|
||||
setupMainWindowControlIpc();
|
||||
|
||||
setupMenu();
|
||||
|
||||
startServer(app, projectGetSettings, projectGetInfo, projectGetComponentBundleExport);
|
||||
|
||||
startCloudFunctionServer(app, cloudServicesGetActive);
|
||||
closeRuntimeWhenWindowCloses(win);
|
||||
|
||||
DesignToolImportServer.start(projectGetInfo);
|
||||
|
||||
try {
|
||||
startUDPMulticast();
|
||||
} catch (e) {
|
||||
console.log('Failed to start UDP Multicast');
|
||||
}
|
||||
});
|
||||
|
||||
app.on('will-finish-launching', function () {
|
||||
app.on('open-url', function (event, uri) {
|
||||
console.log('open-url', uri);
|
||||
event.preventDefault();
|
||||
win && win.webContents.send('open-noodl-uri', uri);
|
||||
process.env.noodlURI = uri;
|
||||
// logEverywhere("open-url# " + deeplinkingUrl)
|
||||
});
|
||||
});
|
||||
|
||||
// Quit when all windows are closed.
|
||||
app.on('window-all-closed', () => {
|
||||
// On macOS it is common for applications and their menu bar
|
||||
// to stay active until the user quits explicitly with Cmd + Q
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
// On macOS it's common to re-create a window in the app when the
|
||||
// dock icon is clicked and there are no other windows open.
|
||||
if (win === null) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
|
||||
function isAppFocused() {
|
||||
return BrowserWindow.getAllWindows().some((x) => x.isFocused());
|
||||
}
|
||||
|
||||
// Lets make sure we only trigger it when the app have been unfocused.
|
||||
let appHaveFocus = true;
|
||||
|
||||
app.on('browser-window-focus', (event, win) => {
|
||||
win && win.webContents.send('window-focused');
|
||||
|
||||
if (isAppFocused() && !appHaveFocus) {
|
||||
appHaveFocus = true;
|
||||
win && win.webContents.send('app-focused');
|
||||
}
|
||||
});
|
||||
|
||||
app.on('browser-window-blur', (event, win) => {
|
||||
win && win.webContents.send('window-blurred');
|
||||
|
||||
if (!isAppFocused()) {
|
||||
appHaveFocus = false;
|
||||
win && win.webContents.send('app-blurred');
|
||||
}
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------------------------------------------------
|
||||
// Floating windows
|
||||
// --------------------------------------------------------------------------------------------------------------------
|
||||
function setupFloatingWindowIpc() {
|
||||
ipcMain.on('floating-window-close', function (event, options) {
|
||||
closeFloatingWindow(options);
|
||||
});
|
||||
ipcMain.on('floating-window-open', function (event, options) {
|
||||
openFloatingWindow(options);
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------------------------------------------------
|
||||
// Main window control
|
||||
// --------------------------------------------------------------------------------------------------------------------
|
||||
function setupMainWindowControlIpc() {
|
||||
ipcMain.on('main-window-resize', function (event, options) {
|
||||
resizeMainWindow(options);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function startUDPMulticast() {
|
||||
var dgram = require('dgram');
|
||||
var server = dgram.createSocket('udp4');
|
||||
var os = require('os');
|
||||
const { ipcMain } = electron;
|
||||
|
||||
server.bind();
|
||||
|
||||
server.on('listening', function () {
|
||||
server.setBroadcast(true);
|
||||
server.setMulticastTTL(128);
|
||||
try {
|
||||
server.addMembership('225.0.0.100');
|
||||
} catch (e) {
|
||||
//this can happen when running without a connection to a router, just ignore for now
|
||||
}
|
||||
setInterval(broadcastNew, 2000);
|
||||
});
|
||||
|
||||
let projectName = 'No Project Open';
|
||||
ipcMain.on('project-opened', (e, newProjectName) => {
|
||||
projectName = newProjectName;
|
||||
broadcastNew();
|
||||
DesignToolImportServer.setProjectName(newProjectName);
|
||||
});
|
||||
ipcMain.on('project-closed', () => {
|
||||
projectName = 'No Project Open';
|
||||
DesignToolImportServer.setProjectName(null);
|
||||
});
|
||||
|
||||
//converts an object to a UTF16 ArrayBuffer
|
||||
function jsToArrayBuffer(obj) {
|
||||
const str = JSON.stringify(obj);
|
||||
const buf = new ArrayBuffer(str.length * 2);
|
||||
const bufView = new Uint16Array(buf);
|
||||
for (let i = 0, strLen = str.length; i < strLen; i++) {
|
||||
bufView[i] = str.charCodeAt(i);
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
app.on('quit', () => {
|
||||
//broadcast a message when shutting down so clients can
|
||||
//remove the editor as fast as possible, without having to wait
|
||||
//for a timeout
|
||||
const hostname = os.hostname();
|
||||
|
||||
if (hostname) {
|
||||
const message = new Buffer(
|
||||
jsToArrayBuffer({ https: process.env.ssl ? true : false, hostname, status: 'closed' })
|
||||
);
|
||||
server.send(message, 0, message.length, 8575, '225.0.0.100');
|
||||
}
|
||||
});
|
||||
|
||||
function broadcastNew() {
|
||||
const hostname = os.hostname();
|
||||
const httpPort = process.env.NOODLPORT || 8574;
|
||||
|
||||
if (hostname) {
|
||||
const message = new Buffer(
|
||||
jsToArrayBuffer({ https: process.env.ssl ? true : false, hostname, httpPort, projectName, status: 'active' })
|
||||
);
|
||||
server.send(message, 0, message.length, 8575, '225.0.0.100');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find domain name argument if existing
|
||||
process.env.noodlArgs = JSON.stringify(args);
|
||||
for (var i = 0; i < args.length; i++) {
|
||||
if (args[i].indexOf('--api=') === 0) {
|
||||
process.env.apiEndpoint = args[i].split('=')[1];
|
||||
} else if (args[i].indexOf('--autoupdate=') === 0) {
|
||||
process.env.autoUpdate = args[i].split('=')[1];
|
||||
} else if (args[i].indexOf('--lessons=') === 0) {
|
||||
process.env.lessons = path.resolve(args[i].split('=')[1]);
|
||||
} else if (args[i].indexOf('--feed=') === 0) {
|
||||
process.env.feed = path.resolve(args[i].split('=')[1]);
|
||||
} else if (args[i].indexOf('--library=') === 0) {
|
||||
process.env.library = path.resolve(args[i].split('=')[1]);
|
||||
} else if (args[i].indexOf('--previews=') === 0) {
|
||||
process.env.previews = path.resolve(args[i].split('=')[1]);
|
||||
} else if (args[i].indexOf('--projectTemplates=') === 0) {
|
||||
process.env.projectTemplates = path.resolve(args[i].split('=')[1]);
|
||||
} else if (args[i].indexOf('--ssl-cert=') === 0) {
|
||||
process.env.sslCert = path.resolve(args[i].split('=')[1]);
|
||||
} else if (args[i].indexOf('--ssl-key=') === 0) {
|
||||
process.env.sslKey = path.resolve(args[i].split('=')[1]);
|
||||
} else if (args[i].indexOf('noodl:') === 0) {
|
||||
process.env.noodlURI = args[i];
|
||||
}
|
||||
}
|
||||
|
||||
let flagsOk = true;
|
||||
|
||||
if (process.env.sslCert && !process.env.sslKey) {
|
||||
console.log('missing --sslKey');
|
||||
flagsOk = false;
|
||||
}
|
||||
|
||||
if (!process.env.sslCert && process.env.sslKey) {
|
||||
console.log('missing --sslCert');
|
||||
flagsOk = false;
|
||||
}
|
||||
|
||||
if (process.env.sslCert && process.env.sslKey) {
|
||||
process.env.ssl = 'true';
|
||||
}
|
||||
|
||||
if (args.indexOf('--merge') !== -1) {
|
||||
// The noodl app can be started in merge mode, then it will merge two project files and then
|
||||
// exit the app
|
||||
handleProjectMerge(args);
|
||||
} else if (flagsOk) {
|
||||
launchApp();
|
||||
} else {
|
||||
app.quit();
|
||||
}
|
||||
30
packages/noodl-editor/src/main/src/StorageApi.js
Normal file
30
packages/noodl-editor/src/main/src/StorageApi.js
Normal file
@@ -0,0 +1,30 @@
|
||||
const Store = require('electron-store');
|
||||
const { ipcMain, safeStorage } = require('electron');
|
||||
|
||||
function execute(method, args) {
|
||||
switch (method) {
|
||||
case 'decryptString':
|
||||
return safeStorage.decryptString(Buffer.from(args[0], 'latin1'));
|
||||
|
||||
case 'encryptString':
|
||||
return safeStorage.encryptString(args[0]).toString('latin1');
|
||||
|
||||
case 'isEncryptionAvailable':
|
||||
return safeStorage.isEncryptionAvailable();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
setup(mainWindow) {
|
||||
Store.initRenderer();
|
||||
|
||||
ipcMain.on('storage-api', (_, { token, method, args }) => {
|
||||
try {
|
||||
const data = execute(method, args);
|
||||
mainWindow.webContents.send('storage-api', { token, data });
|
||||
} catch (error) {
|
||||
console.error('[Storage]', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
61
packages/noodl-editor/src/main/src/autoupdater.js
Normal file
61
packages/noodl-editor/src/main/src/autoupdater.js
Normal file
@@ -0,0 +1,61 @@
|
||||
const { app, ipcMain } = require('electron');
|
||||
const { autoUpdater } = require('electron-updater');
|
||||
//const {autoUpdateBaseUrl} = require('../../shared/config/config');
|
||||
|
||||
function setupAutoUpdate(window) {
|
||||
if (process.env.autoUpdate === 'no') return;
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
return;
|
||||
}
|
||||
|
||||
function _checkForUpdates() {
|
||||
try {
|
||||
autoUpdater.checkForUpdates();
|
||||
} catch (e) {
|
||||
// Failed to check for updates, try again later
|
||||
setTimeout(() => {
|
||||
_checkForUpdates();
|
||||
}, 60 * 1000);
|
||||
}
|
||||
}
|
||||
_checkForUpdates();
|
||||
|
||||
autoUpdater.addListener('update-available', (event) => {
|
||||
console.log('A new update is available, downloading...');
|
||||
});
|
||||
|
||||
autoUpdater.addListener('update-downloaded', (event) => {
|
||||
window.webContents.send('showAutoUpdatePopup');
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.on('autoUpdatePopupClosed', (event, restartNow) => {
|
||||
if (restartNow) {
|
||||
autoUpdater.quitAndInstall();
|
||||
}
|
||||
});
|
||||
|
||||
/* autoUpdater.addListener("error", (error) => {
|
||||
console.log('Auto update error', error);
|
||||
});*/
|
||||
|
||||
autoUpdater.addListener('update-not-available', () => {
|
||||
setTimeout(() => {
|
||||
_checkForUpdates();
|
||||
}, 60 * 1000);
|
||||
});
|
||||
|
||||
autoUpdater.addListener('error', (event) => {
|
||||
// There was an error while trying to update, try again
|
||||
console.log('Error while auto updating, trying again in a while...');
|
||||
setTimeout(() => {
|
||||
_checkForUpdates();
|
||||
}, 60 * 1000);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
setupAutoUpdate
|
||||
};
|
||||
257
packages/noodl-editor/src/main/src/cloud-function-server.js
Normal file
257
packages/noodl-editor/src/main/src/cloud-function-server.js
Normal file
@@ -0,0 +1,257 @@
|
||||
const electron = require('electron');
|
||||
const { BrowserWindow, ipcMain } = electron;
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const URL = require('url');
|
||||
|
||||
var port = process.env.NOODL_CLOUD_FUNCTIONS_PORT || 8577;
|
||||
|
||||
function guid() {
|
||||
function s4() {
|
||||
return Math.floor((1 + Math.random()) * 0x10000)
|
||||
.toString(16)
|
||||
.substring(1);
|
||||
}
|
||||
return s4() + s4() + '-' + s4() + '-' + s4() + '-' + s4() + '-' + s4() + s4() + s4();
|
||||
}
|
||||
|
||||
let sandbox;
|
||||
let hasLoadedProject = false;
|
||||
const queuedRequestsBeforeProjectLoaded = [];
|
||||
|
||||
function openCloudRuntimeDevTools() {
|
||||
if (sandbox) {
|
||||
if (sandbox.isDevToolsOpened()) {
|
||||
//closing and opening the dev tools will put it on front of the editor window
|
||||
sandbox.closeDevTools();
|
||||
}
|
||||
|
||||
sandbox.openDevTools({
|
||||
mode: 'detach'
|
||||
});
|
||||
} else {
|
||||
console.log('No cloud sandbox active');
|
||||
}
|
||||
}
|
||||
|
||||
function closeCloudRuntime() {
|
||||
if (sandbox) {
|
||||
sandbox.destroy();
|
||||
sandbox = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (sandbox) {
|
||||
sandbox.reload();
|
||||
}
|
||||
}
|
||||
|
||||
function startCloudFunctionServer(app, cloudServicesGetActive, mainWindow) {
|
||||
function startCloudRuntime() {
|
||||
const appPath = app.getAppPath();
|
||||
|
||||
sandbox = new BrowserWindow({
|
||||
width: 10,
|
||||
height: 10,
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false,
|
||||
webviewTag: false
|
||||
},
|
||||
show: false
|
||||
});
|
||||
|
||||
console.log('starting cloud runtime');
|
||||
|
||||
hasLoadedProject = false;
|
||||
|
||||
sandbox.loadURL('file:///' + appPath + '/src/external/cloudruntime/index.html');
|
||||
sandbox.webContents.on('did-start-loading', () => {
|
||||
//window has been refreshed, we need to wait for the viewer to get the export and load the components before handling the requests
|
||||
hasLoadedProject = false;
|
||||
});
|
||||
}
|
||||
|
||||
const _responseHandlers = {};
|
||||
ipcMain.on('noodl-cf-response', function (event, args) {
|
||||
const token = args.token;
|
||||
|
||||
if (!_responseHandlers[token]) return;
|
||||
_responseHandlers[token](args);
|
||||
delete _responseHandlers[token];
|
||||
});
|
||||
|
||||
ipcMain.on('noodl-cf-has-loaded-project', function (event, args) {
|
||||
hasLoadedProject = true;
|
||||
|
||||
for (const req of queuedRequestsBeforeProjectLoaded) {
|
||||
sandbox.webContents.send('noodl-cf-request', req);
|
||||
}
|
||||
queuedRequestsBeforeProjectLoaded.length = 0;
|
||||
});
|
||||
|
||||
ipcMain.on('noodl-cf-fetch', function (event, args) {
|
||||
const token = args.token;
|
||||
const url = URL.parse(args.url, true);
|
||||
|
||||
const _options = {
|
||||
hostname: url.hostname,
|
||||
port: url.port,
|
||||
path: url.pathname,
|
||||
method: args.method || 'GET',
|
||||
headers: args.headers
|
||||
};
|
||||
|
||||
console.log('noodl-cf-fetch');
|
||||
console.log(_options);
|
||||
console.log(args.body);
|
||||
|
||||
const httpx = url.protocol === 'https:' ? https : http;
|
||||
const req = httpx.request(_options, (res) => {
|
||||
let _data = '';
|
||||
res.on('data', (d) => {
|
||||
_data += d;
|
||||
});
|
||||
res.on('end', () => {
|
||||
const _response = {
|
||||
token,
|
||||
headers: res.headers,
|
||||
body: _data,
|
||||
status: res.statusCode
|
||||
};
|
||||
console.log('response', _response);
|
||||
sandbox.webContents.send('noodl-cf-fetch-response', _response);
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
console.log('error', error);
|
||||
sandbox.webContents.send('noodl-cf-fetch-response', {
|
||||
token,
|
||||
error: error
|
||||
});
|
||||
});
|
||||
|
||||
req.write(args.body);
|
||||
req.end();
|
||||
});
|
||||
|
||||
ipcMain.on('project-opened', startCloudRuntime);
|
||||
ipcMain.on('cloud-runtime-open-devtools', openCloudRuntimeDevTools);
|
||||
ipcMain.on('project-closed', closeCloudRuntime);
|
||||
ipcMain.on('cloud-runtime-refresh', refresh);
|
||||
|
||||
function handleRequest(request, response) {
|
||||
const headers = {
|
||||
'Access-Control-Allow-Origin': '*' /* @dev First, read about security */,
|
||||
'Access-Control-Allow-Methods': 'OPTIONS, POST',
|
||||
'Access-Control-Max-Age': 2592000, // 30 days
|
||||
'Access-Control-Allow-Headers': '*'
|
||||
};
|
||||
|
||||
if (request.method === 'OPTIONS') {
|
||||
response.writeHead(204, headers);
|
||||
response.end();
|
||||
} else if (request.method === 'POST') {
|
||||
var parsedUrl = URL.parse(request.url, true);
|
||||
|
||||
let path = decodeURI(parsedUrl.pathname);
|
||||
|
||||
if (path.startsWith('/functions/')) {
|
||||
const functionName = decodeURIComponent(path.split('/')[2]);
|
||||
|
||||
console.log('Calling cloud function: ' + functionName);
|
||||
if (!sandbox) {
|
||||
console.log('Error: No cloud runtime active...');
|
||||
return;
|
||||
}
|
||||
|
||||
var body = '';
|
||||
|
||||
request.on('data', function (data) {
|
||||
body += data;
|
||||
});
|
||||
|
||||
request.on('end', function () {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
|
||||
cloudServicesGetActive((cs) => {
|
||||
if (!cs) {
|
||||
response.writeHead(400, headers);
|
||||
response.end(JSON.stringify({ error: 'No active editor cloud services.' }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('with body ', body);
|
||||
|
||||
const token = guid();
|
||||
_responseHandlers[token] = (args) => {
|
||||
response.writeHead(args.statusCode, args.headers || headers);
|
||||
response.end(args.body);
|
||||
};
|
||||
|
||||
const cfRequestArgs = {
|
||||
function: functionName,
|
||||
token,
|
||||
headers: request.headers,
|
||||
body,
|
||||
cloudService: cs
|
||||
};
|
||||
if (hasLoadedProject) {
|
||||
sandbox.webContents.send('noodl-cf-request', cfRequestArgs);
|
||||
} else {
|
||||
queuedRequestsBeforeProjectLoaded.push(cfRequestArgs);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
|
||||
response.writeHead(400, headers);
|
||||
response.end(JSON.stringify({ error: 'Failed to run function.' }));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var server;
|
||||
if (process.env.ssl) {
|
||||
console.log('Using SSL');
|
||||
|
||||
const options = {
|
||||
key: fs.readFileSync(process.env.sslKey),
|
||||
cert: fs.readFileSync(process.env.sslCert)
|
||||
};
|
||||
server = https.createServer(options, handleRequest).listen(port);
|
||||
} else {
|
||||
server = http.createServer(handleRequest).listen(port);
|
||||
}
|
||||
|
||||
server.on('error', (e) => {
|
||||
if (e.code === 'EADDRINUSE') {
|
||||
}
|
||||
|
||||
const { dialog } = require('electron');
|
||||
dialog
|
||||
.showMessageBox({
|
||||
type: 'error',
|
||||
message: `A problem was encountered while starting Noodl's webserver\n\n${e.message}`
|
||||
})
|
||||
.then(() => {
|
||||
app.quit();
|
||||
});
|
||||
});
|
||||
|
||||
server.on('listening', (e) => {
|
||||
console.log('noodl cloud functions server running on port', port);
|
||||
process.env.NOODL_CLOUD_FUNCTIONS_PORT = port;
|
||||
});
|
||||
}
|
||||
|
||||
function closeRuntimeWhenWindowCloses(window) {
|
||||
window.on('closed', closeCloudRuntime);
|
||||
}
|
||||
|
||||
module.exports = { startCloudFunctionServer, openCloudRuntimeDevTools, closeRuntimeWhenWindowCloses };
|
||||
@@ -0,0 +1,87 @@
|
||||
const WebSocket = require('ws');
|
||||
const WebSocketServer = WebSocket.Server;
|
||||
const JSONStorage = require('../../shared/utils/jsonstorage');
|
||||
const { app } = require('electron');
|
||||
const fs = require('fs');
|
||||
|
||||
let win;
|
||||
let clientSockets = [];
|
||||
|
||||
let projectName = null;
|
||||
|
||||
function sendProjectName(ws, name) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'projectInfo',
|
||||
data: {
|
||||
name: projectName
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function start(projectGetInfo) {
|
||||
const port = Number(process.env.NOODLPORT || 8574) + 1; //use standard Noodl port + 1
|
||||
|
||||
var wss = new WebSocketServer({ port });
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
clientSockets.push(ws);
|
||||
|
||||
ws.on('close', () => {
|
||||
const idx = clientSockets.findIndex((w) => w === ws);
|
||||
if (idx !== -1) clientSockets.splice(idx, 1);
|
||||
});
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'editorInfo',
|
||||
data: {
|
||||
version: app.getVersion()
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
sendProjectName(ws, projectName);
|
||||
|
||||
let imageName;
|
||||
ws.on('message', (data, isBinary) => {
|
||||
const message = isBinary ? data : data.toString();
|
||||
|
||||
if (message instanceof Uint8Array) {
|
||||
//got image
|
||||
const name = imageName; //async hax trickery
|
||||
projectGetInfo((local) => {
|
||||
const path = local.projectDirectory + '/' + name;
|
||||
fs.writeFileSync(path, message);
|
||||
});
|
||||
} else {
|
||||
const request = JSON.parse(message);
|
||||
|
||||
if (request.type === 'export') {
|
||||
win.send('import-nodeset', request.data.nodeset);
|
||||
win.show(); //focus window
|
||||
} else if (request.type === 'exportImageName') {
|
||||
imageName = request.name;
|
||||
} else if (request.type === 'exportProjectMetadata') {
|
||||
win.send('import-projectmetadata', request.data.metadata);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setWindow(window) {
|
||||
win = window;
|
||||
}
|
||||
|
||||
function setProjectName(name) {
|
||||
projectName = name;
|
||||
clientSockets.forEach((ws) => sendProjectName(ws, name));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
start,
|
||||
setWindow,
|
||||
setProjectName
|
||||
};
|
||||
161
packages/noodl-editor/src/main/src/floating-window.js
Normal file
161
packages/noodl-editor/src/main/src/floating-window.js
Normal file
@@ -0,0 +1,161 @@
|
||||
const { BrowserWindow, ipcMain } = require('electron');
|
||||
|
||||
function FloatingWindow() {
|
||||
this.window = null;
|
||||
}
|
||||
|
||||
FloatingWindow.prototype.open = function ({ x, y, width, height, minWidth, minHeight, parent, url, alwaysShadow }) {
|
||||
if (this.window) return; //already open
|
||||
|
||||
this.dockedInParent = true;
|
||||
const parentBounds = parent.getBounds();
|
||||
|
||||
this.position = { x, y };
|
||||
|
||||
this.alwaysShadow = alwaysShadow || false;
|
||||
|
||||
this.window = new BrowserWindow({
|
||||
x: parentBounds.x + x,
|
||||
y: parentBounds.y + y,
|
||||
width,
|
||||
height,
|
||||
minWidth,
|
||||
minHeight,
|
||||
parent,
|
||||
resizable: true,
|
||||
frame: false,
|
||||
acceptFirstMouse: true,
|
||||
hasShadow: this.alwaysShadow,
|
||||
minimizable: false,
|
||||
webPreferences: {
|
||||
webSecurity: false,
|
||||
allowRunningInsecureContent: true,
|
||||
nodeIntegration: true,
|
||||
contextIsolation: false,
|
||||
webviewTag: true
|
||||
},
|
||||
backgroundThrottling: false,
|
||||
closable: false,
|
||||
fullscreenable: false,
|
||||
backgroundColor: '#131313'
|
||||
});
|
||||
|
||||
require('@electron/remote/main').enable(this.window.webContents);
|
||||
|
||||
this.window.loadURL(url);
|
||||
|
||||
this.onParentClosed = () => {
|
||||
this.close();
|
||||
};
|
||||
|
||||
parent.on('closed', this.onParentClosed);
|
||||
|
||||
if (process.platform !== 'darwin') {
|
||||
/* parent.on('move', () => {
|
||||
if(!this.dockedInParent || !this.window) return;
|
||||
|
||||
const parentBounds = parent.getBounds();
|
||||
this.window.setPosition(parentBounds.x + this.position.x, parentBounds.y + this.position.y);
|
||||
});*/
|
||||
}
|
||||
|
||||
this.window.on('move', () => {
|
||||
const parentBounds = parent.getBounds();
|
||||
const bounds = this.window.getBounds();
|
||||
|
||||
const inside =
|
||||
bounds.x >= parentBounds.x &&
|
||||
bounds.x <= parentBounds.x + parentBounds.width &&
|
||||
bounds.y >= parentBounds.y &&
|
||||
bounds.y <= parentBounds.y + parentBounds.height;
|
||||
|
||||
//set parent window to null when outside the editor so the viewer doesn't follow
|
||||
//the main window anymore.
|
||||
//This has no effect on windows, but since windows also doesn't move child
|
||||
//windows relative to the parent it's not needed
|
||||
if (process.platform !== 'win32') {
|
||||
if (!inside && this.dockedInParent) {
|
||||
this.window.setParentWindow(null);
|
||||
} else if (inside && !this.dockedInParent) {
|
||||
this.window.setParentWindow(parent);
|
||||
}
|
||||
}
|
||||
|
||||
this.dockedInParent = inside;
|
||||
if (!this.alwaysShadow) {
|
||||
this.window.setHasShadow(!inside); //only works on osx
|
||||
}
|
||||
|
||||
if (process.platform !== 'darwin') {
|
||||
//on macOS a child window moves with the parent, on the other platforms
|
||||
//we have implement it ourselves. Keep track of the relative position of the window
|
||||
this.position.x = bounds.x - parentBounds.x;
|
||||
this.position.y = bounds.y - parentBounds.y;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
FloatingWindow.prototype.close = function () {
|
||||
if (!this.window || this.window.isDestroyed()) return;
|
||||
|
||||
const parent = this.window.getParentWindow();
|
||||
if (parent) {
|
||||
parent.off('closed', this.onParentClosed);
|
||||
}
|
||||
this.window.destroy();
|
||||
this.window = null;
|
||||
};
|
||||
|
||||
FloatingWindow.prototype.show = function () {
|
||||
if (!this.window || this.window.isVisible()) return;
|
||||
|
||||
this.window.show();
|
||||
if (this.lastPosition) {
|
||||
this.window.setPosition(this.lastPosition[0], this.lastPosition[1], false);
|
||||
this.lastPosition = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
FloatingWindow.prototype.hide = function () {
|
||||
if (!this.window) return;
|
||||
|
||||
this.lastPosition = this.window.getPosition();
|
||||
this.window.hide();
|
||||
};
|
||||
|
||||
FloatingWindow.prototype.openDevTools = function () {
|
||||
if (!this.window) return;
|
||||
|
||||
if (this.window.webContents.isDevToolsOpened()) {
|
||||
this.window.webContents.closeDevTools();
|
||||
}
|
||||
|
||||
if (this.window.webContents.isDevToolsOpened()) {
|
||||
this.window.webContents.closeDevTools();
|
||||
this.window.webContents.openDevTools();
|
||||
} else {
|
||||
this.window.webContents.openDevTools();
|
||||
}
|
||||
};
|
||||
|
||||
FloatingWindow.prototype.send = function (event, ...args) {
|
||||
this.window && this.window.webContents.send(event, ...args);
|
||||
};
|
||||
|
||||
FloatingWindow.reload = function () {
|
||||
this.window && this.window.webContents.reload();
|
||||
};
|
||||
|
||||
FloatingWindow.prototype.forwardIpcEvents = function (events) {
|
||||
for (const eventName of events) {
|
||||
ipcMain.on(eventName, (e, ...args) => {
|
||||
this.window && this.window.webContents.send(eventName, ...args);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
FloatingWindow.prototype.isOpen = function () {
|
||||
return this.window ? true : false;
|
||||
};
|
||||
|
||||
module.exports = FloatingWindow;
|
||||
117
packages/noodl-editor/src/main/src/jsonpatch.js
Normal file
117
packages/noodl-editor/src/main/src/jsonpatch.js
Normal file
@@ -0,0 +1,117 @@
|
||||
if (typeof Array.isArray === 'undefined') {
|
||||
Array.isArray = function (obj) {
|
||||
return Object.prototype.toString.call(obj) === '[object Array]';
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
diff: function (base, compared, _p) {
|
||||
var path = _p || '';
|
||||
var ops = [];
|
||||
|
||||
// Loop through the compared object
|
||||
for (idx in compared) {
|
||||
var value = compared[idx];
|
||||
|
||||
// Recurse into object
|
||||
if (typeof value === 'object' && base.hasOwnProperty(idx)) {
|
||||
ops = ops.concat(this.diff(base[idx], value, path + '/' + idx));
|
||||
}
|
||||
// To get the added items
|
||||
else if (!base.hasOwnProperty(idx)) {
|
||||
ops.push({
|
||||
op: 'add',
|
||||
path: path + '/' + idx,
|
||||
value: value
|
||||
});
|
||||
|
||||
// The updated items
|
||||
} else if (value !== base[idx]) {
|
||||
ops.push({
|
||||
op: 'replace',
|
||||
path: path + '/' + idx,
|
||||
value: value
|
||||
});
|
||||
|
||||
// And the unchanged
|
||||
} else if (value === base[idx]) {
|
||||
}
|
||||
}
|
||||
|
||||
// Loop through the before object
|
||||
for (idx in base) {
|
||||
var value = base[idx];
|
||||
|
||||
// To get the deleted items
|
||||
if (!(idx in compared)) {
|
||||
ops.push({
|
||||
op: 'remove',
|
||||
path: path + '/' + idx
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return ops;
|
||||
},
|
||||
hash: function (o) {
|
||||
var s = JSON.stringify(o);
|
||||
var hash = 0;
|
||||
if (s.length == 0) {
|
||||
return hash;
|
||||
}
|
||||
for (var i = 0; i < s.length; i++) {
|
||||
var char = s.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash = hash & hash; // Convert to 32bit integer
|
||||
}
|
||||
return hash;
|
||||
},
|
||||
resolvePath: function (base, path) {
|
||||
var p = path.split('/');
|
||||
if (p[0] === '') p.shift(); // Remove leading empty
|
||||
var field = p.pop();
|
||||
|
||||
var ref = base;
|
||||
for (var j = 0; j < p.length; j++) ref = ref[p[j]];
|
||||
|
||||
return {
|
||||
ref: ref,
|
||||
field: field
|
||||
};
|
||||
},
|
||||
patch: function (base, ops) {
|
||||
var patched = JSON.parse(JSON.stringify(base));
|
||||
|
||||
for (var i = 0; i < ops.length; i++) {
|
||||
var op = ops[i];
|
||||
var p = op.path.split('/');
|
||||
if (p[0] === '') p.shift(); // Remove leading empty
|
||||
var field = p.pop();
|
||||
|
||||
var ref = patched;
|
||||
for (var j = 0; j < p.length; j++) ref = ref[p[j]];
|
||||
|
||||
if (op.op === 'remove') {
|
||||
delete ref[field];
|
||||
} else {
|
||||
if (Array.isArray(ref) && op.op === 'add' && field === '-') ref.push(op.value);
|
||||
else ref[field] = op.value;
|
||||
}
|
||||
}
|
||||
|
||||
// Purge "undefined" slots in arrays
|
||||
function purgeUndef(o) {
|
||||
for (var key in o) {
|
||||
var v = o[key];
|
||||
if (Array.isArray(v)) {
|
||||
o[key] = v.filter(function (i) {
|
||||
return i !== undefined;
|
||||
});
|
||||
} else if (typeof v === 'object') purgeUndef(v);
|
||||
}
|
||||
}
|
||||
purgeUndef(patched);
|
||||
|
||||
return patched;
|
||||
}
|
||||
};
|
||||
83
packages/noodl-editor/src/main/src/merge-driver.js
Normal file
83
packages/noodl-editor/src/main/src/merge-driver.js
Normal file
@@ -0,0 +1,83 @@
|
||||
const fs = require('fs');
|
||||
const { app } = require('electron');
|
||||
|
||||
const { readMergeDriverOptionsSync, cleanMergeDriverOptionsSync } = require('@noodl/git/src/merge-driver');
|
||||
|
||||
function datePathExt(date) {
|
||||
return date.toUTCString().replace(/[:\ ,]/g, '-');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
handleProjectMerge(args) {
|
||||
var mergeArgIndex = args.indexOf('--merge');
|
||||
var ancestorFileName = process.argv[mergeArgIndex + 1];
|
||||
var currentFileName = process.argv[mergeArgIndex + 2];
|
||||
var branchFileName = process.argv[mergeArgIndex + 3];
|
||||
|
||||
const options = readMergeDriverOptionsSync();
|
||||
cleanMergeDriverOptionsSync();
|
||||
|
||||
if (ancestorFileName && currentFileName && branchFileName) {
|
||||
const { mergeProject } = require('../../editor/src/utils/projectmerger');
|
||||
const { applyPatches } = require('../../editor/src/models/ProjectPatches/applypatches');
|
||||
|
||||
console.log('Merging Noodl project');
|
||||
|
||||
// Perform merge
|
||||
try {
|
||||
let ancestors = {};
|
||||
|
||||
try {
|
||||
ancestors = JSON.parse(fs.readFileSync(ancestorFileName, 'utf8'));
|
||||
applyPatches(ancestors);
|
||||
} catch (e) {
|
||||
console.log('failed to parse ancestors project file');
|
||||
}
|
||||
|
||||
let ours = JSON.parse(fs.readFileSync(currentFileName, 'utf8'));
|
||||
applyPatches(ours);
|
||||
|
||||
let theirs = JSON.parse(fs.readFileSync(branchFileName, 'utf8'));
|
||||
applyPatches(theirs);
|
||||
|
||||
if (options.reversed) {
|
||||
const tmp = ours;
|
||||
ours = theirs;
|
||||
theirs = tmp;
|
||||
}
|
||||
|
||||
const result = mergeProject(ancestors, ours, theirs);
|
||||
//git expects result to be written to the currentFileName path
|
||||
fs.writeFileSync(currentFileName, JSON.stringify(result, null, 4));
|
||||
|
||||
app.exit(0);
|
||||
} catch (e) {
|
||||
// Merge failed, write error to debug log
|
||||
console.error('merge failed', e);
|
||||
|
||||
try {
|
||||
const date = datePathExt(new Date());
|
||||
const userDataPath = app.getPath('userData');
|
||||
const logFile = userDataPath + '/debug/git-merge-failed-' + date + '.json';
|
||||
fs.writeFileSync(logFile, e.toString());
|
||||
|
||||
const anscestorsDebugFile = userDataPath + '/debug/git-ancestors-merge-project-' + date + '.json';
|
||||
const oursDebugFile = userDataPath + '/debug/git-ours-merge-project-' + date + '.json';
|
||||
const theirsDebugFile = userDataPath + '/debug/git-theirs-merge-project-' + date + '.json';
|
||||
fs.copyFileSync(ancestorFileName, anscestorsDebugFile);
|
||||
fs.copyFileSync(currentFileName, oursDebugFile);
|
||||
fs.copyFileSync(branchFileName, theirsDebugFile);
|
||||
} catch (e) {
|
||||
//do nothing if error log fails
|
||||
}
|
||||
|
||||
//exit with a failure code
|
||||
app.exit(1);
|
||||
}
|
||||
} else {
|
||||
console.log('invalid args', args);
|
||||
//exit with a failure code
|
||||
app.exit(1);
|
||||
}
|
||||
}
|
||||
};
|
||||
388
packages/noodl-editor/src/main/src/web-server.js
Normal file
388
packages/noodl-editor/src/main/src/web-server.js
Normal file
@@ -0,0 +1,388 @@
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const path = require('path');
|
||||
const URL = require('url');
|
||||
|
||||
const WebSocket = require('ws');
|
||||
const WebSocketServer = WebSocket.Server;
|
||||
|
||||
const ProjectModules = require('../../shared/utils/projectmodules');
|
||||
const JSONStorage = require('../../shared/utils/jsonstorage');
|
||||
|
||||
function parseRangeHeader(range, length) {
|
||||
if (!range || range.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = range.replace(/bytes=/, '').split('-');
|
||||
const partialstart = parts[0];
|
||||
const partialend = parts[1];
|
||||
|
||||
const start = parseInt(partialstart, 10);
|
||||
const end = parseInt(partialend, 10);
|
||||
|
||||
const result = {
|
||||
start: isNaN(start) ? 0 : start,
|
||||
end: isNaN(end) ? length - 1 : end
|
||||
};
|
||||
|
||||
if (!isNaN(start) && isNaN(end)) {
|
||||
result.start = start;
|
||||
result.end = length - 1;
|
||||
}
|
||||
|
||||
if (isNaN(start) && !isNaN(end)) {
|
||||
result.start = length - end;
|
||||
result.end = length - 1;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function startServer(app, projectGetSettings, projectGetInfo, projectGetComponentBundleExport) {
|
||||
const appPath = app.getAppPath();
|
||||
|
||||
//accept any certificate from localhost (e.g. self signed)
|
||||
app.on('certificate-error', (event, webContents, url, error, certificate, callback) => {
|
||||
if (url.startsWith('wss://localhost') || url.startsWith('https://localhost')) {
|
||||
event.preventDefault();
|
||||
callback(true);
|
||||
} else {
|
||||
console.log('reject certificate', url);
|
||||
callback(false);
|
||||
}
|
||||
});
|
||||
|
||||
var port = process.env.NOODLPORT || 8574;
|
||||
|
||||
function serveIndexFile(path, response) {
|
||||
fs.readFile(path, 'utf8', function (err, data) {
|
||||
if (err) {
|
||||
response.writeHead(404);
|
||||
response.end('internal error');
|
||||
}
|
||||
|
||||
projectGetInfo((info) => {
|
||||
ProjectModules.instance.injectIntoHtml(info.projectDirectory, data, '/', function (injected) {
|
||||
projectGetSettings((settings) => {
|
||||
settings = settings || {};
|
||||
injected = injected.replace('{{#title#}}', settings.htmlTitle || 'Noodl Viewer');
|
||||
injected = injected.replace('{{#customHeadCode#}}', settings.headCode || '');
|
||||
|
||||
response.writeHead(200, {
|
||||
'Content-Type': 'text/html'
|
||||
});
|
||||
response.end(injected);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function serveProjectBundle(path, response) {
|
||||
const idx = path.indexOf('/noodl_bundles/') + '/noodl_bundles/'.length;
|
||||
const name = decodeURI(path.substring(idx).replace('.json', ''));
|
||||
|
||||
projectGetComponentBundleExport(name, (data) => {
|
||||
if (!data) {
|
||||
response.writeHead(404);
|
||||
response.end('component not found');
|
||||
} else {
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
response.end(data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleRequest(request, response) {
|
||||
var parsedUrl = URL.parse(request.url, true);
|
||||
|
||||
let path = decodeURI(parsedUrl.pathname);
|
||||
|
||||
//previous versions of Noodl will request /external/viewer/index.html or /external/viewer/index.htmlnull
|
||||
//new version can also do this if old requests are cached by electron
|
||||
if (path === '/external/viewer/index.html' || path.endsWith('viewer/index.htmlnull')) {
|
||||
serveIndexFile(appPath + '/src/external/viewer/index.html', response);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.startsWith('/external/canvas/')) {
|
||||
if (path === '/external/canvas/index.html') {
|
||||
serveIndexFile(appPath + '/src' + path, response);
|
||||
//all done
|
||||
return;
|
||||
}
|
||||
//look in canvas folder for static files
|
||||
const fullPath = appPath + '/src' + path;
|
||||
if (fs.existsSync(fullPath)) {
|
||||
serveFile(fullPath, request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
//not done, strip away the index part of the path and continue
|
||||
path = path.replace('/external/canvas', '');
|
||||
} else if (path.startsWith('/external/viewer')) {
|
||||
//we're in the viewer folder, just strip away and proceed (treat it as the regular root path)
|
||||
path = path.replace('/external/viewer', '');
|
||||
}
|
||||
|
||||
//special bundle folder that requests dynamic data from editor
|
||||
if (path.includes('/noodl_bundles/')) {
|
||||
serveProjectBundle(path, response);
|
||||
return;
|
||||
}
|
||||
|
||||
//any folder, including root, serves the index (SPA app). Make any index.html just return the viewer
|
||||
//exclude noodl module folder (shouldn't be any folder requests, but who knows)
|
||||
if (path.includes('/noodl_modules/') === false && (path.endsWith('index.html') || path.includes('.') === false)) {
|
||||
serveIndexFile(appPath + '/src/external/viewer/index.html', response);
|
||||
return;
|
||||
}
|
||||
|
||||
//by this point it must be a static file in either the viewer folder or the project
|
||||
//check if it's a viewer file
|
||||
const viewerFilePath = appPath + '/src/external/viewer/' + path;
|
||||
if (fs.existsSync(viewerFilePath)) {
|
||||
serveFile(viewerFilePath, request, response);
|
||||
} else {
|
||||
// Check if file exists in project directory
|
||||
projectGetInfo((info) => {
|
||||
const projectPath = info.projectDirectory + path;
|
||||
if (fs.existsSync(projectPath)) {
|
||||
serveFile(projectPath, request, response);
|
||||
} else {
|
||||
serve404(response);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var server;
|
||||
if (process.env.ssl) {
|
||||
console.log('Using SSL');
|
||||
|
||||
const options = {
|
||||
key: fs.readFileSync(process.env.sslKey),
|
||||
cert: fs.readFileSync(process.env.sslCert)
|
||||
};
|
||||
server = https.createServer(options, handleRequest).listen(port);
|
||||
} else {
|
||||
server = http.createServer(handleRequest).listen(port);
|
||||
}
|
||||
|
||||
server.on('error', (e) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { dialog } = require('electron');
|
||||
dialog
|
||||
.showMessageBox({
|
||||
type: 'error',
|
||||
message: `A problem was encountered while starting Noodl's webserver\n\n${e.message}`
|
||||
})
|
||||
.then(() => {
|
||||
app.quit();
|
||||
});
|
||||
});
|
||||
|
||||
server.on('listening', (e) => {
|
||||
console.log('webserver hustling bytes on port', port);
|
||||
process.env.NOODLPORT = port;
|
||||
|
||||
startWebSocketServer(server);
|
||||
});
|
||||
}
|
||||
|
||||
function startWebSocketServer(server) {
|
||||
// Websocket server for sending updates and debugging
|
||||
var connectedSockets = [];
|
||||
var services = {};
|
||||
|
||||
function broadcastMessage(msg, type) {
|
||||
var broadcastToType = type === 'viewer' ? 'editor' : 'viewer';
|
||||
for (var i = 0; i < connectedSockets.length; i++) {
|
||||
var s = connectedSockets[i];
|
||||
if (!type || s.type === broadcastToType) {
|
||||
s.ws.readyState === WebSocket.OPEN && s.ws.send(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var wss = new WebSocketServer({
|
||||
server: server
|
||||
});
|
||||
|
||||
wss.on('connection', function (ws) {
|
||||
var handle = {
|
||||
ws: ws
|
||||
};
|
||||
connectedSockets.push(handle);
|
||||
|
||||
(function () {
|
||||
var _handle = handle;
|
||||
|
||||
ws.on('message', function (message) {
|
||||
var request = JSON.parse(message);
|
||||
if (request.cmd === 'register') {
|
||||
_handle.type = request.type;
|
||||
_handle.clientId = request.clientId;
|
||||
|
||||
// A viewer is connected, broadcast to editors
|
||||
if (request.type === 'viewer')
|
||||
broadcastMessage(
|
||||
JSON.stringify({
|
||||
cmd: 'registered',
|
||||
type: _handle.type,
|
||||
clientId: _handle.clientId
|
||||
}),
|
||||
_handle.type
|
||||
);
|
||||
|
||||
if (_handle.type === 'service' && request.service)
|
||||
// A new serivce is registered
|
||||
services[request.service] = handle;
|
||||
}
|
||||
// If this is a request to a service, pass it along to the service
|
||||
else if (request.service) {
|
||||
var s = services[request.service];
|
||||
s && s.ws.send(message);
|
||||
} else {
|
||||
// If there is a target client, send the message to that client
|
||||
if (request.target) {
|
||||
for (var i = 0; i < connectedSockets.length; i++)
|
||||
if (connectedSockets[i].clientId === request.target) connectedSockets[i].ws.send(message);
|
||||
}
|
||||
// Broadcast message to other connected sockets
|
||||
// message from viewers should go to connected editors and vice versa
|
||||
else broadcastMessage(message, _handle.type);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (e) => {
|
||||
console.log('ws error', e);
|
||||
});
|
||||
|
||||
ws.on('close', function () {
|
||||
const idx = connectedSockets.indexOf(_handle);
|
||||
const clientId = connectedSockets[idx].clientId;
|
||||
connectedSockets.splice(idx, 1);
|
||||
const msg = JSON.stringify({
|
||||
cmd: 'disconnect',
|
||||
clientId: clientId
|
||||
});
|
||||
broadcastMessage(msg, 'viewer'); // Notify editor that a viewer disconnected
|
||||
});
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
function getContentType(request) {
|
||||
var extname = path.extname(request.url);
|
||||
var contentType = 'text/html';
|
||||
switch (extname) {
|
||||
case '.js':
|
||||
contentType = 'text/javascript';
|
||||
break;
|
||||
case '.css':
|
||||
contentType = 'text/css';
|
||||
break;
|
||||
case '.json':
|
||||
contentType = 'application/json';
|
||||
break;
|
||||
case '.png':
|
||||
contentType = 'image/png';
|
||||
break;
|
||||
case '.webp':
|
||||
contentType = 'image/webp';
|
||||
break;
|
||||
case '.gif':
|
||||
contentType = 'image/gif';
|
||||
break;
|
||||
case '.jpg':
|
||||
contentType = 'image/jpg';
|
||||
break;
|
||||
case '.wav':
|
||||
contentType = 'audio/wav';
|
||||
// eslint-disable-next-line no-fallthrough
|
||||
case '.mp4':
|
||||
case '.m4v':
|
||||
contentType = 'video/mp4';
|
||||
break;
|
||||
case '.wasm':
|
||||
contentType = 'application/wasm';
|
||||
break;
|
||||
case '.svg':
|
||||
contentType = 'image/svg+xml';
|
||||
break;
|
||||
case '.ttf':
|
||||
contentType = 'font/ttf';
|
||||
break;
|
||||
}
|
||||
|
||||
return contentType;
|
||||
}
|
||||
|
||||
function serveFile(filePath, request, response) {
|
||||
fs.stat(decodeURI(filePath), (error, stat) => {
|
||||
if (error) {
|
||||
response.writeHead(404);
|
||||
response.end(error.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
const range = parseRangeHeader(request.headers.range, stat.size);
|
||||
if (range) {
|
||||
const start = range.start;
|
||||
const end = range.end;
|
||||
|
||||
if (start >= stat.size || end >= stat.size) {
|
||||
response.writeHead(416, {
|
||||
'Content-Type': getContentType(request),
|
||||
'Content-Range': 'bytes */' + stat.size
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileStream = fs.createReadStream(decodeURI(filePath), {
|
||||
start: range.start,
|
||||
end: range.end
|
||||
});
|
||||
|
||||
fileStream.on('error', function (err) {
|
||||
response.writeHead(404);
|
||||
response.end(err.message);
|
||||
});
|
||||
|
||||
const responseHeaders = {
|
||||
'Content-Range': 'bytes ' + start + '-' + end + '/' + stat.size,
|
||||
'Content-Length': start == end ? 0 : end - start + 1,
|
||||
'Content-Type': getContentType(request),
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Cache-Control': 'no-cache'
|
||||
};
|
||||
|
||||
response.writeHead(206, responseHeaders);
|
||||
fileStream.pipe(response);
|
||||
} else {
|
||||
response.writeHead(200, {
|
||||
'Content-Type': getContentType(request),
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET'
|
||||
});
|
||||
const fileStream = fs.createReadStream(decodeURI(filePath));
|
||||
fileStream.on('error', function (err) {
|
||||
response.writeHead(404);
|
||||
response.end(err.message);
|
||||
});
|
||||
|
||||
fileStream.pipe(response);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function serve404(response) {
|
||||
response.writeHead(404);
|
||||
response.end('file not found');
|
||||
}
|
||||
|
||||
module.exports = startServer;
|
||||
Reference in New Issue
Block a user