Home Reference Source

cables_dev/cables_electron/src/electron/main.js

import { app, BrowserWindow, dialog, Menu, shell, clipboard, nativeTheme, nativeImage } from "electron";
import path from "path";
import localShortcut from "electron-localshortcut";
import fs from "fs";
import electronEndpoint from "./electron_endpoint.js";
import electronApi from "./electron_api.js";
import logger from "../utils/logger.js";
import settings from "./electron_settings.js";
import doc from "../utils/doc_util.js";
import projectsUtil from "../utils/projects_util.js";
import filesUtil from "../utils/files_util.js";
import helper from "../utils/helper_util.js";
// this needs to be imported like this to not have to asarUnpack the entire nodejs world - sm,25.07.2024
import Npm from "../../node_modules/npm/lib/npm.js";

app.commandLine.appendSwitch("disable-http-cache");
app.commandLine.appendSwitch("force_high_performance_gpu");
app.commandLine.appendSwitch("unsafely-disable-devtools-self-xss-warnings");
app.commandLine.appendSwitch("lang", "EN");
app.commandLine.appendSwitch("autoplay-policy", "no-user-gesture-required");
app.commandLine.appendSwitch("no-user-gesture-required", "true");

app.disableDomainBlockingFor3DAPIs();

logger.info("--- starting");

class ElectronApp
{
    constructor()
    {
        this._log = logger;
        this.appName = "name" in app ? app.name : app.getName();
        this.appIcon = nativeImage.createFromPath("../../resources/cables.png");

        this.editorWindow = null;

        settings.set("uiLoadStart", this._log.loadStart);
        this._log.logStartup("started electron");

        process.on("uncaughtException", (error) =>
        {
            this._handleError(this.appName + " encountered an error", error);
        });

        process.on("unhandledRejection", (error) =>
        {
            this._handleError(this.appName + " encountered an error", error);
        });

        app.on("browser-window-created", (event, win) =>
        {
            if (settings.get(settings.OPEN_DEV_TOOLS_FIELD))
            {
                win.webContents.once("dom-ready", this._toggleDevTools.bind(this));
            }
        });

        nativeTheme.themeSource = "dark";
    }

    init()
    {
        this._createWindow();
        this._createMenu();
        this._loadNpm();
    }

    _loadNpm(cb = null)
    {
        try
        {
            this._npm = new Npm({
                "argv": [
                    "--no-save",
                    "--no-package-lock",
                    "--legacy-peer-deps",
                    "--no-progress",
                    "--no-color",
                    "--yes",
                    "--no-fund",
                    "--no-audit"
                ],
                "excludeNpmCwd": true,
            });
            this._npm.load().then(() =>
            {
                this._log.info("loaded npm", this._npm.version);
            });
        }
        catch (e)
        {
            this._log.error("failed to load npm", e);
        }
    }

    async installPackages(targetDir, packageNames, opName = null)
    {
        if (!targetDir || !packageNames || packageNames.length === 0) return { "stdout": "nothing to install", "packages": [] };
        let result = { "stdout": "", "stderr": "", "packages": packageNames, "targetDir": targetDir };
        if (opName) result.opName = opName;
        this._npm.config.localPrefix = targetDir;
        const logToVariable = (level, ...args) =>
        {
            switch (level)
            {
            case "standard":
                args.forEach((arg) =>
                {
                    result.stdout += arg;
                });
                break;
            case "error":
                args.forEach((arg) =>
                {
                    result.stderr += arg;
                });
                break;
            case "buffer":
            case "flush":
            default:
            }
        };
        process.on("output", logToVariable);
        this._log.debug("installing", packageNames, "to", opName, targetDir);
        try
        {
            await this._npm.exec("install", packageNames);
        }
        catch (e)
        {
            result.stderr += e;
        }
        process.off("output", logToVariable);
        if (fs.existsSync(path.join(targetDir, "package.json"))) fs.rmSync(path.join(targetDir, "package.json"));
        if (fs.existsSync(path.join(targetDir, "package-lock.json"))) fs.rmSync(path.join(targetDir, "package-lock.json"));
        return result;
    }

    _createWindow()
    {
        let patchFile = null;
        const openLast = settings.getUserSetting("openlastproject", false);
        if (openLast)
        {
            const projectFile = settings.getCurrentProjectFile();
            if (fs.existsSync(projectFile)) patchFile = projectFile;
        }
        this.editorWindow = new BrowserWindow({
            "width": 1920,
            "height": 1080,
            "backgroundColor": "#222",
            "icon": this.appIcon,
            "autoHideMenuBar": true,
            "webPreferences": {
                "defaultEncoding": "utf-8",
                "partition": settings.SESSION_PARTITION,
                "nodeIntegration": true,
                "nodeIntegrationInWorker": true,
                "nodeIntegrationInSubFrames": true,
                "contextIsolation": false,
                "sandbox": false,
                "webSecurity": false,
                "allowRunningInsecureContent": true,
                "plugins": true,
                "experimentalFeatures": true,
                "v8CacheOptions": "none",
                "backgroundThrottling": false,
                "autoplayPolicy": "no-user-gesture-required"
            }
        });

        this._initCaches(() =>
        {
            this._registerListeners();
            this._registerShortcuts();
            this.openPatch(patchFile).then(() =>
            {
                this._log.logStartup("electron loaded");
            });
        });
    }

    async pickProjectFileDialog()
    {
        let title = "select patch";
        let properties = ["openFile"];
        return this._projectFileDialog(title, properties);
    }

    async pickFileDialog(filePath, asUrl = false, filter = [])
    {
        let title = "select file";
        let properties = ["openFile"];
        return this._fileDialog(title, filePath, asUrl, filter, properties);
    }

    async saveProjectFileDialog()
    {
        const extensions = [];
        extensions.push(projectsUtil.CABLES_PROJECT_FILE_EXTENSION);

        let title = "select patch";
        let properties = ["createDirectory"];
        return dialog.showSaveDialog(this.editorWindow, {
            "title": title,
            "properties": properties,
            "filters": [{
                "name": "cables project",
                "extensions": extensions,
            }]
        }).then((result) =>
        {
            if (!result.canceled)
            {
                let patchFile = result.filePath;
                if (!patchFile.endsWith(projectsUtil.CABLES_PROJECT_FILE_EXTENSION))
                {
                    patchFile += "." + projectsUtil.CABLES_PROJECT_FILE_EXTENSION;
                }
                const currentProject = settings.getCurrentProject();
                if (currentProject)
                {
                    currentProject.name = path.basename(patchFile);
                    currentProject.summary = currentProject.summary || {};
                    currentProject.summary.title = currentProject.name;
                    projectsUtil.writeProjectToFile(patchFile, currentProject);
                }
                return patchFile;
            }
            else
            {
                return null;
            }
        });
    }

    async pickOpDirDialog()
    {
        const title = "select op directory";
        const properties = ["openDirectory", "createDirectory"];
        return this._dirDialog(title, properties);
    }

    _createMenu()
    {
        const isOsX = process.platform === "darwin";
        let devToolsAcc = "CmdOrCtrl+Shift+I";
        let inspectElementAcc = "CmdOrCtrl+Shift+C";
        let consoleAcc = "CmdOrCtrl+Shift+J";
        if (isOsX)
        {
            devToolsAcc = "CmdOrCtrl+Option+I";
            inspectElementAcc = "CmdOrCtrl+Option+C";
            consoleAcc = "CmdOrCtrl+Option+J";
        }

        const menuTemplate = [
            {
                "role": "appMenu",
                "label": "cables",
                "submenu": [
                    {
                        "label": "About Cables",
                        "click": () =>
                        {
                            this._showAbout();
                        }
                    },
                    {
                        "type": "separator"
                    },
                    {
                        "role": "quit",
                        "label": "Quit",
                        "accelerator": "CmdOrCtrl+Q",
                        "click": () =>
                        {
                            app.quit();
                        }
                    }
                ]
            },
            {
                "label": "File",
                "submenu": [
                    {
                        "label": "New patch",
                        "accelerator": "CmdOrCtrl+N",
                        "click": () =>
                        {
                            this.openPatch();
                        }
                    },
                    {
                        "label": "Open patch",
                        "accelerator": "CmdOrCtrl+O",
                        "click": () =>
                        {
                            this.pickProjectFileDialog();
                        }
                    },
                    {
                        "label": "Open Recent",
                        "role": "recentdocuments",
                        "submenu": [
                            {
                                "label": "Clear Recent",
                                "role": "clearrecentdocuments"
                            }
                        ]
                    }
                ]
            },
            {
                "label": "Edit",
                "submenu": [
                    { "role": "undo" }, { "role": "redo" },
                    { "type": "separator" },
                    { "role": "cut" },
                    { "role": "copy" },
                    { "role": "paste" },
                    { "role": "selectAll" },

                ]
            },
            {
                "label": "Window",
                "submenu": [
                    {
                        "role": "minimize",
                    },
                    {
                        "role": "zoom",
                        "visible": isOsX
                    },
                    { "role": "togglefullscreen" },
                    { "type": "separator" },
                    {
                        "label": "Zoom In",
                        "accelerator": "CmdOrCtrl+Plus",
                        "click": () =>
                        {
                            this._zoomIn();
                        }
                    },
                    {
                        "label": "Zoom Out",
                        "accelerator": "CmdOrCtrl+-",
                        "click": () =>
                        {
                            this._zoomOut();
                        }
                    },
                    {
                        "label": "Reset Zoom",
                        "click": () =>
                        {
                            this._resetZoom();
                        }
                    },
                    { "type": "separator" },
                    {
                        "label": "Developer Tools",
                        "accelerator": devToolsAcc,
                        "click": () =>
                        {
                            this._toggleDevTools();
                        }
                    },
                    {
                        "label": "Insepect elements",
                        "accelerator": inspectElementAcc,
                        "click": () =>
                        {
                            this._inspectElements();
                        }
                    },
                    {
                        "label": "JavaScript Console",
                        "accelerator": consoleAcc,
                        "click": () =>
                        {
                            this._toggleDevTools();
                        }
                    },
                    { "role": "close", "visible": false }
                ]
            }
        ];
        // prevent osx from showin currently running process as name (e.g. `npm`)
        if (process.platform == "darwin") { menuTemplate.unshift({ "label": "" }); }
        let menu = Menu.buildFromTemplate(menuTemplate);

        Menu.setApplicationMenu(menu);
    }

    async openPatch(patchFile, rebuildCache = true)
    {
        this._unsavedContentLeave = false;
        const open = async () =>
        {
            electronApi.loadProject(patchFile);
            this.updateTitle();
            await this.editorWindow.loadFile("index.html");
            this._log.logStartup("loaded", patchFile);
            const userZoom = settings.get(settings.WINDOW_ZOOM_FACTOR); // maybe set stored zoom later
            this._resetZoom();
            if (rebuildCache) doc.rebuildOpCaches(() => { this._log.logStartup("rebuild op caches"); }, ["core", "teams", "extensions"], true);
        };

        if (this.isDocumentEdited())
        {
            const leave = this._unsavedContentDialog();
            if (leave)
            {
                await open();
            }
        }
        else
        {
            await open();
        }
    }

    updateTitle()
    {
        const buildInfo = settings.getBuildInfo();
        let title = "cables";
        if (buildInfo && buildInfo.api)
        {
            if (buildInfo.api.version)
            {
                title += " - " + buildInfo.api.version;
            }
        }
        const projectFile = settings.getCurrentProjectFile();
        if (projectFile)
        {
            title = title + " - " + projectFile;
        }
        const project = settings.getCurrentProject();
        if (project)
        {
            this.sendTalkerMessage("updatePatchName", { "name": project.name });
            this.sendTalkerMessage("updatePatchSummary", { "summary": project.summary });
        }

        this.editorWindow.setTitle(title);
    }

    _dirDialog(title, properties)
    {
        return dialog.showOpenDialog(this.editorWindow, {
            "title": title,
            "properties": properties
        }).then((result) =>
        {
            if (!result.canceled)
            {
                return result.filePaths[0];
            }
            else
            {
                return null;
            }
        });
    }

    _fileDialog(title, filePath = null, asUrl = false, extensions = ["*"], properties = null)
    {
        if (extensions)
        {
            extensions.forEach((ext, i) =>
            {
                if (ext.startsWith(".")) extensions[i] = ext.replace(".", "");
            });
        }
        const options = {
            "title": title,
            "properties": properties,
            "filters": [{ "name": "Assets", "extensions": extensions }]
        };
        if (filePath) options.defaultPath = filePath;
        return dialog.showOpenDialog(this.editorWindow, options).then((result) =>
        {
            if (!result.canceled)
            {
                if (!asUrl) return result.filePaths[0];
                return helper.pathToFileURL(result.filePaths[0], true);
            }
            else
            {
                return null;
            }
        });
    }

    _projectFileDialog(title, properties)
    {
        const extensions = [];
        extensions.push(projectsUtil.CABLES_PROJECT_FILE_EXTENSION);

        return dialog.showOpenDialog(this.editorWindow, {
            "title": title,
            "properties": properties,
            "filters": [{
                "name": "cables project",
                "extensions": extensions,
            }]
        }).then((result) =>
        {
            if (!result.canceled)
            {
                let projectFile = result.filePaths[0];
                this.openPatch(projectFile);
                return projectFile;
            }
            else
            {
                return null;
            }
        });
    }

    reload()
    {
        const projectFile = settings.getCurrentProjectFile();
        this.openPatch(projectFile, false).then(() => { this._log.debug("reloaded", projectFile); });
    }

    setDocumentEdited(edited)
    {
        this._contentChanged = edited;
    }


    isDocumentEdited()
    {
        return this._contentChanged || this.editorWindow.isDocumentEdited();
    }

    cycleFullscreen()
    {
        if (this.editorWindow.isFullScreen())
        {
            this.editorWindow.setMenuBarVisibility(true);
            this.editorWindow.setFullScreen(false);
        }
        else
        {
            this.editorWindow.setMenuBarVisibility(false);
            this.editorWindow.setFullScreen(true);
        }
    }

    sendTalkerMessage(cmd, data)
    {
        this.editorWindow.webContents.send("talkerMessage", { "cmd": cmd, "data": data });
    }

    _registerShortcuts()
    {
        let devToolsAcc = "CmdOrCtrl+Shift+I";
        let inspectElementAcc = "CmdOrCtrl+Shift+C";
        if (process.platform === "darwin") devToolsAcc = "CmdOrCtrl+Option+I";

        // https://github.com/sindresorhus/electron-debug/blob/main/index.js
        localShortcut.register(this.editorWindow, inspectElementAcc, this._inspectElements.bind(this));
        localShortcut.register(this.editorWindow, devToolsAcc, this._toggleDevTools.bind(this));
        localShortcut.register(this.editorWindow, "F12", this._toggleDevTools.bind(this));
        localShortcut.register(this.editorWindow, "CommandOrControl+R", this._reloadWindow.bind(this));
        localShortcut.register(this.editorWindow, "F5", this._reloadWindow.bind(this));
        localShortcut.register(this.editorWindow, "CmdOrCtrl+O", this.pickProjectFileDialog.bind(this));
        localShortcut.register(this.editorWindow, "CmdOrCtrl+=", this._zoomIn.bind(this));
        localShortcut.register(this.editorWindow, "CmdOrCtrl+Plus", this._zoomIn.bind(this));
        localShortcut.register(this.editorWindow, "CmdOrCtrl+-", this._zoomOut.bind(this));
    }

    _toggleDevTools()
    {
        if (this.editorWindow.webContents.isDevToolsOpened())
        {
            this.editorWindow.webContents.closeDevTools();
        }
        else
        {
            this.editorWindow.webContents.openDevTools({ "mode": "previous" });
        }
    }

    _inspectElements()
    {
        const inspect = () =>
        {
            this.editorWindow.devToolsWebContents.executeJavaScript("DevToolsAPI.enterInspectElementMode()");
        };

        if (this.editorWindow.webContents.isDevToolsOpened())
        {
            inspect();
        }
        else
        {
            this.editorWindow.webContents.once("devtools-opened", inspect);
            this.editorWindow.openDevTools();
        }
    }

    _reloadWindow()
    {
        this.editorWindow.webContents.reloadIgnoringCache();
    }

    _registerListeners()
    {
        app.on("open-file", (e, p) =>
        {
            if (p.endsWith("." + projectsUtil.CABLES_PROJECT_FILE_EXTENSION) && fs.existsSync(p))
            {
                this.openPatch(p, true);
            }
        });
        app.on("browser-window-created", (e, win) =>
        {
            win.setMenuBarVisibility(false);
        });

        this.editorWindow.webContents.on("will-prevent-unload", (event) =>
        {
            if (!this._unsavedContentLeave && this.isDocumentEdited())
            {
                const leave = this._unsavedContentDialog();
                if (leave) event.preventDefault();
            }
            else
            {
                event.preventDefault();
            }
        });
        this.editorWindow.webContents.setWindowOpenHandler(({ url }) =>
        {
            if (url && url.startsWith("http"))
            {
                shell.openExternal(url);
                return { "action": "deny" };
            }
            return { "action": "allow" };
        });

        this.editorWindow.webContents.on("devtools-opened", (event, win) =>
        {
            settings.set(settings.OPEN_DEV_TOOLS_FIELD, true);
        });

        this.editorWindow.webContents.on("devtools-closed", (event, win) =>
        {
            settings.set(settings.OPEN_DEV_TOOLS_FIELD, false);
        });
    }

    _zoomIn()
    {
        let newZoom = this.editorWindow.webContents.getZoomFactor() + 0.2;
        this.editorWindow.webContents.setZoomFactor(newZoom);
        settings.set(settings.WINDOW_ZOOM_FACTOR, newZoom);
    }

    _zoomOut()
    {
        let newZoom = this.editorWindow.webContents.getZoomFactor() - 0.2;
        newZoom = Math.round(newZoom * 100) / 100;
        if (newZoom > 0)
        {
            this.editorWindow.webContents.setZoomFactor(newZoom);
            settings.set(settings.WINDOW_ZOOM_FACTOR, newZoom);
        }
    }

    _resetZoom()
    {
        this.editorWindow.webContents.setZoomFactor(1.0);
    }

    _initCaches(cb)
    {
        doc.addOpsToLookup([], true);
        cb();
    }

    _handleError(title, error)
    {
        this._log.error(title, error);
        if (app.isReady())
        {
            const buttons = [
                "&Reload",
                "&New Patch",
                "&Quit",
                process.platform === "darwin" ? "Copy Error" : "Copy error",
            ];
            const buttonIndex = dialog.showMessageBoxSync({
                "type": "error",
                buttons,
                "defaultId": 0,
                "noLink": true,
                "message": title,
                "detail": error.stack,
                "normalizeAccessKeys": true
            });
            if (buttonIndex === 0)
            {
                this.reload();
            }
            if (buttonIndex === 1)
            {
                this.openPatch(null);
            }
            if (buttonIndex === 2)
            {
                app.quit();
            }
            if (buttonIndex === 3)
            {
                clipboard.writeText(title + "\n" + error.stack);
            }
        }
        else
        {
            dialog.showErrorBox(title, (error.stack));
        }
    }

    _unsavedContentDialog()
    {
        if (this._unsavedContentLeave) return true;
        const choice = dialog.showMessageBoxSync(this.editorWindow, {
            "type": "question",
            "buttons": ["Leave", "Stay"],
            "title": "unsaved content!",
            "message": "unsaved content!",
            "defaultId": 0,
            "cancelId": 1
        });
        this._unsavedContentLeave = (choice === 0);
        return this._unsavedContentLeave;
    }

    _showAbout()
    {
        const options = {
            "icon": this.appIcon,
            "type": "info",
            "buttons": [],
            "message": "cables standalone",
        };

        const buildInfo = settings.getBuildInfo();
        if (buildInfo)
        {
            let versionText = "";
            if (buildInfo.api.git)
            {
                if (buildInfo.api.version)
                {
                    versionText += "version: " + buildInfo.api.version + "\n";
                }
                else
                {
                    versionText += "local build" + "\n\n";
                    if (buildInfo.api.git)
                    {
                        versionText += "branch: " + buildInfo.api.git.branch + "\n";
                        versionText += "message: " + buildInfo.api.git.message + "\n";
                    }
                }
                if (buildInfo.api.git.tag) versionText += "tag: " + buildInfo.api.git.tag + "\n";
            }
            if (buildInfo.api.platform)
            {
                versionText += "\n";
                if (buildInfo.api.platform.node) versionText += "node: " + buildInfo.api.platform.node + "\n";
                if (buildInfo.api.platform.npm) versionText += "npm: " + buildInfo.api.platform.npm;
            }
            options.detail = versionText;
        }
        dialog.showMessageBox(options);
    }
}
Menu.setApplicationMenu(null);


app.whenReady().then(() =>
{
    electronApp.init();
    electronApi.init();
    electronEndpoint.init();
    app.on("activate", () =>
    {
        if (BrowserWindow.getAllWindows().length === 0) electronApp.init();
    });
});



app.on("window-all-closed", () =>
{
    app.quit();
});
app.on("will-quit", (event) =>
{
    event.preventDefault();
    filesUtil.unregisterChangeListeners().then(() =>
    {
        process.exit(0);
    }).catch((e) =>
    {
        console.error("error during shutdown", e);
        process.exit(1);
    });
});
const electronApp = new ElectronApp();
export default electronApp;