// desktop.js // Electron script to run Hyperspace as an app // © 2018 Hyperspace developers. Licensed under NPL v1. const { app, Menu, protocol, BrowserWindow, shell, systemPreferences } = require('electron'); const windowStateKeeper = require('electron-window-state'); const { autoUpdater } = require('electron-updater'); const path = require('path'); // Check for any updates to the app autoUpdater.checkForUpdatesAndNotify(); // Create a container for the window let mainWindow; // Register the "hyperspace://" protocol so that it supports // HTTPS and acts like a standard protocol instead of a fake // file:// protocol, which is necessary for Mastodon to redirect // to when authorizing Hyperspace. protocol.registerSchemesAsPrivileged([ { scheme: 'hyperspace', privileges: { standard: true, secure: true } } ]); /** * Determine whether the desktop app is on macOS * - Returns: Boolean of whether platform is Darwin */ function isDarwin() { return process.platform === "darwin"; } /** * Register the protocol for Hyperspace */ function registerProtocol() { protocol.registerFileProtocol('hyperspace', (request, callback) => { // Check to make sure we're doing a GET request if (request.method !== "GET") { callback({error: -322}); return null; } // Check to make sure we're actually working with a hyperspace // protocol and that the host is 'hyperspace' const parsedUrl = new URL(request.url); if (parsedUrl.protocol !== "hyperspace:") { callback({error: -302}); return; } if (parsedUrl.host !== "hyperspace") { callback({error: -105}); return; } // Convert the parsed URL to a list of strings. const target = parsedUrl.pathname.split("/"); // Check that the target isn't trying to go somewhere // else. If it is, throw a "FILE_NOT_FOUND" error if (target[0] !== "") { callback({error: -6}); return; } // Check if the last target item in the list is empty. // If so, replace it with "index.html" so that it can // load a page. if (target[target.length -1] === "") { target[target.length -1] = "index.html"; } // Check the middle target and redirect to the appropriate // build files of the desktop app when running. let baseDirectory; if (target[1] === "app" || target[1] === "oauth") { baseDirectory = __dirname + "/../build/"; } else { // If it doesn't match above, throw a "FILE_NOT_FOUND" error. callback({error: -6}); } // Create a normalized version of the string. baseDirectory = path.normalize(baseDirectory); // Check to make sure the target isn't trying to go out of bounds. // If it is, throw a "FILE_NOT_FOUND" error. const relTarget = path.normalize(path.join(...target.slice(2))); if (relTarget.startsWith('..')) { callback({error: -6}); return; } // Create the absolute target path and return it. const absTarget = path.join(baseDirectory, relTarget); callback({ path: absTarget }); }, (error) => { if (error) console.error('Failed to register protocol'); }); } /** * Create the window and all of its properties */ function createWindow() { // Create a window state manager that keeps track of the width // and height of the main window. let mainWindowState = windowStateKeeper({ defaultHeight: 624, defaultWidth: 1024 }); // Create a browser window with some settings mainWindow = new BrowserWindow( { // Use the values from the window state keeper // to draw the window exactly as it was left. // If not possible, derive it from the default // values defined earlier. x: mainWindowState.x, y: mainWindowState.y, width: mainWindowState.width, height: mainWindowState.height, // Set a minimum width to prevent element collisions. minWidth: 300, // Set important web preferences. webPreferences: {nodeIntegration: true}, // Set some preferences that are specific to macOS. titleBarStyle: 'hiddenInset', vibrancy: "sidebar", transparent: isDarwin(), backgroundColor: isDarwin()? "#80000000": "#FFF", // Hide the window until the contents load show: false } ); // Set up event listeners to track changes in the window state. mainWindowState.manage(mainWindow); // Load the main app and open the index page. mainWindow.loadURL("hyperspace://hyperspace/app/"); // Watch for a change in macOS's dark mode and reload the window to apply changes, as well as accent color if (isDarwin()) { systemPreferences.subscribeNotification('AppleInterfaceThemeChangedNotification', () => { if (mainWindow != null) { mainWindow.webContents.reload(); } }); systemPreferences.subscribeNotification('AppleColorPreferencesChangedNotification', () => { if (mainWindow != null) { mainWindow.webContents.reload(); } }); } // Only show the window when ready mainWindow.once('ready-to-show', () => { mainWindow.show(); }); // Delete the window when closed mainWindow.on('closed', () => { mainWindow = null }); // Hijack any links with a blank target and open them in the default // browser instead of a new Electron window mainWindow.webContents.on('new-window', (event, url) => { event.preventDefault(); shell.openExternal(url); }); } /** * Go to a URL in the main window. If it doesn't exist, * create the window and then navigate to it. * @param url The URL to visit in the main window */ function safelyGoTo(url) { if (mainWindow == null) { registerProtocol(); createWindow(); } mainWindow.loadURL(url); } /** * Create the menu bar and attach it to a window */ function createMenubar() { // Create an instance of the Menu class let menu = Menu; // Create a menu bar template const menuBar = [ { label: 'File', submenu: [ { label: 'New Window', accelerator: 'CmdOrCtrl+N', click() { if (mainWindow == null) { registerProtocol(); createWindow(); } } }, { label: 'New Post', accelerator: 'Shift+CmdOrCtrl+N', click() { safelyGoTo("hyperspace://hyperspace/app/#compose") } } ] }, { label: 'Edit', submenu: [ { role: 'undo' }, { role: 'redo' }, { type: 'separator' }, { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'pasteandmatchstyle' }, { role: 'delete' }, { role: 'selectall' } ] }, { label: 'View', submenu: [ { label: 'Back', accelerator: 'CmdOrCtrl+[', click() { if (mainWindow != null && mainWindow.webContents.canGoBack()) { mainWindow.webContents.goBack() } } }, { label: 'Forward', accelerator: 'CmdOrCtrl+]', click() { if (mainWindow != null && mainWindow.webContents.canGoForward()) { mainWindow.webContents.goForward() } } }, { role: 'reload' }, { role: 'forcereload' }, { type: 'separator' }, { label: 'Open Dev Tools', click () { try { mainWindow.webContents.openDevTools(); } catch (err) { console.error("Couldn't open dev tools: " + err); } }, accelerator: 'Shift+CmdOrCtrl+I' }, { type: 'separator' }, { role: 'togglefullscreen' } ] }, { label: "Timelines", submenu: [ { label: 'Home', accelerator: "CmdOrCtrl+0", click() { safelyGoTo("hyperspace://hyperspace/app/#/home") } }, { label: 'Local', accelerator: "CmdOrCtrl+1", click() { safelyGoTo("hyperspace://hyperspace/app/#/local") } }, { label: 'Public', accelerator: "CmdOrCtrl+2", click() { safelyGoTo("hyperspace://hyperspace/app/#/public") } }, { label: 'Messages', accelerator: "CmdOrCtrl+3", click() { safelyGoTo("hyperspace://hyperspace/app/#/messages") } } ] }, { label: "Account", submenu: [ { label: 'Notifications', accelerator: "Alt+CmdOrCtrl+N", click() { safelyGoTo("hyperspace://hyperspace/app/#/notifications") } }, { label: 'Recommendations...', accelerator: "Alt+CmdOrCtrl+R", click() { safelyGoTo("hyperspace://hyperspace/app/#/recommended") } }, { type: 'separator' }, { label: 'Edit Profile', accelerator: "Shift+CmdOrCtrl+P", click() { safelyGoTo("hyperspace://hyperspace/app/#/you") } }, { label: 'Blocked Servers', accelerator: "Shift+CmdOrCtrl+B", click() { safelyGoTo("hyperspace://hyperspace/app/#/blocked") } }, { type: 'separator'}, { label: 'Switch Accounts...', click() { safelyGoTo("hyperspace://hyperspace/app/#/welcome") } } ] }, { role: 'window', submenu: [ { role: 'minimize' }, { role: 'close' }, { type: 'separator' }, ] }, { role: 'help', submenu: [ { label: 'Hyperspace Desktop Docs', click () { require('electron').shell.openExternal('https://hyperspace.marquiskurt.net/docs/') } }, { label: 'Report a Bug', click () { require('electron').shell.openExternal('https://github.com/hyperspacedev/hyperspace/issues') } }, { type: 'separator' }, { label: 'Acknowledgements', click () { require('electron').shell.openExternal('https://github.com/hyperspacedev/hyperspace/blob/master/patreon.md') } } ] } ]; if (process.platform === 'darwin') { menuBar.unshift({ label: app.getName(), submenu: [ { label: `About ${app.getName()}`, click() { safelyGoTo("hyperspace://hyperspace/app/#/about") } }, { type: 'separator' }, { label: "Preferences...", accelerator: 'Cmd+,', click() { safelyGoTo("hyperspace://hyperspace/app/#/settings"); } }, { type: 'separator' }, { role: 'services' }, { type: 'separator' }, { role: 'hide' }, { role: 'hideothers' }, { role: 'unhide' }, { type: 'separator' }, { role: 'quit' } ] }); // Edit menu menuBar[2].submenu.push( { type: 'separator' }, { label: 'Speech', submenu: [ { role: 'startspeaking' }, { role: 'stopspeaking' } ] } ); // Window menu menuBar[6].submenu = [ { role: 'close' }, { role: 'minimize' }, { role: 'zoom' }, { type: 'separator' }, { role: 'front' } ] } // Create the template for the menu and attach it to the application const thisMenu = menu.buildFromTemplate(menuBar); menu.setApplicationMenu(thisMenu); } // When the app is ready, create the window and menu bar app.on('ready', () => { registerProtocol(); createWindow(); createMenubar(); }); // Standard quit behavior changes for macOS app.on('window-all-closed', () => { if (!isDarwin()) { app.quit() } }); // When the app is activated, create the window and menu bar app.on('activate', () => { if (mainWindow === null) { createWindow(); createMenubar(); } });