Skip to content
Snippets Groups Projects
index.js 9.34 KiB
/* eslint-disable import/order */
// (I have no idea why the imports are like this. Not touching them.)
const isDev = require('electron-is-dev');
const fs = require('fs');

require('module').globalPaths.push(__dirname + '/..');

// Allow unsecure in dev
if (isDev) {
  process.env['NODE_TLS_REJECT_UNAUTHORIZED'] = '0';
}

const {
  app,
  ipcMain,
  BrowserWindow,
  Menu,
  dialog,
  shell,
  protocol,
} = require('electron');
const promiseRetry = require('promise-retry');

// This allows relative URLs to be resolved to app:// which makes
// local assets load correctly
protocol.registerSchemesAsPrivileged([
  { scheme: 'app', privileges: { standard: true } },
]);

global.fetch = require('node-fetch');

const about = require('./about');
const { getRandomPort } = require('get-port-please');
const getMenu = require('./menu');
const updater = require('./updater');

require('./security');

const { fork } = require('child_process');
const path = require('path');

require('./setRequireHook');

if (!isDev || !process.env.ACTUAL_DOCUMENT_DIR) {
  process.env.ACTUAL_DOCUMENT_DIR = app.getPath('documents');
}

if (!isDev || !process.env.ACTUAL_DATA_DIR) {
  process.env.ACTUAL_DATA_DIR = app.getPath('userData');
}

// eslint-disable-next-line import/extensions
const WindowState = require('./window-state.js');

// Keep a global reference of the window object, if you don't, the window will
// be closed automatically when the JavaScript object is garbage collected.
let clientWin;
let serverWin; // eslint-disable-line @typescript-eslint/no-unused-vars
let serverProcess;
let serverSocket;
let IS_QUITTING = false;

updater.onEvent((type, data) => {
  // Notify both the app and the about window
  if (clientWin) {
    clientWin.webContents.send(type, data);
  }

  if (about.getWindow()) {
    about.getWindow().webContents.send(type, data);
  }
});

if (isDev) {
  process.traceProcessWarnings = true;
}

function createBackgroundProcess(socketName) {
  serverProcess = fork(
    __dirname + '/server.js',
    ['--subprocess', app.getVersion(), socketName],
    isDev ? { execArgv: ['--inspect'] } : undefined,
  );

  serverProcess.on('message', msg => {
    switch (msg.type) {
      case 'captureEvent':
      case 'captureBreadcrumb':
        break;
      case 'shouldAutoUpdate':
        if (msg.flag) {
          updater.start();
        } else {
          updater.stop();
        }
        break;
      default:
        console.log('Unknown server message: ' + msg.type);
    }
  });
}

async function createWindow() {
  const windowState = await WindowState.get();

  // Create the browser window.
  const win = new BrowserWindow({
    x: windowState.x,
    y: windowState.y,
    width: windowState.width,
    height: windowState.height,
    title: 'Actual',
    webPreferences: {
      nodeIntegration: false,
      nodeIntegrationInWorker: false,
      nodeIntegrationInSubFrames: false,
      contextIsolation: true,
      enableRemoteModule: false,
      preload: __dirname + '/preload.js',
    },
  });
  win.setBackgroundColor('#E8ECF0');

  if (isDev) {
    win.webContents.openDevTools();
  }

  const unlistenToState = WindowState.listen(win, windowState);

  if (isDev) {
    win.loadURL(`file://${__dirname}/loading.html`);
    // Wait for the development server to start
    setTimeout(() => {
      promiseRetry(retry => win.loadURL('http://localhost:3001/').catch(retry));
    }, 3000);
  } else {
    win.loadURL(`app://actual/`);
  }

  win.on('close', () => {
    // We don't want to close the budget on exit because that will
    // clear the state which re-opens the last budget automatically on
    // startup
    if (!IS_QUITTING) {
      clientWin.webContents.executeJavaScript('__actionsForMenu.closeBudget()');
    }
  });

  win.on('closed', () => {
    clientWin = null;
    updateMenu(false);
    unlistenToState();
  });

  win.on('unresponsive', () => {
    console.log(
      'browser window went unresponsive (maybe because of a modal though)',
    );
  });

  win.on('focus', async () => {
    let url = clientWin.webContents.getURL();
    if (url.includes('app://') || url.includes('localhost:')) {
      clientWin.webContents.executeJavaScript('__actionsForMenu.focused()');
    }
  });

  win.webContents.on('did-finish-load', () => {
    win.webContents.send('set-socket', { name: serverSocket });
  });

  // hit when middle-clicking buttons or <a href/> with a target set to _blank
  // always deny, optionally redirect to browser
  win.webContents.setWindowOpenHandler(({ url }) => {
    if (isExternalUrl(url)) {
      shell.openExternal(url);
    }

    return { action: 'deny' };
  });

  // hit when clicking <a href/> with no target
  // optionally redirect to browser
  win.webContents.on('will-navigate', (event, url) => {
    if (isExternalUrl(url)) {
      shell.openExternal(url);
      event.preventDefault();
    }
  });

  if (process.platform === 'win32') {
    Menu.setApplicationMenu(null);
    win.setMenu(getMenu(isDev, createWindow));
  } else {
    Menu.setApplicationMenu(getMenu(isDev, createWindow));
  }

  clientWin = win;
}

function isExternalUrl(url) {
  return !url.includes('localhost:') && !url.includes('app://');
}

function updateMenu(isBudgetOpen) {
  const menu = getMenu(isDev, createWindow);
  const file = menu.items.filter(item => item.label === 'File')[0];
  const fileItems = file.submenu.items;
  fileItems
    .filter(item => item.label === 'Load Backup...')
    .map(item => (item.enabled = isBudgetOpen));

  let tools = menu.items.filter(item => item.label === 'Tools')[0];
  tools.submenu.items.forEach(item => {
    item.enabled = isBudgetOpen;
  });

  const edit = menu.items.filter(item => item.label === 'Edit')[0];
  const editItems = edit.submenu.items;
  editItems
    .filter(item => item.label === 'Undo' || item.label === 'Redo')
    .map(item => (item.enabled = isBudgetOpen));

  if (process.platform === 'win32') {
    if (clientWin) {
      clientWin.setMenu(menu);
    }
  } else {
    Menu.setApplicationMenu(menu);
  }
}

app.setAppUserModelId('com.shiftreset.actual');

app.on('ready', async () => {
  serverSocket = await getRandomPort();

  // Install an `app://` protocol that always returns the base HTML
  // file no matter what URL it is. This allows us to use react-router
  // on the frontend
  protocol.registerFileProtocol('app', (request, callback) => {
    if (request.method !== 'GET') {
      callback({ error: -322 }); // METHOD_NOT_SUPPORTED from chromium/src/net/base/net_error_list.h
      return null;
    }

    const parsedUrl = new URL(request.url);
    if (parsedUrl.protocol !== 'app:') {
      callback({ error: -302 }); // UNKNOWN_URL_SCHEME
      return;
    }

    if (parsedUrl.host !== 'actual') {
      callback({ error: -105 }); // NAME_NOT_RESOLVED
      return;
    }

    const pathname = parsedUrl.pathname;

    if (pathname.startsWith('/static')) {
      callback({
        path: path.normalize(`${__dirname}/client-build${pathname}`),
      });
    } else {
      callback({
        path: path.normalize(`${__dirname}/client-build/index.html`),
      });
    }
  });

  if (process.argv[1] !== '--server') {
    await createWindow();
  }

  // This is mainly to aid debugging Sentry errors - it will add a
  // breadcrumb
  require('electron').powerMonitor.on('suspend', () => {
    console.log('Suspending', new Date());
  });
  createBackgroundProcess(serverSocket);
});

app.on('window-all-closed', () => {
  // On macOS, closing all windows shouldn't exit the process
  if (process.platform !== 'darwin') {
    app.quit();
  }
});

app.on('before-quit', () => {
  IS_QUITTING = true;
  if (serverProcess) {
    serverProcess.kill();
    serverProcess = null;
  }
});

app.on('activate', () => {
  if (clientWin === null) {
    createWindow();
  }
});

ipcMain.on('get-bootstrap-data', event => {
  event.returnValue = {
    version: app.getVersion(),
    isDev,
  };
});

ipcMain.handle('relaunch', () => {
  app.relaunch();
  app.exit();
});

ipcMain.handle('open-file-dialog', (event, { filters, properties }) => {
  return dialog.showOpenDialogSync({
    properties: properties || ['openFile'],
    filters,
  });
});

ipcMain.handle(
  'save-file-dialog',
  (event, { title, defaultPath, fileContents }) => {
    let fileLocation = dialog.showSaveDialogSync({ title, defaultPath });

    return new Promise((resolve, reject) => {
      if (fileLocation) {
        fs.writeFile(fileLocation, fileContents, error => {
          return reject(error);
        });
      }
      resolve();
    });
  },
);

ipcMain.handle('open-external-url', (event, url) => {
  shell.openExternal(url);
});

ipcMain.on('show-about', () => {
  about.openAboutWindow();
});

ipcMain.on('screenshot', () => {
  if (isDev) {
    let width = 1100;

    // This is for the main screenshot inside the frame
    clientWin.setSize(width, Math.floor(width * (427 / 623)));
    // clientWin.setSize(width, Math.floor(width * (495 / 700)));
  }
});

ipcMain.on('check-for-update', () => {
  // If the updater is in the middle of an update already, send the
  // about window the current status
  if (updater.isChecking()) {
    // This should always come from the about window so we can
    // guarantee that it exists. If we ever see an error here
    // something is wrong
    about.getWindow().webContents.send(updater.getLastEvent());
  } else {
    updater.check();
  }
});

ipcMain.on('apply-update', () => {
  updater.apply();
});

ipcMain.on('update-menu', (event, isBudgetOpen) => {
  updateMenu(isBudgetOpen);
});