Thanks to my sponsors: Nikolai Vincent Vaags, Daniel Wagner-Hall, Chris Emery, Seth, Scott Sanderson, Makoto Nakashima, Michal Hošna, Marcin Kołodziej, belzael, Alex Krantz, Adam Gutglick, std__mpa, joseph, Alejandro Angulo, Yann Schwartz, Dirkjan Ochtman, zaurask, Romain Ruetschi, pinkhatbeard, Blake Johnson and 232 more
Cracking Electron apps open
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!
Here's another article just for you:
Futures Nostalgia
Up until recently, hyper was my favorite Rust HTTP framework. It's low-level, but that gives you a lot of control over what happens.
Here's what a sample hyper application would look like:
$ cargo new nostalgia
Created binary (application) `nostalgia` package
$ cd nostalgia
$ cargo add hyper@0.14 --features "http1 tcp server"
Updating 'https://github.com/rust-lang/crates.io-index' index
Adding hyper v0.14 to dependencies with features: ["http1", "tcp", "server"]
$ cargo add tokio@1 --features "full"
Updating 'https://github.com/rust-lang/crates.io-index' index
Adding tokio v1 to dependencies with features: ["full"]