Skip to content

Instantly share code, notes, and snippets.

@0xdevalias
Last active May 3, 2024 06:12
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save 0xdevalias/428e56a146e3c09ec129ee58584583ba to your computer and use it in GitHub Desktop.
Save 0xdevalias/428e56a146e3c09ec129ee58584583ba to your computer and use it in GitHub Desktop.
Debugging Electron Apps (and related memory issues)

Debugging Electron Apps (and related memory issues)

Table of Contents

Unsorted


eg.

Open the electron app, and enable debugging for the main electron process:

open /Applications/Beeper.app --args --inspect=1337

Connect to this debug process from chrome://inspect/#devices

Run the following in the DevTools console:

const _electron = require('electron')

// Get "Array of ProcessMetric objects that correspond to memory and CPU usage statistics of all the processes associated with the app."
// https://www.electronjs.org/docs/latest/api/app#appgetappmetrics
// https://www.electronjs.org/docs/latest/api/structures/process-metric
// https://www.electronjs.org/docs/latest/api/structures/memory-info
_electron.app.getAppMetrics()
// Looking at the PID's of the object returned, and contrasting them against the Process Name in macOS' Activity Monitor:
//  [0] with `type: 'Browser'` seems to correspond to the main 'Beeper' process
//  [1] with `type: 'GPU', serviceName: 'GPU'` seems to correspond to the 'Beeper Helper (GPU)' process
//  [2] with `type: 'Utility', serviceName: 'network.mojom.NetworkService', name: 'Network Service'` seems to correspond to the 'Beeper Helper' process
//  [3] with `type: 'Tab'` seems to correspond to the 'Beeper Helper (Renderer)' process

// Open devtools for the first webContents (can do similar for others if there are more than one listed)
_electron.webContents.getAllWebContents()[0].openDevTools()

// Find the webContents for the main Beeper app
const beeperWebContents = _electron.webContents.getAllWebContents().find(wc => wc.mainFrame.origin === 'nova://nova-web')

// Access the .mainFrame
beeperWebContents.mainFrame

// View the preload paths
beepereWebContents._getPreloadPaths()
// [
//   '/Applications/Beeper.app/Contents/Resources/app.asar/node_modules/@sentry/electron/preload/index.js',
//   '/Applications/Beeper.app/Contents/Resources/app.asar/lib/preload.js'
// ]

Extract the *.asar bundle code:

⇒ npm install -g @electron/asar

⇒ nodenv rehash

⇒ asar -h
Usage: asar [options] [command]

Manipulate asar archive files

Options:
  -V, --version                         output the version number
  -h, --help                            display help for command

Commands:
  pack|p [options] <dir> <output>       create asar archive
  list|l [options] <archive>            list files of asar archive
  extract-file|ef <archive> <filename>  extract one file from archive
  extract|e <archive> <dest>            extract archive
  *
  help [command]                        display help for command
  
⇒ ls /Applications/Beeper.app/Contents/Resources/*.asar
/Applications/Beeper.app/Contents/Resources/app.asar
/Applications/Beeper.app/Contents/Resources/webapp.asar

⇒ asar extract /Applications/Beeper.app/Contents/Resources/app.asar ~/Desktop/Beeper-app.asar

⇒ asar extract /Applications/Beeper.app/Contents/Resources/webapp.asar ~/Desktop/Beeper-webapp.asar

See Also

My Other Related Deepdive Gist's and Projects

@0xdevalias
Copy link
Author

0xdevalias commented May 3, 2023

I also added a bunch of electron/asar helper aliases to my dotfiles just now, which might be helpful:

@0xdevalias
Copy link
Author

0xdevalias commented Jul 14, 2023

Edit: This has also been duplicated across to my Beeper CSS Hacks gist as well:


On macOS you can get to the Beeper *.asar files by:

cd /Applications/Beeper.app/Contents/Resources

⇒ ls
af.lproj/           cs.lproj/      fa.lproj/   icon.icns  ml.lproj/     ru.lproj/  todesktop-runtime-config.json
am.lproj/           da.lproj/      fi.lproj/   icons/     mr.lproj/     sk.lproj/  tr.lproj/
app-update.yml      de.lproj/      fil.lproj/  id.lproj/  ms.lproj/     sl.lproj/  uk.lproj/
app.asar            el.lproj/      fr.lproj/   it.lproj/  nb.lproj/     sr.lproj/  ur.lproj/
app.asar.unpacked/  en.lproj/      gu.lproj/   ja.lproj/  nl.lproj/     sv.lproj/  vi.lproj/
ar.lproj/           en_GB.lproj/   he.lproj/   kn.lproj/  pl.lproj/     sw.lproj/  webapp.asar
bg.lproj/           es.lproj/      hi.lproj/   ko.lproj/  pt_BR.lproj/  ta.lproj/  zh_CN.lproj/
bn.lproj/           es_419.lproj/  hr.lproj/   lt.lproj/  pt_PT.lproj/  te.lproj/  zh_TW.lproj/
ca.lproj/           et.lproj/      hu.lproj/   lv.lproj/  ro.lproj/     th.lproj/

Where the most relevant files/folders there are:

  • app.asar
  • app.asar.unpacked/
  • webapp.asar

From memory, I believe app.asar is more related to the core electron/element type features, and webapp.asar was more related to the more custom Beeper features; but I didn't look super deeply into that side of things.

We can then use the node asar package via npx to inspect the contents of the *.asar files:

⇒ npx asar list --is-pack app.asar | grep -v node_modules

⇒ npx asar list --is-pack webapp.asar | grep -v node_modules

We can then run Beeper passing the node remote debugging --inspect-brk command to set a breakpoint at the entrypoint of the code:

⇒ open /Applications/Beeper.app --args --inspect-brk=1337

Which we can then connect to by opening a Chrome browser, navigating to chrome://inspect/#devices, and under 'Remote Target' looking for something like the following:

image

electron/js2c/browser_init file:///

Then clicking on 'inspect', which will open the Chrome DevTools 'Sources' tab and show the entrypoint line where the debugger has stopped execution, in this case, in the file:///Applications/Beeper.app/Contents/Resources/app.asar/lib/electron-main.js file:

image

We can then skim around the code in this file to understand what it does, and what other options are available.

For example, here are some command line arguments documentation; of which --devtools sounds interesting:

if (argv["help"]) {
    console.log("Options:");
    console.log("  --profile-dir {path}: Path to where to store the profile.");
    console.log("  --profile {name}:     Name of alternate profile to use, allows for running multiple accounts.");
    console.log("  --devtools:           Install and use react-devtools and react-perf.");
    console.log("  --no-update:          Disable automatic updating.");
    console.log("  --default-frame:      Use OS-default window decorations.");
    console.log("  --hidden:             Start the application hidden in the system tray.");
    console.log("  --help:               Displays this help message.");
    console.log("And more such as --proxy, see:" +
        "https://electronjs.org/docs/api/command-line-switches");
    electron_1.app.exit();
}

We can also see some path loading aspects of where the app looks for webapp.asar:

// Find the webapp resources and set up things that require them
async function setupGlobals() {
    // find the webapp asar.
    asarPath = await tryPaths("webapp", __dirname, [
        // If run from the source checkout, this will be in the directory above
        '../webapp.asar',
        // but if run from a packaged application, electron-main.js will be in
        // a different asar file so it will be two levels above
        '../../webapp.asar',
        // also try without the 'asar' suffix to allow symlinking in a directory
        '../webapp',
        // from a packaged application
        '../../webapp',
        // Workaround for developing beeper on windows, where symlinks are poorly supported.
        "../../nova-web/webapp",
    ]);
    console.log("Web App Path is", asarPath);
    iconsPath = await tryPaths("icons", __dirname, [
        '../res/icons',
        '../../icons'
    ]);
    console.log("iconsPath path is", iconsPath);
    // eslint-disable-next-line @typescript-eslint/no-var-requires
    vectorConfig = require(asarPath + 'config.json');
    console.log("Loading vector config for brand", vectorConfig.brand);
    try {
        // Load local config and use it to override values from the one baked with the build
        // eslint-disable-next-line @typescript-eslint/no-var-requires
        const localConfig = require(path_1.default.join(electron_1.app.getPath('userData'), 'config.json'));

There are also some hidden/undocumented CLI arguments, localdev / localapi:

const localdev = Array.isArray(argv._) && argv._.includes("localdev");
const localapi = Array.isArray(argv._) && argv._.includes("localapi");

We find the code that processes the --devtools arg here:

if (argv['devtools']) {
        try {
            const { default: installExt, REACT_DEVELOPER_TOOLS, REACT_PERF, } = require("electron-devtools-installer");
            await installExt([REACT_DEVELOPER_TOOLS, REACT_PERF], { loadExtensionOptions: { allowFileAccess: true } });
        }
        catch (e) {
            console.log(e);
        }
    }

A little further down we see how localdev / localapi are handled:

if (localdev) {
        // Open dev tools at startup if in dev mode
        mainWindow.webContents.openDevTools();
        electron_1.app.on("certificate-error", (event, webContents, url, error, certificate, callback) => {
            // On certificate error we disable default behaviour (stop loading the page)
            // and we then say "it is all fine - true" to the callback
            event.preventDefault();
            callback(true);
        });
    }
    if (localapi) {
        vectorConfig.novaApiUrl = `https://localhost:4001`;
    }
    mainWindow.loadURL(localdev ? "http://localhost:8080" : 'nova://nova-web/webapp/');

Then beyond that, you're sort of getting deeper into the internals of Electron apps and how Beeper / Element is built on top of Electron; so really depends what you're wanting to achieve at that point.


While at the initial 'entrypoint' debugger breakpoint (from --inspect-brk), we could also choose to manually load/inject some custom code of our own. For example:

With a code file like:

// /Users/devalias/Desktop/beeperInjectionHax.js
console.log("Hello World, is this custom JS in Beeper?");

We could run the following in the Chrome Devtools console while at the initial app loading debug breakpoint:

require('/Users/devalias/Desktop/beeperInjectionHax.js')

Which would show an output message such as:

beeperInjectionHax.js:1 Hello World, is this custom JS in Beeper?

image

@0xdevalias
Copy link
Author

0xdevalias commented Jul 16, 2023

Using asar's CLI, we can 're-pack' a *.asar file such that all of it's included files are outside of the actual *.asar file in the *.asar.unpacked directory (eg. to make it easier to inspect/inject into/manipulate the script files while exploring the app) as follows:

⇒ npx asar extract app.asar app-unpacked

⇒ npx asar pack app-unpacked app-all-unpacked.asar --unpack-dir "{**/*,**/.*}"

⇒ npx asar list --is-pack app4.asar

The relevant help for these commands is as follows:

npx asar extract -h
Usage: asar extract|e [options] <archive> <dest>

extract archive

Options:
  -h, --help  display help for command
 ⇒ npx asar pack -h
Usage: asar pack|p [options] <dir> <output>

create asar archive

Options:
  --ordering <file path>     path to a text file for ordering contents
  --unpack <expression>      do not pack files matching glob <expression>
  --unpack-dir <expression>  do not pack dirs matching glob <expression> or starting with
                             literal <expression>
  --exclude-hidden           exclude hidden files
  -h, --help                 display help for command
⇒ npx asar list -h
Usage: asar list|l [options] <archive>

list files of asar archive

Options:
  -i, --is-pack  each file in the asar is pack or unpack
  -h, --help     display help for command

We could also use asar programmatically if we didn't want to use the CLI:


Here is more about the asar format:

  • https://github.com/electron/asar#format
    • Asar uses Pickle to safely serialize binary value to file.

    • The format of asar is very flat:

      • | UInt32: header_size | String: header | Bytes: file1 | ... | Bytes: file42 |
      • The header_size and header are serialized with Pickle class, and header_size's Pickle object is 8 bytes.
      • The header is a JSON string, and the header_size is the size of header's Pickle object.
      • offset and size records the information to read the file from archive, the offset starts from 0 so you have to manually add the size of header_size and header to the offset to get the real offset of the file.
      • offset is a UINT64 number represented in string, because there is no way to precisely represent UINT64 in JavaScript Number. size is a JavaScript Number that is no larger than Number.MAX_SAFE_INTEGER, which has a value of 9007199254740991 and is about 8PB in size. We didn't store size in UINT64 because file size in Node.js is represented as Number and it is not safe to convert Number to UINT64.
      • integrity is an object consisting of a few keys:
        • A hashing algorithm, currently only SHA256 is supported.
        • A hex encoded hash value representing the hash of the entire file.
        • An array of hex encoded hashes for the blocks of the file. i.e. for a blockSize of 4KB this array contains the hash of every block if you split the file into N 4KB blocks.
        • A integer value blockSize representing the size in bytes of each block in the blocks hashes above

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment