Cracking Electron apps open

👋 This page was last updated ~1 year ago. Just so you know.

I use the draw.io desktop app to make diagrams for my website. I run it on an actual desktop, like Windows or macOS, but the asset pipeline that converts .drawio files, to .pdf, to .svg, and then to .svg again (but smaller) runs on Linux.

So I have a Rust program somewhere that opens headless chromium, and loads just the HTML/JS/CSS part of draw.io I need to render my diagrams, and then use Chromium's "print to PDF" functionality to save a PDF.

This is pretty much exactly what the official draw.io docker image does btw.

Bear

Wait, why do we need Chromium again?

Because SVG doesn't have rich text formatting, so draw.io kinda... sorta.. there's HTML markup in there, and we need a browser to lay it all out. (I suppose we could write a custom renderer, but then it wouldn't look the way it does in the editor so shrug).

Anyway - over time, draw.io updates, and my converter program breaks!

When that happens, I need to grab a recent build of draw.io desktop and rip it apart to find the files I want!

Draw.io desktop releases in a few different formats: Windows Installer, Windows No Installer, macOS Universal, Linux deb, Linux snap, Linux AppImage, Linux rpm, and Google Chrome OS.

Let's see how we can crack open all of these!

(crack as in walnut, not as in jail time).

Windows Installer

You'll want 7-zip: on Ubuntu you can install p7zip-full.

We get a 100M .exe file:

Shell session
$ ls -lhA
total 100M
-rw-rw-r-- 1 amos amos 100M Jul  2 20:49 draw.io-21.4.0-windows-installer.exe

Will 7-zip be able to list what's inside?

Shell session
$ 7z l draw.io-21.4.0-windows-installer.exe

7-Zip [64] 16.02 : Copyright (c) 1999-2016 Igor Pavlov : 2016-05-21
p7zip Version 16.02 (locale=en_US.UTF-8,Utf16=on,HugeFiles=on,64 bits,16 CPUs AMD Ryzen 9 5950X 16-Core Processor             (A20F10),ASM,AES-NI)

Scanning the drive for archives:
1 file, 104056664 bytes (100 MiB)

Listing archive: draw.io-21.4.0-windows-installer.exe

--
Path = draw.io-21.4.0-windows-installer.exe
Type = PE
Physical Size = 104056664
CPU = x86
Characteristics = Executable 32-bit NoRelocs NoLineNums NoLocalSyms
Created = 2018-12-16 00:26:14
Headers Size = 1024
Checksum = 104056718
Image Size = 2211840
Section Alignment = 4096
File Alignment = 512
Code Size = 26624
Initialized Data Size = 473088
Uninitialized Data Size = 16384
Linker Version = 6.0
OS Version = 4.0
Image Version = 6.0
Subsystem Version = 4.0
Subsystem = Windows GUI
DLL Characteristics = Relocated NX-Compatible NoSEH TerminalServerAware
Stack Reserve = 1048576
Stack Commit = 4096
Heap Reserve = 1048576
Heap Commit = 4096
Image Base = 4194304
Comment = FileVersion: 21.4.0.0
FileVersion: 21.4.0
ProductVersion: 21.4.0.0
ProductVersion: 21.4.0
CompanyName: JGraph
FileDescription: draw.io desktop
LegalCopyright: Copyright 2017-2019 draw.io
ProductName: draw.io
----
Path = [0]
Size = 103883624
Packed Size = 103883624
Virtual Size = 103883624
Offset = 153088
--
Path = [0]
Type = Nsis
Physical Size = 103883624
Method = Deflate
Solid = -
Headers Size = 161498
Embedded Stub Size = 0
SubType = NSIS-3 Unicode BadCmd=11

   Date      Time    Attr         Size   Compressed  Name
------------------- ----- ------------ ------------  ------------------------
                    .....                      6931  $PLUGINSDIR/System.dll
                    .....                     45608  $PLUGINSDIR/StdUtils.dll
2019-06-17 21:21:30 .....                      1899  $PLUGINSDIR/modern-wizard.bmp
                    .....                      4684  $PLUGINSDIR/nsDialogs.dll
                    .....                      8018  $PLUGINSDIR/UAC.dll
                    .....                      2027  $PLUGINSDIR/nsProcess.dll
                    .....                      3299  $PLUGINSDIR/nsExec.dll
2023-06-14 23:42:16 .....    103376259    103376259  $PLUGINSDIR/app-64.7z
                    .....                    242382  $PLUGINSDIR/nsis7z.dll
2023-06-14 23:42:18 .....                    152955  $R0/Uninstall draw.io.exe
                    .....                      1080  $PLUGINSDIR/WinShell.dll
------------------- ----- ------------ ------------  ------------------------
2023-06-14 23:42:18          103376259    103845142  11 files

Of course! Looks like it's using NSIS, the Nullsoft Scriptable Install System, and 7-zip knows how to extract that:

Shell session
$ 7z x draw.io-21.4.0-windows-installer.exe
(cut)

SubType = NSIS-3 Unicode BadCmd=11

Everything is Ok

Files: 11
Size:       104395691
Compressed: 104056664

The actual installed app data lies in a nested .7z archive:

Shell session
$ cd '$PLUGINSDIR'

$ 7z x app-64.7z
(cut)

Everything is Ok

Folders: 2
Files: 74
Size:       433816630
Compressed: 103376259

Did we find what we were looking for?

Shell session
$  find . -name '*.html'
./LICENSES.chromium.html

Mhh, not quite, that's just a Chromium license (expected for an Electron app).

What we want is in the .asar, which is Electron's own archive format

Shell session
$ find . -name '*.asar'
./resources/app.asar

Let's install the CLI:

Shell session
$ sudo npm install -g @electron/asar

added 14 packages in 13s

1 package is looking for funding
  run `npm fund` for details

(Note: I'm sudo-ing out of laziness here, if you just want to install npm packages globally, you can just change the permissions of the global prefix, or change the global prefix altogether, see this article)

Now let's see what's hidden in there:

Shell session
$ hash -r
# (we just added a command to $PATH - tab-completion won't have it until we
# open a new shell or call `hash -r` manually)

$ asar l resources/app.asar | grep -v node_modules | grep -E '[.](html|css)$'
/drawio/teams.html
/drawio/src/main/webapp/clear.html
/drawio/src/main/webapp/dropbox.html
/drawio/src/main/webapp/export-fonts.css
/drawio/src/main/webapp/export3.html
/drawio/src/main/webapp/github.html
/drawio/src/main/webapp/gitlab.html
/drawio/src/main/webapp/index.html
/drawio/src/main/webapp/onedrive3.html
/drawio/src/main/webapp/open.html
/drawio/src/main/webapp/teams.html
/drawio/src/main/webapp/vsdxImporter.html
/drawio/src/main/webapp/styles/atlas.css
/drawio/src/main/webapp/styles/dark.css
/drawio/src/main/webapp/styles/grapheditor.css
/drawio/src/main/webapp/mxgraph/css/common.css
/drawio/src/main/webapp/mxgraph/css/explorer.css

Okay yeah that's what I need!

Shell session
$ asar e ./resources/app.asar ./app-unpacked
(no output)

$  du -sh ./app-unpacked
204M    ./app-unpacked

Game's over, we can grab anything we want from there.

Again, this isn't "hacking" - draw.io desktop is open source in the first place, and they don't make even the slightest attempt at hiding what's in there.

It is interesting though, that we're able to extract the entire contents of that Windows app from Linux. Similar tricks are employed in, like, "wine install scripts" for GOG games (since their Windows games only provide installers).

Let's look at some of the other installation formats, though!

Windows, no installer

Let's see:

Shell session
$ ls -lhA
total 100M
-rw-rw-r-- 1 amos amos 100M Jul  2 21:34 draw.io-21.4.0-windows-no-installer.exe

The file command-line utility is good at sussing out what's actually in a... file:

Shell session
$ file draw.io-21.4.0-windows-no-installer.exe
draw.io-21.4.0-windows-no-installer.exe: PE32 executable (GUI) Intel 80386, for MS Windows, Nullsoft Installer self-extracting archive, 5 sections

Here too, 7z x does the job:

Shell session
$ 7z x draw.io-21.4.0-windows-no-installer.exe

Everything is Ok

Files: 4
Size:       103925123
Compressed: 103841200

$ ll
total 100M
drwx------ 2 amos amos 4.0K Jul  2 21:36 '$PLUGINSDIR'
-rw-rw-r-- 1 amos amos 100M Jul  2 21:34  draw.io-21.4.0-windows-no-installer.exe

$ ls '$PLUGINSDIR/'
app-64.7z  nsis7z.dll  StdUtils.dll  System.dll

You know the rest.

(P.S: draw.io folks: a self-extracting NSIS archive is not really "no installer". An actual archive file would be that. Windows 11 is getting native support for 7z archives soon).

macOS - Universal

Shell session
$ ls -lhA
total 218M
-rw-rw-r-- 1 amos amos 218M Jul  2 21:40 draw.io-universal-21.4.0.dmg

Annoyingly the macOS build is a DMG, which is a mountable image format. This is not an issue if you're on macOS, of course, but say you're trying to extract this from Linux, your options are limited.

Haha, just kidding!

Shell session
$ 7z x draw.io-universal-21.4.0.dmg
(cut)

$ find . -name '*.asar'
./draw.io 21.4.0-universal/draw.io.app/Contents/Resources/app.asar

$ asar list "$(find . -name '*.asar')" | grep -F index.html
/drawio/src/main/webapp/index.html

Are you starting to see a pattern here?

Linux .deb

Debian package files are just GNU ar archives (just like static libraries!)

So we can list the contents and extract it with ar (provided by GNU binutils):

Shell session
$ ar t drawio-amd64-21.4.0.deb
debian-binary
control.tar.gz
data.tar.xz

Or llvm-ar, provided by LLVM:

Shell session
$ llvm-ar t drawio-amd64-21.4.0.deb
debian-binary
control.tar.gz
data.tar.xz

Or 7-zip! Again! (Which is handy if you're doing this from Windows, for example):

Shell session
$ 7z x drawio-amd64-21.4.0.deb
(cut)

$ ls -lhA
total 206M
-rw-rw-r-- 1 amos amos 3.3K Jul  2 21:45 control.tar.gz
-rw-rw-r-- 1 amos amos 103M Jul  2 21:45 data.tar.xz
-rw-rw-r-- 1 amos amos    4 Jul  2 21:45 debian-binary
-rw-rw-r-- 1 amos amos 103M Jul  2 21:45 drawio-amd64-21.4.0.deb

What we're looking for is in data.tar.xz: we can extract with 7-zip too, although annoyingly it first extracts data.tar, and then you can find the .asar in there:

Shell session
$ 7z l data.tar | grep -E '[.]asar$'
2023-06-14 23:33:19 .....    194079121    194079232  ./opt/drawio/resources/app.asar

Alternatively, you can use tar to do this, which.. should work on Linux, macOS and even Windows 11 nowadays!

Shell session
# list files
$ tar wtf data.tar.xz | grep -E '[.]asar$'
./opt/drawio/resources/app.asar

# extract only the .asar
$ tar pfx data.tar.xz ./opt/drawio/resources/app.asar
amos@sonic /tmp/winnoins

# bingo!
$ asar list ./opt/drawio/resources/app.asar | grep -F 'index.html'
/drawio/src/main/webapp/index.html

Linux .rpm

Extracting with 7z gives us a .cpio file:

Shell session
$ 7z x drawio-x86_64-21.4.0.rpm
(cut)

❯ ls -lhA
total 520M
-rw-rw-r-- 1 amos amos 418M Jul  2 23:02 draw.io-21.4.0-1.x86_64.cpio
-rw-rw-r-- 1 amos amos 103M Jul  2 23:02 drawio-x86_64-21.4.0.rpm

Extracting again gives us what we want!

Shell session
$ 7z l draw.io-21.4.0-1.x86_64.cpio | grep -E '[.]asar$'
2023-06-14 23:36:02 .....    194079121    194079121  ./opt/drawio/resources/app.asar
Bear

Ok yeah I'm definitely seeing a pattern now.

Linux AppImage

Shortest one yet:

Shell session
$ 7z l drawio-x86_64-21.4.0.AppImage | grep -E '[.]asar$'
2023-06-14 23:33:19 .....    194079121     67062337  resources/app.asar

Linux snap

This one links to the snap store.

If you use their API, at https://search.apps.ubuntu.com/api/v1/package/drawio, you'll notice that...

JSON
{
  "aliases": null,
  "anon_download_url": "https://api.snapcraft.io/api/v1/snaps/download/84JReQ8pcNGJyAbT0gSDiW7OpDkrdaXp_180.snap",
  "apps": [
    "drawio"
  ],
  "architecture": [
    "amd64"
  ],
  "base": "core18",
  "binary_filesize": 144482304,
  "channel": "stable",
  "common_ids": [],
  "confinement": "strict",
  "contact": "mailto:support@diagrams.net",
  "content": "application",
  "date_published": "2019-08-07T15:46:03.060636Z",
  "deltas": [],
  "description": "draw.io desktop",
  "developer_id": "odtkI5xnXQ5ok0IrzhKceRjC7sEztjsf",
  "developer_name": "draw.io",
  "developer_validation": "verified",
  "download_sha3_384": "183eb28b82d7c43faa1b186f7df06cbe10cb268af76a7b3e11ad25e8daf44ba3a4a64f071fd3774ccfc795c67b7037d4",
  "download_sha512": "ea337e84e09135445e75e1430b8daeb857277e63918575ddfebbd6cfb2b6979ebf09cf35100a253527fd2fa20e898f63f4120df9d19bba839bf6fba5ef2222ce",
  "download_url": "https://api.snapcraft.io/api/v1/snaps/download/84JReQ8pcNGJyAbT0gSDiW7OpDkrdaXp_180.snap",
  "epoch": "0",
  "gated_snap_ids": [],
  "icon_url": "https://dashboard.snapcraft.io/site_media/appmedia/2019/08/android-chrome-512x512.png",
  "last_updated": "2023-06-27T13:36:29.785729+00:00",
  "license": "Apache-2.0",
  "links": {
    "contact": [
      "mailto:support@diagrams.net"
    ],
    "website": [
      "https://www.diagrams.net"
    ]
  },
  "name": "drawio.jgraph",
  "origin": "jgraph",
  "package_name": "drawio",
  "prices": {},
  "private": false,
  "publisher": "draw.io",
  "ratings_average": 0,
  "release": [
    "16"
  ],
  "revision": 180,
  "screenshot_urls": [
    "https://dashboard.snapcraft.io/site_media/appmedia/2019/08/screenshot.png"
  ],
  "snap_id": "84JReQ8pcNGJyAbT0gSDiW7OpDkrdaXp",
  "summary": "draw.io",
  "support_url": "",
  "title": "draw.io",
  "version": "21.5.1",
  "website": "https://www.diagrams.net"
}

There's an anon_download_url field.

Shell session
$ curl --silent --location --remote-name https://api.snapcraft.io/api/v1/snaps/download/84JReQ8pcNGJyAbT0gSDiW7OpDkrdaXp_180.snap
(no output)

$ ls -lhA
total 138M
-rw-rw-r-- 1 amos amos 138M Jul  2 23:10 84JReQ8pcNGJyAbT0gSDiW7OpDkrdaXp_180.snap

$ file 84JReQ8pcNGJyAbT0gSDiW7OpDkrdaXp_180.snap
84JReQ8pcNGJyAbT0gSDiW7OpDkrdaXp_180.snap: Squashfs filesystem, little endian, version 4.0, xz compressed, 144481911 bytes, 133 inodes, blocksize: 131072 bytes, created: Tue Jun 27 13:36:19 2023

Oh, a squashfs filesystem, xz-compressed!

Bear

Gee, I wonder if-

Shell session
$ 7z l 84JReQ8pcNGJyAbT0gSDiW7OpDkrdaXp_180.snap | grep -E '[.]asar$'
2023-06-27 15:35:17 .....    194485747     62628240  resources/app.asar
Bear

..I guess it does!

Google Chrome OS

This time it links to the chrome web store.

Adapting some code from the crxviewer extension, we can build a valid download URL by pasting the following code onto the JS console in a browser tab open to the relevant chrome web store page:

JavaScript code
(function() {
    let extensionID = new URL(location.href).pathname.split("/")[4];
    console.log(`Downloading extension ${extensionID}`);
    let x = `id%3D${extensionID}%26uc`;
    let url = `https://clients2.google.com/service/update2/crx?response=redirect&prod=chromiumcrx` +
        `&prodchannel=unknown&prodversion=9999.0.9999.0&acceptformat=crx2,crx3&x=${x}`;
    return console.log(url);
}())

Let's see what we got!

Shell session
$ ls -lhA
total 39152
-rw-r--r--@ 1 amos  wheel    19M Jul  3 14:32 extension_21_2_7_0.crx
Bear

This is much smaller than any of the other ones.

Well yes, because this one doesn't actually include a full copy of Chromium and V8. It's not actually an Electron app, just the HTML/JS.

Also, like a lot of other file formats (JAR Java Archives, ODF OpenDocument Format, MSIX Windows installers) it's actually just "a zip file with extra bytes at the beginning":

$ unzip -l extension_21_2_7_0.crx | head
Archive:  extension_21_2_7_0.crx
warning [extension_21_2_7_0.crx]:  1320 extra bytes at beginning or within zipfile
  (attempting to process anyway)
  Length      Date    Time    Name
---------  ---------- -----   ----
        0  05-03-2023 12:45   images/
        0  05-03-2023 12:45   img/
        0  05-03-2023 12:45   img/clipart/
        0  05-03-2023 12:45   img/computers/
        0  05-03-2023 12:45   img/finance/
        0  05-03-2023 12:45   img/lib/
        0  05-03-2023 12:45   img/lib/active_directory/

So, you guessed it, we can extract it with 7-zip.

No .asar archive here though! We can get at the meat of the app directly:

Shell session
$ 7z l extension_21_2_7_0.crx | grep .html
2022-06-11 16:07:56 .....          694          401  index.html

.asar archives only serve Electron apps because, well, some older versions of Windows had limits on path length for some APIs, so some tools struggled to install/uninstall apps.

Also in general, Windows is much slower than Linux/macOS at "lots of small files" (see this in-depth explanation), so having a single file that transparently operates like a folder from Electron's perspective (yes really) nets you some nice speedups.

Does this work for any Electron app?

Figma

Figma for macOS is a .dmg that's.. about two megabytes?

Shell session
$ ls -lhA
total 3608
-rw-r--r--@ 1 amos  wheel   1.8M Jul  3 15:18 Figma.dmg

Extracting it with 7-zip gives us "HFS+ Private Data" folders and files:

Shell session
$ tree -ah
[ 128]  .
├── [ 288]  Figma
│   ├── [ 15K]  .DS_Store
│   ├── [  64]  .HFS+ Private Directory Data\015
│   ├── [914K]  .VolumeIcon.icns
│   ├── [  96]  .background
│   │   └── [ 49K]  dmg-background.tiff
│   ├── [  13]  Applications
│   ├── [  96]  Figma.app
│   │   └── [ 256]  Contents
│   │       ├── [1.6K]  CodeResources
│   │       ├── [1.8K]  Info.plist
│   │       ├── [  96]  MacOS
│   │       │   └── [238K]  DynamicUniversalApp
│   │       ├── [   8]  PkgInfo
│   │       ├── [ 192]  Resources
│   │       │   ├── [ 128]  App.nib
│   │       │   │   ├── [9.5K]  keyedobjects-101300.nib
│   │       │   │   └── [ 12K]  keyedobjects.nib
│   │       │   ├── [  96]  en.lproj
│   │       │   │   └── [3.2K]  Localizable.strings
│   │       │   ├── [914K]  icon.icns
│   │       │   └── [  96]  ja.lproj
│   │       │       └── [3.6K]  Localizable.strings
│   │       └── [  96]  _CodeSignature
│   │           └── [3.6K]  CodeResources
│   └── [  64]  [HFS+ Private Data]
└── [1.8M]  Figma.dmg

13 directories, 15 files

And the .app itself can't be opened:

$ open Figma.app
The application cannot be opened for an unexpected reason,
error=Error Domain=RBSRequestErrorDomain Code=5 "Launch failed."
UserInfo={NSLocalizedFailureReason=Launch failed.,
NSUnderlyingError=0x6000005f2610 {Error Domain=NSPOSIXErrorDomain Code=111 "Unknown error: 111" UserInfo={NSLocalizedDescription=Launchd job spawn failed}}}

Why? Because 7-zip didn't preserve permissions:

Shell session
$ cat ./Figma.app/Contents/Info.plist | grep -i cfbundlename -A 1
        <key>CFBundleName</key>
        <string>DynamicUniversalApp</string>

$ stat ./Figma.app/Contents/MacOS/DynamicUniversalApp
16777231 1453815 -rw-r--r-- 1 amos wheel 0 244000 "Jun 27 11:21:39 2023" "Jun 27 11:21:39 2023" "Jul  3 15:23:28 2023" "Jun 27 11:21:39 2023" 4096 480 0 ./Figma.app/Contents/MacOS/DynamicUniversalApp

We can fix that with chmod +x - after that, running it complains that it's not in the /Application folder.

Of course that 2-megabyte bundle isn't the actual app. Running it complains that it must be in /Applications to work correctly, and that's because of App Translocation (thanks to Skip R. and Kahanis on Mastodon for clarifications there).

What if you don't want to run that though? Or if you haven't sneakily switched to macOS like I just did, and running the application is straight up not an option?

Well, the download URLs are in the Info.plist (see property list):

$ cat ./Figma.app/Contents/Info.plist | grep -i https -B 1
                <key>aarch64</key>
                <string>https://desktop.figma.com/mac-arm/Figma.zip</string>
                <key>x86_64</key>
                <string>https://desktop.figma.com/mac/Figma.zip</string>

Personally I think it's hilarious, that they have you download a 2MB downloader that will then download the right build for your architecture.

But it also saves everyone bandwidth (as opposed to distributing an actual universal binary), so I'll allow it.

Anyway:

Shell session
$ curl -sLO https://desktop.figma.com/mac-arm/Figma.zip

$ unzip -l ./Figma.zip | grep -F '.asar'
  1658107  06-27-2023 11:13   Figma.app/Contents/Resources/app.asar
        0  06-27-2023 11:14   Figma.app/Contents/Resources/app.asar.unpacked/
        0  06-27-2023 11:13   Figma.app/Contents/Resources/app.asar.unpacked/node_modules/
        0  06-27-2023 11:14   Figma.app/Contents/Resources/app.asar.unpacked/node_modules/fsevents/
    55872  06-27-2023 11:14   Figma.app/Contents/Resources/app.asar.unpacked/node_modules/fsevents/fsevents.node
   173696  06-27-2023 11:14   Figma.app/Contents/Resources/app.asar.unpacked/bindings.node
  2249984  06-27-2023 11:14   Figma.app/Contents/Resources/app.asar.unpacked/desktop_rust.node

How interesting!

Shell session
$ asar list app.asar | grep .js | grep -v node_modules
/build.json
/i18n/ja.json
/js
/js/desktop_shell.js
/main.js
/package-lock.json
/package.json
/shell_app_binding_renderer.js
/tray_binding_renderer.js
/web_app_binding_renderer.js

And, well, I've got good news and bad news:

Shell session
$ asar ef app.asar main.js

$ js-beautify main.js | grep '[.]node"'
var K = hl.app.isPackaged ? require("./bindings.node") : require("../build/Release/bindings.node"),
    Ei = kt(() => hl.app.isPackaged ? require("./desktop_rust.node") : require("../build/Release/desktop_rust.node")),
Bear

That command comes from the js-beautify npm package.

The good news is apparently they ship some Rust? (And I'm assuming some C++, as everyone said they did?) and it's used for some parts of the app:

Shell session
$ js-beautify main.js | grep -E '(K[.]|Ei)' | pbcopy
    Ei = kt(() => hl.app.isPackaged ? require("./desktop_rust.node") : require("../build/Release/desktop_rust.node")),
    let t = Ei(),
    return Ei().getFontPreview(t, e, r, i, n)
    process.platform === "darwin" && K.customizeWindowButtons(t)
    if (process.platform === "darwin") return K.launchApp(t, e.foreground)
    return process.platform !== "darwin" ? null : K.getAppPathForProtocol(t) || null
    if (process.platform === "darwin") return K.getBundleVersion(t);
    if (process.platform === "win32") return K.getExecutableVersion(t);
    return K.removeBundleDirectory(t)
    return process.platform !== "darwin" ? !1 : K.isP3ColorSpaceCapable()
    return process.platform !== "darwin" ? null : JSON.parse(K.getActiveNSScreens(t.getNativeWindowHandle()))
    process.platform === "win32" ? K.forceFocusWindow(t.getNativeWindowHandle()) : t.focus()
    if (process.platform === "win32") return K.removeAgentRegistryLoginItem()
    return process.platform !== "win32" ? !1 : K.isSystemDarkMode()
    return K.getCurrentKeyboardLayout()
    return K.getWindowScreenshot(t.getNativeWindowHandle())
    if (process.platform === "darwin") return K.makePanel(t.getNativeWindowHandle())
    if (process.platform === "darwin") return K.positionPanel(t, e)
    if (process.platform === "darwin") return K.showPanel()
    if (process.platform === "darwin") return K.hidePanel()
    return process.platform !== "darwin" ? !1 : K.getPanelVisibility()
    if (process.platform === "darwin") return K.destroyPanel()
    return K.getWindowUnderCursor()
    return K.CheckSpelling(t, e)
    return K.SetDictionary(t)
    return K.AddWord(t, e)
    return t.map(r => K.IgnoreWord(r, e)).every(r => r)
    return K.GetCorrectionsForMisspelling(t, e)
    return K.GetAvailableDictionaries()
    if (process.platform === "darwin") return K.setMenuShortcuts(t)
    return process.platform !== "darwin" ? !1 : K.getOSNotificationsEnabled()
    return Ei().talonInitialize(t, e, r)
    Ei().talonOpenStream(t, e)
    Ei().talonSinkStream(t, e, r)
    Ei().talonCloseStream(t, e)
        for (let i = 0; i < e; i++) K.triggerHaptic(t, e, r), i !== e - 1 && await new Promise(n => setTimeout(n, r))

Mostly it feels like they're complementing Electron's APIs. How do we know it's still actually electron?

Shell session
$ ls -lhA Figma.app/Contents/Frameworks
total 0
drwxr-xr-x@ 7 amos  wheel   224B Jun 27 11:13 Electron Framework.framework
drwxr-xr-x@ 3 amos  wheel    96B Jun 27 11:13 Figma Helper (GPU).app
drwxr-xr-x@ 3 amos  wheel    96B Jun 27 11:13 Figma Helper (Plugin).app
drwxr-xr-x@ 3 amos  wheel    96B Jun 27 11:13 Figma Helper (Renderer).app
drwxr-xr-x@ 3 amos  wheel    96B Jun 27 11:13 Figma Helper.app
drwxr-xr-x@ 5 amos  wheel   160B Jun 27 11:13 Mantle.framework
drwxr-xr-x@ 5 amos  wheel   160B Jun 27 11:13 ReactiveObjC.framework
drwxr-xr-x@ 5 amos  wheel   160B Jun 27 11:13 Squirrel.framework

That's how.

There's a ton of interesting symbols in desktop_rust.node (you can run nm on it) btw: harfbuzz and freetype stuff, which I expected since the JavaScript code above calls getFontPreview:

Shell session
$ nm -C desktop_rust.node | grep ' _ft_' | head -5
0000000000144b42 s _ft_adobe_glyph_list
0000000000029fcc t _ft_alloc
0000000000029fe8 t _ft_ansi_stream_close
000000000002a014 t _ft_ansi_stream_io
0000000000029fe0 t _ft_free

$ nm -C desktop_rust.node | grep ' _hb_' | head -5
00000000000e3e4c t _hb_shapers_get()
0000000000086b10 t _hb_options_init()
00000000000dbf7c t _hb_ot_shape_normalize(hb_ot_shape_plan_t const*, hb_buffer_t*, hb_font_t*)
000000000013972c t _hb_ot_shape_normalize(hb_ot_shape_plan_t const*, hb_buffer_t*, hb_font_t*) (.cold.1)
00000000000db7f0 t _hb_ot_shape_fallback_kern(hb_ot_shape_plan_t const*, hb_font_t*, hb_buffer_t*)

...but also cxxbridge stuff! Which is interesting.

Shell session
nm -C desktop_rust.node | grep 'cxxbridge' | head
0000000000029164 t generate_svg(rust::cxxbridge1::String, float, rust::cxxbridge1::String, rust::cxxbridge1::String, rust::cxxbridge1::String)
000000000002866c t get_font(rust::cxxbridge1::String)
0000000000138108 t get_font(rust::cxxbridge1::String) (.cold.1)
00000000000280fc t rust::cxxbridge1::Vec<FontVariationAxis>::reserve_total(unsigned long)
00000000000280f0 t rust::cxxbridge1::Vec<FontVariationAxis>::drop()
0000000000028100 t rust::cxxbridge1::Vec<FontVariationAxis>::set_len(unsigned long)
0000000000029d90 t rust::cxxbridge1::Vec<FontVariationAxis>::Vec(rust::cxxbridge1::Vec<FontVariationAxis> const&)
00000000000280cc t rust::cxxbridge1::Vec<FontVariationAxis>::Vec()
0000000000028134 t rust::cxxbridge1::Vec<Font>::reserve_total(unsigned long)
0000000000028128 t rust::cxxbridge1::Vec<Font>::drop()

So I guess this is the "Rust+C++" bundle, and bindings.node is maybe just Objective-C/Swift for macOS-specific platform stuff.

Disassembling these is left as an exercise to the reader.

Discord

How about one last for the road?

Shell session
$ ls -lhA Discord.dmg
-rw-r--r--@ 1 amos  wheel   158M Jul  3 16:11 Discord.dmg

$ 7z l Discord.dmg | grep 'asar'
2023-04-26 23:30:26 .....      4816906      4820992  Discord/Discord.app/Contents/Resources/app.asar

Alright, this is promising!

But here too, this app is in fact, just an installer/updater:

Shell session
$ rg 'NEW_UPDATE_ENDPOINT'
index.js
23:    NEW_UPDATE_ENDPOINT
26:    if (!updater.tryInitUpdater(buildInfo, NEW_UPDATE_ENDPOINT)) {

appUpdater.js
27:  if ((0, _updater.tryInitUpdater)(_buildInfo.default, _Constants.NEW_UPDATE_ENDPOINT)) {

Constants.js
25:const NEW_UPDATE_ENDPOINT = settings.get('NEW_UPDATE_ENDPOINT') || 'https://updates.discord.com/';
34:  NEW_UPDATE_ENDPOINT,

(rg is ripgrep)

This could just be a self-updater, but let's keep digging:

Shell session
$ js-beautify app/app_bootstrap/splash/index.js | grep 'discord.com' -B 10
    const ee = [{
            value: "deb",
            label: "Ubuntu (deb)"
        }, {
            value: "tar.gz",
            label: "Linux (tar.gz)"
        }, {
            value: "nope",
            label: "I'll figure it out"
        }],
        te = `https://discord.com/api/download/${DiscordSplash.getReleaseChannel()}?platform=linux&format=`,

$ cat build_info.json
{
  "releaseChannel": "stable",
  "version": "0.0.275"
}

Ah, there we go! This lets us build this URL:

Shell session
$ curl -sL -o discord.tar.gz "https://discord.com/api/download/stable?platform=linux&format=tar.gz"

$ ls -lhA
total 197880
-rw-r--r--@ 1 amos  wheel    88M Jul  3 16:36 discord.tar.gz

What's in there? Turns out, it's the same as what's in the macOS app.

This is just Discord doing their best at supporting Linux, on which it's really hard to distribute anything properly, let alone a self-updating app.

There is, however, a second update system, from what I can see, that's able to download individual components:

Shell session
$ cat bootstrap/manifest.json
{
  "discord_desktop_core": 0,
  "discord_erlpack": 0,
  "discord_spellcheck": 0,
  "discord_utils": 0,
  "discord_voice": 0
}

We've got this in moduleUpdater.js:

JavaScript code
  remoteBaseURL = `${endpoint}/modules/${buildInfo.releaseChannel}`;

And also this:

JavaScript code
  const url = `${remoteBaseURL}/${encodeURIComponent(getRemoteModuleName(queuedModule.name))}/${encodeURIComponent(queuedModule.version)}`;

(Note: getRemoteModuleName just adds .x64 if we're running 64-bit Windows. I'm assuming Windows is the last OS Discord supports non-64-bit versions for, because.. gamers.. and Windows 7.. or something).

After hitting a few 404, I ended up searching for updates.discord.com and found this error message on Reddit:

[2023-04-07 17:44:23.790094 -04:00] ERROR [updater_client]: Failed 7: Other(
   Reqwest(
   reqwest::Error {
       kind: Request,
       url: Url { scheme: "https", cannot_be_a_base: false, username: "", password: None,
           host: Some(Domain("updates.discord.com")), port: None,
           path: "/distributions/app/manifests/latest",
           query: Some("install_id=b92b8922-c623-4a40-8965-8f6752288cb4&channel=stable&platform=win&arch=x86"),
           fragment: None },
           source: hyper::Error(Connect, ConnectError("dns error",
           Os { code: 11003, kind: Uncategorized, message: "A non-recoverable error occurred during a database lookup." })) }))

Which is interesting! First of all, it means Discord also ships Rust in its Electron app, and second of all it means we can grab a full manifest easily:

Shell session
$ curl "https://updates.discord.com/distributions/app/manifests/latest?channel=stable&platform=win&arch=x86" -o my-manifest.json

I used the fx interactive JSON viewer tool to find the proper jq query:

$ jq -C .modules.discord_desktop_core.full my-manifest.json
{
  "host_version": [
    1,
    0,
    9014
  ],
  "module_version": 1,
  "package_sha256": "62a8bb668df50930e514b7910ba236e259a0bfe7cd97d8e131541317229faeaa",
  "url": "https://dl.discordapp.net/distro/app/stable/win/x86/1.0.9014/discord_desktop_core/1/full.distro"
}

So what is that .distro file, actually?

Shell session
$ file full.distro
full.distro: OpenPGP Public Key

Ah, mh.

Searching for "discord full.distro" turns up a GitHub repository named harmonicord/packageDiscordAsar and harmonyClient/packageAsar - these are both 404-ing, but similar searches turn up OpenAsar, which, if we look at their sources a bit..

    body = Buffer.concat(body);
    body = zlib.brotliDecompressSync(body);

    fs.writeFileSync('client.tar', body);

Ah. It's just brotli.

Shell session
$ brotli -d full.distro -o full.distro.tar
(cut)

$ tar wtf full.distro.tar
delta_manifest.json
files/core.asar
files/index.js
files/package.json
Bear

Quick mnemonic:

  • tar wtf: what the fuck is inside of there?
  • tar pfx: please fucking extract this

And inside of there... we have 2500+ files:

Shell session
$ find . | wc -l
    2613

And it's the actual app!

Shell session
$ rg -i 'devtools' index.js
125:  const enableDevtoolsSetting = global.appSettings.get('DANGEROUS_ENABLE_DEVTOOLS_ONLY_ENABLE_IF_YOU_KNOW_WHAT_YOURE_DOING', false);
126:  const enableDevtools = buildInfo.releaseChannel === 'stable' ? enableDevtoolsSetting : true;
130:  Menu.setApplicationMenu(createApplicationMenu(enableDevtools));

$ rg -i 'Window' index.js
8:exports.setMainWindowVisible = setMainWindowVisible;
12:  BrowserWindow
117:  const windowNative = require('./discord_native/browser/window');
123:  global.mainWindowId = Constants.DEFAULT_MAIN_WINDOW_ID;
135:    getWindow: getPopoutWindowByKey
136:  } = require('./popoutWindows');
138:  windowNative.injectGetWindow(key => {
139:    return getPopoutWindowByKey(key) || BrowserWindow.fromId(mainScreen.getMainWindowId());
147:function setMainWindowVisible(visible) {
148:  mainScreen.setMainWindowVisible(visible);

Conclusion

It's usually pretty easy to get at an app's source code, at least for Electron apps. Most of the time it's not even minified (and they waste a lot of space in node_modules).

You can learn a bunch of things that way! Sometimes even find vulnerabilities, which some companies will throw insultingly low bounties at on HackerOne or something.

Anyway. I'll now go and try to remember why I was doing that in the first place. Have fun!

If you liked what you saw, please support my work!

Github logo Donate on GitHub Patreon logo Donate on Patreon

Here's another article just for you:

A Rust match made in hell

I often write pieces that showcase how well Rust can work for you, and how it can let you build powerful abstractions, and prevents you from making a bunch of mistakes.

If you read something like Some mistakes Rust doesn't catch in isolation, it could seem as if I had only nice things to say about Rust, and it's a perfect little fantasy land where nothing ever goes wrong.