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.
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:
$ 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?
$ 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:
$ 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:
$ 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?
$ 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
$ find . -name '*.asar' ./resources/app.asar
Let's install the CLI:
$ 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:
$ 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!
$ 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:
$ 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:
$ 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:
$ 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
$ 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!
$ 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):
$ ar t drawio-amd64-21.4.0.deb debian-binary control.tar.gz data.tar.xz
Or llvm-ar
, provided by LLVM:
$ 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):
$ 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:
$ 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!
# 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:
$ 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!
$ 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
Ok yeah I'm definitely seeing a pattern now.
Linux AppImage
Shortest one yet:
$ 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...
{ "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.
$ 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!
Gee, I wonder if-
$ 7z l 84JReQ8pcNGJyAbT0gSDiW7OpDkrdaXp_180.snap | grep -E '[.]asar$' 2023-06-27 15:35:17 ..... 194485747 62628240 resources/app.asar
..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:
(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!
$ ls -lhA total 39152 -rw-r--r--@ 1 amos wheel 19M Jul 3 14:32 extension_21_2_7_0.crx
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:
$ 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?
$ 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:
$ 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:
$ 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:
$ 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!
$ 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:
$ 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")),
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:
$ 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?
$ 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
:
$ 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.
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?
$ 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:
$ 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:
$ 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:
$ 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:
$ 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
:
remoteBaseURL = `${endpoint}/modules/${buildInfo.releaseChannel}`;
And also this:
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:
$ 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?
$ 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.
$ 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
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:
$ find . | wc -l 2613
And it's the actual app!
$ 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!
Thanks to my sponsors: ofrighil, old.woman.josiah, Romet Tagobert, Michał Bartoszkiewicz, Daniel Papp, avborhanian, Zoran Zaric, Daniel Silverstone, Jan De Landtsheer, Alejandro Angulo, Tanner Muro, bbutkovic, Olivia Crain, Chirag Jain, James Rhodes, you got maiL, Torben Clasen, L0r3m1p5um, Benjamin Röjder Delnavaz, Valentin Mariette and 227 more
If you liked what you saw, please support my work!
Here's another article just for you:
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.