Code Study: GitHub Desktop / main-process / main.ts

Source code: github-desktop/app/src/main-process/main.ts

This is the entry point of the main process.

1. Enable source maps by calling enableSourceMaps

It uses the source-map-support npm module.

2. Register unhandled exception handler

When uncaught exception happens from inside the main process, it will first make a copy of the error with a source-mapped stack trace. If it couldn’t perform the source mapping, it’ll use the original error stack. Next it reports the error to central.github.com/api/desktop/exception. (See exception-reporting.ts). Last, in handleUncaughtException, it destroy the main window, and show the uncaught exception UI. When usercloses the uncaught exception UI, the app quits. (see show-uncaught-exception.ts)

process.on('uncaughtException', (error: Error) => {
  error = withSourceMappedStack(error)
  reportError(error)
  handleUncaughtException(error)
})

3. In Windows, deal with squirrel event and startup arguments

If the current OS is Windows and the app startup argument has at least one argument,

let handlingSquirrelEvent = false
if (__WIN32__ && process.argv.length > 1) {
  const arg = process.argv[1]
  const promise = handleSquirrelEvent(arg)
  if (promise) {
    handlingSquirrelEvent = true
    promise
      .catch(e => {
        log.error(`Failed handling Squirrel event: ${arg}`, e)
      })
      .then(() => {
        app.quit()
      })
  } else {
    handlePossibleProtocolLauncherArgs(process.argv)
  }
}

4. Make the app a single-instance app

let isDuplicateInstance = false
// If we're handling a Squirrel event we don't want toenforce single instance.
// We want to let the updated instance launch and do itswork. It will then quit
// once it's done.
if (!handlingSquirrelEvent) {
  isDuplicateInstance = app.makeSingleInstance((args, workingDirectory) => {
    // Someone tried to run a second instance, we should focus our window.
    if (mainWindow) {
      if (mainWindow.isMinimized()) {
        mainWindow.restore()
      }
      if (!mainWindow.isVisible()) {
        mainWindow.show()
      }
      mainWindow.focus()
    }
    handlePossibleProtocolLauncherArgs(args)
  })
  if (isDuplicateInstance) {
    app.quit()
  }
}

5. In macOS, inspect whether the current process needs to be patched to get important environment variables for Desktop

This is to work and integrate with other tools the user may invoke as part of their workflow. This is only applied to macOS installations due to how the application is launched. If the current process needs to be patched,update the current process’s environment variables using environment variables from the user’s shell, if theycan be retrieved successfully.

if (shellNeedsPatching(process)) {
  updateEnvironmentForProcess()
}

6. When the app will finish launching, handle the open-url event for macOS

According to the doc: ‘will-finish-launching’ is emitted when the application has finished basic startup. On Windows and Linux, the ‘will-finish-launching’ event is the same as the ready event; on macOS, this event represents the applicationWillFinishLaunching notification of NSApplication. You would usually set up listeners for the open-file and open-url events here, and start the crash reporter and auto updater.

‘open-url’ is an event for macOS only. It is emitted when the user wants to open a URL with the application. Here we call handleAppURL to handle the URL. (See How URL Action Works in GitHub Desktop)

app.on('will-finish-launching', () => {
  // macOS only
  app.on('open-url', (event, url) => {
    event.preventDefault()
    handleAppURL(url)
  })
})

7. In macOS, handle the ‘open-file’ event

In macOS, the app only allows dropping a folder (not a file) onto the app, and it will open the folder.

if (__DARWIN__) {
  app.on('open-file', async (event, path) => {
    event.preventDefault()
    log.info(`[main] a path to ${path} was triggered`)
    Fs.stat(path, (err, stats) => {
      if (err) {
        log.error(`Unable to open path '${path}' in Desktop`, err)
        return
      }
      if (stats.isFile()) {
        log.warn(
          `A file at ${path} was dropped onto Desktop, but it can only handle folders. Ignoring this action.`
        )
        return
      }
      handleAppURL(
        `x-github-client://openLocalRepo/${encodeURIComponent(path)}`
      )
    })
  })
}

8. If ‘hardware accelration is not allowed’, disable hardware acceleration

if (process.env.GITHUB_DESKTOP_DISABLE_HARDWARE_ACCELERATION {
  log.info(
    `GITHUB_DESKTOP_DISABLE_HARDWARE_ACCELERATION environment variable set, disabling hardware acceleration`
  )
  app.disableHardwareAcceleration()
}

9. Handle the ‘ready’ event

  1. If the current process is a duplicate instance of the app, or the squirell update instance, do nothing and return directly.
  2. Calculate the time span from the launch time to ready time.
  3. Set the app as the default protocol handler for x-github-client, and x-github-client-auth.
     setAsDefaultProtocolClient('x-github-client')
     if (__DEV__) {
       setAsDefaultProtocolClient('x-github-desktop-dev-auth')
     } else {
       setAsDefaultProtocolClient('x-github-desktop-auth')
     }
    
  4. Create the main window (createWindow())
    • Create a new instance of AppWindow -> window -> mainWindow in app.
    • If the current env is dev env, set up the electron dev tools, electron debug, chromelens, react developer tools, and react perf.
    • Handle the window close event (onClose)

        window.onClose(() => {
          mainWindow = null
          if (!__DARWIN__ && !preventQuit) {
            app.quit()
          }
        })
      
    • Register window onDidLoad, aka. ‘did-load’, event handler. At this point, the window has not finished loading. It’s just registering the ‘did-load’ event handler. Later we will call window.load to do the actual loading. When the loading is finished, the event handler here will be invoked to show and give focus to the window (window.show()), and call all the registered onDidLoad functions in main app.

        window.onDidLoad(() => {
          window.show()
          window.sendLaunchTimingStats({
            mainReadyTime: readyTime!,
            loadTime: window.loadTime!,
            rendererReadyTime: window.rendererReadyTime!,
          })
      
          const fns = onDidLoadFns!
          onDidLoadFns = null
          for (const fn of fns) {
            fn(window)
          }
        })
      
    • Call window.load() to do the actual window loading. At the end of this, it will trigger the onDidLoad event handler registered in the last step.
  5. Set the application menu

     let menu = buildDefaultMenu({})
     Menu.setApplicationMenu(menu)
    
  6. Handle the update-preferred-app-menu-item-labels IPC message to update the menu item labels with the user’s preferred apps. This is emitted when user changes the default external editor or shell in application options. The ‘Open in PowerShell’ and ‘Open in Visual Studio Code’ menu labels will change accordingly.

    update preferred app menu item labels

     ipcMain.on(
       'update-preferred-app-menu-item-labels',
       (event: Electron.IpcMessageEvent, labels: MenuLabels) => {
         menu = buildDefaultMenu(labels)
         Menu.setApplicationMenu(menu)
         if (mainWindow) {
           mainWindow.sendAppMenu()
         }
       }
     )
    
  7. Handle the menu-event IPC message. See How Menu Event Works in GitHub Desktop to understand how menu event works between main process and renderer process.

     ipcMain.on('menu-event', (event: Electron.IpcMessageEvent, args: any[]) => {
       const { name }: { name: MenuEvent } = event as any
       if (mainWindow) {
         mainWindow.sendMenuEvent(name)
       }
     })
    
  8. Handle the execute-menu-item IPC message. Via this message the react AppMenu component, when clicked, tells the main process to execute (i.e. simulate a click of) the corresponding Electron menu item. When the simulation click happens, the Electron menu item emits the ‘menu-event’ IPC message with the event name. This triggers the step 7 ipcMain.on('menu-event', ....).

     ipcMain.on(
       'execute-menu-item',
       (event: Electron.IpcMessageEvent, { id }: { id: string }) => {
         const menuItem = findMenuItemByID(menu, id)
         if (menuItem) {
           const window = BrowserWindow.fromWebContents(event.sender)
           const fakeEvent = { preventDefault: () => {}, sender: event.sender }
           menuItem.click(fakeEvent, window, event.sender)
         }
       }
     )
    
  9. Handle the update-menu-state IPC message. It sets the Electron menu item’s enabledness based on the passed-in menu item state data model, and further sends the app menu to the renderer to update the react AppMenu enabledness state.

     ipcMain.on(
       'update-menu-state',
       (
         event: Electron.IpcMessageEvent,
         items: Array<{ id: string; state: IMenuItemState }>
       ) => {
         let sendMenuChangedEvent = false
         for (const item of items) {
           const { id, state } = item
           const menuItem = findMenuItemByID(menu, id)
           if (menuItem) {
             // Only send the updated app menu when the state actually changes
             // or we might end up introducing a never ending loop between
             // the renderer and the main process
             if (state.enabled !== undefined && menuItem.enabled !== state.enabled) {
               menuItem.enabled = state.enabled
               sendMenuChangedEvent = true
             }
           } else {
             fatalError(`Unknown menu id: ${id}`)
           }
         }
         if (sendMenuChangedEvent && mainWindow) {
           mainWindow.sendAppMenu()
         }
       }
     )
    
  10. Handle the show-contextual-menu IPC message to show a context menu. The passed-in parameters include an array of menu item data models for building the context menu, and the event object for figuring out the event sender window.

    ipcMain.on(
      'show-contextual-menu',
      (event: Electron.IpcMessageEvent, items: ReadonlyArray<IMenuItem>) => {
        const menu = buildContextMenu(items, ix =>
          event.sender.send('contextual-menu-action', ix)
        )
    
        const window = BrowserWindow.fromWebContents(event.sender)
        menu.popup({ window })
      }
    )
    
  11. Handle the get-app-menu IPC message. This is an event sent by the renderer asking for a copy of the current application menu.

    ipcMain.on('get-app-menu', () => {
      if (mainWindow) {
        mainWindow.sendAppMenu()
      }
    })
    
  12. Handle the show-certificate-trust-dialog IPC message. This only applies to Windows and macOS. On macOS, this displays a modal dialog that shows a message and certificate information, and gives the user the option of trusting/importing the certificate. On Windows the options are more limited. This event message is emitted when user uses an untrusted cert to connect to the git server:

    using untrusted certification

    ipcMain.on(
      'show-certificate-trust-dialog',
      (
        event: Electron.IpcMessageEvent,
        {
          certificate,
          message,
        }: { certificate: Electron.Certificate; message: string }
      ) => {
        // This API is only implemented for macOS and Windows right now.
        if (__DARWIN__ || __WIN32__) {
          onDidLoad(window => {
            window.showCertificateTrustDialog(certificate, message)
          })
        }
      }
    )
    
  13. Handle the log IPC message to write the given log entry to all configured transports. See initializeWinston in log.ts for more details about what transports we set up. We use the winston logger here.

    ipcMain.on(
      'log',
      (event: Electron.IpcMessageEvent, level: LogLevel, message: string) => {
        writeLog(level, message)
      }
    )
    
  14. Handle the uncaught-exception IPC message sent from the renderer process. When the render process has an unhandled exception, renderer sends this IPC message to the main process so that the main process can capture and handle it too. By comparision, the earlier call to process.on('uncaughtException', ... in main.ts captures any unhandled exceptions raised from the main process.

    ipcMain.on(
      'uncaught-exception',
      (event: Electron.IpcMessageEvent, error: Error) => {
        handleUncaughtException(error)
      }
    )
    
  15. Handle the send-error-report IPC message. When the renderer process has an unhandled exception, renderer sends this message to the main process to report the error. After that, renderer sends the uncaught-exception shown in the last step to the main process.

    ipcMain.on(
      'send-error-report',
      (
        event: Electron.IpcMessageEvent,
        { error, extra }: { error: Error; extra: { [key: string]: string } }
      ) => {
        reportError(error, extra)
      }
    )
    
  16. Handle the open-external IPC message to open the given external protocol URL in the desktop’s default manner. (For example, mailto: URLs in the user’s default mail agent).
  ipcMain.on(
    'open-external',
    (event: Electron.IpcMessageEvent, { path }: { path: string }) => {
      const pathLowerCase = path.toLowerCase()
      if (
        pathLowerCase.startsWith('http://') ||
        pathLowerCase.startsWith('https://')
      ) {
        log.info(`opening in browser: ${path}`)
      }

      const result = shell.openExternal(path)
      event.sender.send('open-external-result', { result })
    }
  )
  1. Handle the show-item-in-folder IPC message to show the given file or directory in a file manager.

    ipcMain.on(
      'show-item-in-folder',
      (event: Electron.IpcMessageEvent, { path }: { path: string }) => {
        Fs.stat(path, (err, stats) => {
          if (err) {
            log.error(`Unable to find file at '${path}'`, err)
            return
          }
    
          if (!__DARWIN__ && stats.isDirectory()) {
            openDirectorySafe(path)
          } else {
            shell.showItemInFolder(path)
          }
        })
      }
    )
    

10. Handle the ‘activate’ event

Upon activation, and if the window has already been loaded, we simply show the window here.

app.on('activate', () => {
  onDidLoad(window => {
    window.show()
  })
})

11. Handle the web-contents-created event and the ‘new-window’ event from webContents

webContents is an EventEmitter. It is responsible for rendering and controlling a web page and is a property of the BrowserWindow object. After webContents event emitter gets created, new-window is emitted when the page requests to open a new window for a url. It could be requested by window.open or an external link like <a target='_blank'>. By default a new BrowserWindow will be created for the url. Here we call event.preventDefault() to prevent Electron from automatically creating a new BrowserWindow.

app.on('web-contents-created', (event, contents) => {
  contents.on('new-window', (event, url) => {
    // Prevent links or window.open from opening new windows
    event.preventDefault()
    log.warn(`Prevented new window to: ${url}`)
  })
})

12. Handle the certificate-error event

The event is emitted when failed to verify the certificate for url, to trust the certificate you should prevent the default behavior with event.preventDefault() and call callback(true). Here we decide not to trust the certification, and send the certification error message to the renderer and pop up the untrusted certification.

app.on(
  'certificate-error',
  (event, webContents, url, error, certificate, callback) => {
    callback(false)

    onDidLoad(window => {
      window.sendCertificateError(certificate, error, url)
    })
  }
)
tags: GitHub Desktop - Electron