├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── beautifuldiscord ├── __init__.py ├── __main__.py ├── app.py └── asar.py ├── requirements.txt └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.buildinfo 3 | *.egg-info 4 | build/ 5 | dist/ 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 leovoel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a 6 | copy of this software and associated documentation files (the "Software"), 7 | to deal in the Software without restriction, including without limitation 8 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 9 | and/or sell copies of the Software, and to permit persons to whom the 10 | Software is furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 16 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 21 | DEALINGS IN THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | include requirements.txt 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | BeautifulDiscord 2 | ================ 3 | 4 | Simple Python script that adds CSS hot-reload to Discord. 5 | 6 | ![demo gif](http://i.imgur.com/xq4HS5f.gif) 7 | 8 | ## Motivation 9 | 10 | I wanted custom CSS injection for Discord, with no JavaScript add-ons or anything. 11 | That's BeautifulDiscord. 12 | 13 | If you want JS, you can either: 14 | - Use [BetterDiscord](https://github.com/Jiiks/BetterDiscordApp) 15 | - Make your own thing! 16 | 17 | You could also fork this repo and add it, it's not that big of a stretch. 18 | I just didn't add it because it's not what I want to do here. 19 | 20 | ## Usage 21 | 22 | Just invoke the script when installed. If you don't pass the `--css` flag, the stylesheet 23 | will be placed wherever the Discord app resources are found, which is not a very convenient 24 | location. 25 | 26 | **NOTE:** Discord has to be running for this to work in first place. 27 | The script works by scanning the active processes and looking for the Discord ones. 28 | 29 | (yes, this also means you can fool the program into trying to apply this to some random program named Discord) 30 | 31 | ``` 32 | $ beautifuldiscord --css C:\mystuff\myown.css 33 | 0: Found DiscordPTB.exe 34 | 1: Found DiscordCanary.exe 35 | Discord executable to use (number): 1 36 | 37 | Done! 38 | 39 | You may now edit your C:\mystuff\myown.css file, 40 | which will be reloaded whenever it's saved. 41 | 42 | Relaunching Discord now... 43 | $ 44 | ``` 45 | 46 | Pass the `--revert` flag to restore Discord to its initial state. You can also do this manually if your Discord 47 | install gets screwed up, by first locating where Discord stores its resources: 48 | 49 | - On Windows, it's `C:\Users\\AppData\Roaming\discord[ptb,canary]\\modules\discord_desktop_core` 50 | - On OSX, it's `~/Library/Application Support/discord[ptb,canary]//modules/discord_desktop_core` 51 | - On Linux, it's `~/.config/discord[ptb,canary]//modules/discord_desktop_core` 52 | 53 | (`<...>` means it's required, `[...]` means it's optional) 54 | 55 | In that folder, there should be four files, with `core.asar` and `original_core.asar` being the interesting ones. 56 | You should then remove the existing `core.asar` and rename `original_core.asar` to `core.asar`. 57 | 58 | ``` 59 | $ beautifuldiscord --revert 60 | 0: Found DiscordPTB.exe 61 | 1: Found DiscordCanary.exe 62 | Discord executable to use (number): 1 63 | Reverted changes, no more CSS hot-reload :( 64 | $ 65 | ``` 66 | 67 | You can also run it as a package - i.e. `python3 -m beautifuldiscord` - if somehow you cannot 68 | install it as a script that you can run from anywhere. 69 | 70 | ## Installing 71 | 72 | ``` 73 | python3 -m pip install -U https://github.com/leovoel/BeautifulDiscord/archive/master.zip 74 | ``` 75 | 76 | Usage of a virtual environment is recommended, to not pollute your global package space. 77 | 78 | ## Requirements 79 | 80 | - Python 3.x (no interest in compatibility with 2.x, untested on Python 3.x versions below 3.4) 81 | - `psutil` library: https://github.com/giampaolo/psutil 82 | 83 | Normally, `pip` should install any required dependencies. 84 | 85 | ## More GIFs 86 | 87 | ![demo gif](http://i.imgur.com/w0bQOJ6.gif) 88 | -------------------------------------------------------------------------------- /beautifuldiscord/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leovoel/BeautifulDiscord/9d6a0366990867f1b36c5f17b3fa3fd3430bdc97/beautifuldiscord/__init__.py -------------------------------------------------------------------------------- /beautifuldiscord/__main__.py: -------------------------------------------------------------------------------- 1 | from beautifuldiscord.app import main 2 | 3 | main() 4 | -------------------------------------------------------------------------------- /beautifuldiscord/app.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import shutil 5 | import argparse 6 | import textwrap 7 | import subprocess 8 | import psutil 9 | import sys 10 | from beautifuldiscord.asar import Asar 11 | 12 | class DiscordProcess: 13 | def __init__(self, path, exe): 14 | self.path = path 15 | self.exe = exe 16 | self.processes = [] 17 | 18 | def terminate(self): 19 | for process in self.processes: 20 | # terrible 21 | process.kill() 22 | 23 | def launch(self): 24 | with open(os.devnull, 'w') as f: 25 | subprocess.Popen([os.path.join(self.path, self.exe)], stdout=f, stderr=subprocess.STDOUT) 26 | 27 | @property 28 | def resources_path(self): 29 | if sys.platform == 'darwin': 30 | # OS X has a different resources path 31 | # Application directory is under <[EXE].app/Contents/MacOS/[EXE]> 32 | # where [EXE] is Discord Canary, Discord PTB, etc 33 | # Resources directory is under 34 | # So we need to fetch the folder based on the executable path. 35 | # Go two directories up and then go to Resources directory. 36 | return os.path.abspath(os.path.join(self.path, '..', 'Resources')) 37 | return os.path.join(self.path, 'resources') 38 | 39 | @property 40 | def script_path(self): 41 | if sys.platform == 'win32': 42 | # On Windows: 43 | # path is C:\Users\\AppData\Local\\app- 44 | # script: C:\Users\\AppData\Roaming\\\modules\discord_desktop_core 45 | # don't try this at home 46 | path = os.path.split(self.path) 47 | app_version = path[1].replace('app-', '') 48 | discord_version = os.path.basename(path[0]) 49 | # Iterate through the paths... 50 | base = os.path.join(self.path, 'modules') 51 | versions = [] 52 | for directory in os.listdir(base): 53 | if directory.startswith('discord_desktop_core'): 54 | (_, _, version) = directory.partition('-') 55 | if version.isdigit(): 56 | versions.append(int(version)) 57 | else: 58 | # If we have an unversioned directory then maybe they stopped doing this dumb stuff. 59 | return os.path.join(base, directory) 60 | 61 | # Get the highest version number 62 | version = max(versions) 63 | return os.path.join(base, 'discord_desktop_core-%d' % version, 'discord_desktop_core') 64 | 65 | elif sys.platform == 'darwin': 66 | # macOS doesn't encode the app version in the path, but rather it stores it in the Info.plist 67 | # which we can find in the root directory e.g. 68 | # After we obtain the Info.plist, we parse it for the `CFBundleVersion` key 69 | # The actual path ends up being in ~/Library/Application Support///modules/... 70 | import plistlib as plist 71 | info = os.path.abspath(os.path.join(self.path, '..', 'Info.plist')) 72 | with open(info, 'rb') as fp: 73 | info = plist.load(fp) 74 | 75 | app_version = info['CFBundleVersion'] 76 | discord_version = info['CFBundleName'].replace(' ', '').lower() 77 | return os.path.expanduser(os.path.join('~/Library/Application Support', 78 | discord_version, 79 | app_version, 80 | 'modules/discord_desktop_core')) 81 | else: 82 | # Discord is available typically on /opt/discord-canary directory 83 | # The modules are under ~/.config/discordcanary/0.0.xx/modules/discord_desktop_core 84 | # To get the version number we have to iterate over ~/.config/discordcanary and find the 85 | # folder with the highest version number 86 | discord_version = os.path.basename(self.path).replace('-', '').lower() 87 | config = os.path.expanduser(os.path.join(os.getenv('XDG_CONFIG_HOME', '~/.config'), discord_version)) 88 | 89 | versions_found = {} 90 | for subdirectory in os.listdir(config): 91 | if not os.path.isdir(os.path.join(config, subdirectory)): 92 | continue 93 | 94 | try: 95 | # versions are A.B.C 96 | version_info = tuple(int(x) for x in subdirectory.split('.')) 97 | except Exception as e: 98 | # shucks 99 | continue 100 | else: 101 | versions_found[subdirectory] = version_info 102 | 103 | if len(versions_found) == 0: 104 | raise RuntimeError('Could not find discord application version under "{}".'.format(config)) 105 | 106 | app_version = max(versions_found.items(), key=lambda t: t[1]) 107 | return os.path.join(config, app_version[0], 'modules', 'discord_desktop_core') 108 | 109 | @property 110 | def script_file(self): 111 | return os.path.join(self.script_path, 'core', 'app', 'mainScreen.js') 112 | 113 | @property 114 | def preload_script(self): 115 | return os.path.join(self.script_path, 'core', 'app', 'mainScreenPreload.js') 116 | 117 | 118 | def extract_asar(): 119 | try: 120 | with Asar.open('./core.asar') as a: 121 | try: 122 | a.extract('./core') 123 | except FileExistsError: 124 | answer = input('asar already extracted, overwrite? (Y/n): ') 125 | 126 | if answer.lower().startswith('n'): 127 | print('Exiting.') 128 | return False 129 | 130 | shutil.rmtree('./core') 131 | a.extract('./core') 132 | 133 | shutil.move('./core.asar', './original_core.asar') 134 | except FileNotFoundError as e: 135 | print('WARNING: app.asar not found') 136 | 137 | return True 138 | 139 | def repack_asar(): 140 | try: 141 | with Asar.from_path('./core') as a: 142 | with open('./core.asar', 'wb') as fp: 143 | a.fp.seek(0) 144 | fp.write(a.fp.read()) 145 | shutil.rmtree('./core') 146 | except Exception as e: 147 | print('ERROR: {0.__class__.__name__} {0}'.format(e)) 148 | 149 | def parse_args(): 150 | description = """\ 151 | Unpacks Discord and adds CSS hot-reloading. 152 | 153 | Discord has to be open for this to work. When this tool is ran, 154 | Discord will close and then be relaunched when the tool completes. 155 | CSS files must have the ".css" extension. 156 | """ 157 | parser = argparse.ArgumentParser(description=description.strip()) 158 | parser.add_argument('--css', metavar='file_or_dir', help='Location of the file or directory to watch') 159 | parser.add_argument('--revert', action='store_true', help='Reverts any changes made to Discord (does not delete CSS)') 160 | args = parser.parse_args() 161 | return args 162 | 163 | def discord_process(): 164 | executables = {} 165 | for proc in psutil.process_iter(): 166 | try: 167 | (path, exe) = os.path.split(proc.exe()) 168 | except (psutil.Error, OSError): 169 | pass 170 | else: 171 | if exe.startswith('Discord') and not exe.endswith('Helper'): 172 | entry = executables.get(exe) 173 | 174 | if entry is None: 175 | entry = executables[exe] = DiscordProcess(path=path, exe=exe) 176 | 177 | entry.processes.append(proc) 178 | 179 | if len(executables) == 0: 180 | raise RuntimeError('Could not find Discord executable.') 181 | 182 | if len(executables) == 1: 183 | r = executables.popitem() 184 | print('Found {0.exe} under {0.path}'.format(r[1])) 185 | return r[1] 186 | 187 | lookup = list(executables) 188 | for index, exe in enumerate(lookup): 189 | print('%s: Found %s' % (index, exe)) 190 | 191 | while True: 192 | index = input("Discord executable to use (number): ") 193 | try: 194 | index = int(index) 195 | except ValueError as e: 196 | print('Invalid index passed') 197 | else: 198 | if index >= len(lookup) or index < 0: 199 | print('Index too big (or small)') 200 | else: 201 | key = lookup[index] 202 | return executables[key] 203 | 204 | def revert_changes(discord): 205 | try: 206 | shutil.move('./original_core.asar', './core.asar') 207 | except FileNotFoundError as e: 208 | print('No changes to revert.') 209 | else: 210 | print('Reverted changes, no more CSS hot-reload :(') 211 | 212 | discord.launch() 213 | 214 | def allow_https(): 215 | bypass_csp = textwrap.dedent(""" 216 | require("electron").session.defaultSession.webRequest.onHeadersReceived(({ responseHeaders }, done) => { 217 | let csp = responseHeaders["content-security-policy"]; 218 | if (!csp) return done({cancel: false}); 219 | let header = csp[0].replace(/connect-src ([^;]+);/, "connect-src $1 https://*;"); 220 | header = header.replace(/style-src ([^;]+);/, "style-src $1 https://*;"); 221 | header = header.replace(/img-src ([^;]+);/, "img-src $1 https://*;"); 222 | header = header.replace(/font-src ([^;]+);/, "font-src $1 https://*;"); 223 | responseHeaders["content-security-policy"] = header; 224 | done({ responseHeaders }); 225 | }); 226 | """) 227 | 228 | with open('./index.js', 'r+', encoding='utf-8') as f: 229 | content = f.read() 230 | if bypass_csp in content: 231 | print('CSP already bypassed, skipping.') 232 | return 233 | 234 | f.seek(0, 0) 235 | f.write(bypass_csp + '\n' + content) 236 | 237 | def main(): 238 | args = parse_args() 239 | try: 240 | discord = discord_process() 241 | except Exception as e: 242 | print(str(e)) 243 | return 244 | 245 | if args.css: 246 | args.css = os.path.abspath(args.css) 247 | else: 248 | args.css = os.path.join(discord.script_path, 'discord-custom.css') 249 | 250 | os.chdir(discord.script_path) 251 | 252 | args.css = os.path.abspath(args.css) 253 | 254 | discord.terminate() 255 | 256 | if args.revert: 257 | return revert_changes(discord) 258 | 259 | if not os.path.exists(args.css): 260 | with open(args.css, 'w', encoding='utf-8') as f: 261 | f.write('/* put your custom css here. */\n') 262 | 263 | if not extract_asar(): 264 | discord.launch() 265 | return 266 | 267 | css_injection_script = textwrap.dedent("""\ 268 | window._fileWatcher = null; 269 | window._styleTag = {}; 270 | 271 | window.applyCSS = function(path, name) { 272 | var customCSS = window.BeautifulDiscord.loadFile(path); 273 | if (!window._styleTag.hasOwnProperty(name)) { 274 | window._styleTag[name] = document.createElement("style"); 275 | document.documentElement.appendChild(window._styleTag[name]); 276 | } 277 | window._styleTag[name].innerHTML = customCSS; 278 | } 279 | 280 | window.clearCSS = function(name) { 281 | if (window._styleTag.hasOwnProperty(name)) { 282 | window._styleTag[name].innerHTML = ""; 283 | window._styleTag[name].parentElement.removeChild(window._styleTag[name]); 284 | delete window._styleTag[name]; 285 | } 286 | } 287 | 288 | window.watchCSS = function(path) { 289 | if (window.BeautifulDiscord.isDirectory(path)) { 290 | files = window.BeautifulDiscord.readDir(path); 291 | dirname = path; 292 | } else { 293 | files = [window.BeautifulDiscord.basename(path)]; 294 | dirname = window.BeautifulDiscord.dirname(path); 295 | } 296 | 297 | for (var i = 0; i < files.length; i++) { 298 | var file = files[i]; 299 | if (file.endsWith(".css")) { 300 | window.applyCSS(window.BeautifulDiscord.join(dirname, file), file) 301 | } 302 | } 303 | 304 | if(window._fileWatcher === null) { 305 | window._fileWatcher = window.BeautifulDiscord.watcher(path, 306 | function(eventType, filename) { 307 | if (!filename.endsWith(".css")) return; 308 | path = window.BeautifulDiscord.join(dirname, filename); 309 | if (eventType === "rename" && !window.BeautifulDiscord.pathExists(path)) { 310 | window.clearCSS(filename); 311 | } else { 312 | window.applyCSS(window.BeautifulDiscord.join(dirname, filename), filename); 313 | } 314 | } 315 | ); 316 | } 317 | }; 318 | 319 | window.tearDownCSS = function() { 320 | for (var key in window._styleTag) { 321 | if (window._styleTag.hasOwnProperty(key)) { 322 | window.clearCSS(key) 323 | } 324 | } 325 | if(window._fileWatcher !== null) { window._fileWatcher.close(); window._fileWatcher = null; } 326 | }; 327 | 328 | window.removeDuplicateCSS = function(){ 329 | const styles = [...document.getElementsByTagName("style")]; 330 | const styleTags = window._styleTag; 331 | 332 | for(let key in styleTags){ 333 | for (var i = 0; i < styles.length; i++) { 334 | const keyStyle = styleTags[key]; 335 | const curStyle = styles[i]; 336 | 337 | if(curStyle !== keyStyle) { 338 | const compare = keyStyle.innerText.localeCompare(curStyle.innerText); 339 | 340 | if(compare === 0){ 341 | const parent = curStyle.parentElement; 342 | parent.removeChild(curStyle); 343 | } 344 | } 345 | } 346 | } 347 | }; 348 | 349 | 350 | window.applyAndWatchCSS = function(path) { 351 | window.tearDownCSS(); 352 | window.watchCSS(path); 353 | }; 354 | 355 | window.applyAndWatchCSS('%s'); 356 | window.removeDuplicateCSS(); 357 | """ % args.css.replace('\\', '/')) 358 | 359 | 360 | css_reload_script = textwrap.dedent("""\ 361 | mainWindow.webContents.on('dom-ready', function () { 362 | mainWindow.webContents.executeJavaScript(`%s`); 363 | }); 364 | """ % css_injection_script) 365 | 366 | load_file_script = textwrap.dedent("""\ 367 | const bd_fs = require('fs'); 368 | const bd_path = require('path'); 369 | 370 | contextBridge.exposeInMainWorld('BeautifulDiscord', { 371 | loadFile: (fileName) => { 372 | return bd_fs.readFileSync(fileName, 'utf-8'); 373 | }, 374 | readDir: (p) => { 375 | return bd_fs.readdirSync(p); 376 | }, 377 | pathExists: (p) => { 378 | return bd_fs.existsSync(p); 379 | }, 380 | watcher: (p, cb) => { 381 | return bd_fs.watch(p, { encoding: "utf-8" }, cb); 382 | }, 383 | join: (a, b) => { 384 | return bd_path.join(a, b); 385 | }, 386 | basename: (p) => { 387 | return bd_path.basename(p); 388 | }, 389 | dirname: (p) => { 390 | return bd_path.dirname(p); 391 | }, 392 | isDirectory: (p) => { 393 | return bd_fs.lstatSync(p).isDirectory() 394 | } 395 | }); 396 | 397 | process.once('loaded', () => { 398 | global.require = require; 399 | """) 400 | 401 | with open(discord.preload_script, 'rb') as fp: 402 | preload = fp.read() 403 | 404 | if b"contextBridge.exposeInMainWorld('BeautifulDiscord'," not in preload: 405 | preload = preload.replace(b"process.once('loaded', () => {", load_file_script.encode('utf-8'), 1) 406 | 407 | with open(discord.preload_script, 'wb') as fp: 408 | fp.write(preload) 409 | else: 410 | print('info: preload script has already been injected, skipping') 411 | 412 | with open(discord.script_file, 'rb') as f: 413 | entire_thing = f.read() 414 | 415 | index = entire_thing.index(b"mainWindow.on('blur'") 416 | 417 | if index == -1: 418 | # failed replace for some reason? 419 | print('warning: nothing was done.\n' \ 420 | 'note: blur event was not found for the injection point.') 421 | revert_changes(discord) 422 | discord.launch() 423 | return 424 | 425 | # yikes 426 | to_write = entire_thing[:index] + css_reload_script.encode('utf-8') + entire_thing[index:] 427 | 428 | with open(discord.script_file, 'wb') as f: 429 | f.write(to_write) 430 | 431 | # allow links with https to bypass csp 432 | allow_https() 433 | 434 | # repack the asar so discord stops complaining 435 | repack_asar() 436 | 437 | print( 438 | '\nDone!\n' + 439 | '\nYou may now edit your %s file,\n' % os.path.abspath(args.css) + 440 | "which will be reloaded whenever it's saved.\n" + 441 | '\nRelaunching Discord now...' 442 | ) 443 | 444 | discord.launch() 445 | 446 | 447 | if __name__ == '__main__': 448 | main() 449 | -------------------------------------------------------------------------------- /beautifuldiscord/asar.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | import json 4 | import errno 5 | import struct 6 | import shutil 7 | 8 | 9 | def round_up(i, m): 10 | """Rounds up ``i`` to the next multiple of ``m``. 11 | 12 | ``m`` is assumed to be a power of two. 13 | """ 14 | return (i + m - 1) & ~(m - 1) 15 | 16 | 17 | class Asar: 18 | 19 | """Represents an asar file. 20 | 21 | You probably want to use the :meth:`.open` or :meth:`.from_path` 22 | class methods instead of creating an instance of this class. 23 | 24 | Attributes 25 | ---------- 26 | path : str 27 | Path of this asar file on disk. 28 | If :meth:`.from_path` is used, this is just 29 | the path given to it. 30 | fp : File-like object 31 | Contains the data for this asar file. 32 | header : dict 33 | Dictionary used for random file access. 34 | base_offset : int 35 | Indicates where the asar file header ends. 36 | """ 37 | 38 | def __init__(self, path, fp, header, base_offset): 39 | self.path = path 40 | self.fp = fp 41 | self.header = header 42 | self.base_offset = base_offset 43 | 44 | @classmethod 45 | def open(cls, path): 46 | """Decodes the asar file from the given ``path``. 47 | 48 | You should use the context manager interface here, 49 | to automatically close the file object when you're done with it, i.e. 50 | 51 | .. code-block:: python 52 | 53 | with Asar.open('./something.asar') as a: 54 | a.extract('./something_dir') 55 | 56 | Parameters 57 | ---------- 58 | path : str 59 | Path of the file to be decoded. 60 | """ 61 | fp = open(path, 'rb') 62 | 63 | # decode header 64 | # NOTE: we only really care about the last value here. 65 | data_size, header_size, header_object_size, header_string_size = struct.unpack('<4I', fp.read(16)) 66 | 67 | header_json = fp.read(header_string_size).decode('utf-8') 68 | 69 | return cls( 70 | path=path, 71 | fp=fp, 72 | header=json.loads(header_json), 73 | base_offset=round_up(16 + header_string_size, 4) 74 | ) 75 | 76 | @classmethod 77 | def from_path(cls, path): 78 | """Creates an asar file using the given ``path``. 79 | 80 | When this is used, the ``fp`` attribute of the returned instance 81 | will be a :class:`io.BytesIO` object, so it's not written to a file. 82 | You have to do something like: 83 | 84 | .. code-block:: python 85 | 86 | with Asar.from_path('./something_dir') as a: 87 | with open('./something.asar', 'wb') as f: 88 | a.fp.seek(0) # just making sure we're at the start of the file 89 | f.write(a.fp.read()) 90 | 91 | You cannot exclude files/folders from being packed yet. 92 | 93 | Parameters 94 | ---------- 95 | path : str 96 | Path to walk into, recursively, and pack 97 | into an asar file. 98 | """ 99 | offset = 0 100 | concatenated_files = b'' 101 | 102 | def _path_to_dict(path): 103 | nonlocal concatenated_files, offset 104 | result = {'files': {}} 105 | 106 | for f in os.scandir(path): 107 | if os.path.isdir(f.path): 108 | result['files'][f.name] = _path_to_dict(f.path) 109 | elif f.is_symlink(): 110 | result['files'][f.name] = { 111 | 'link': os.path.realpath(f.name) 112 | } 113 | else: 114 | size = f.stat().st_size 115 | 116 | result['files'][f.name] = { 117 | 'size': size, 118 | 'offset': str(offset) 119 | } 120 | 121 | with open(f.path, 'rb') as fp: 122 | concatenated_files += fp.read() 123 | 124 | offset += size 125 | 126 | return result 127 | 128 | header = _path_to_dict(path) 129 | header_json = json.dumps(header, sort_keys=True, separators=(',', ':')).encode('utf-8') 130 | 131 | # TODO: using known constants here for now (laziness)... 132 | # we likely need to calc these, but as far as discord goes we haven't needed it. 133 | header_string_size = len(header_json) 134 | data_size = 4 # uint32 size 135 | aligned_size = round_up(header_string_size, data_size) 136 | header_size = aligned_size + 8 137 | header_object_size = aligned_size + data_size 138 | 139 | # pad remaining space with NULLs 140 | diff = aligned_size - header_string_size 141 | header_json = header_json + b'\0' * (diff) if diff else header_json 142 | 143 | fp = io.BytesIO() 144 | fp.write(struct.pack('<4I', data_size, header_size, header_object_size, header_string_size)) 145 | fp.write(header_json) 146 | fp.write(concatenated_files) 147 | 148 | return cls( 149 | path=path, 150 | fp=fp, 151 | header=header, 152 | base_offset=round_up(16 + header_string_size, 4) 153 | ) 154 | 155 | def _copy_unpacked_file(self, source, destination): 156 | """Copies an unpacked file to where the asar is extracted to. 157 | 158 | An example: 159 | 160 | . 161 | ├── test.asar 162 | └── test.asar.unpacked 163 | ├── abcd.png 164 | ├── efgh.jpg 165 | └── test_subdir 166 | └── xyz.wav 167 | 168 | If we are extracting ``test.asar`` to a folder called ``test_extracted``, 169 | not only the files concatenated in the asar will go there, but also 170 | the ones inside the ``*.unpacked`` folder too. 171 | 172 | That is, after extraction, the previous example will look like this: 173 | 174 | . 175 | ├── test.asar 176 | ├── test.asar.unpacked 177 | | └── ... 178 | └── test_extracted 179 | ├── whatever_was_inside_the_asar.js 180 | ├── junk.js 181 | ├── abcd.png 182 | ├── efgh.jpg 183 | └── test_subdir 184 | └── xyz.wav 185 | 186 | In the asar header, they will show up without an offset, and ``"unpacked": true``. 187 | 188 | Currently, if the expected directory doesn't already exist (or the file isn't there), 189 | a message is printed to stdout. It could be logged in a smarter way but that's a TODO. 190 | 191 | Parameters 192 | ---------- 193 | source : str 194 | Path of the file to locate and copy 195 | destination : str 196 | Destination folder to copy file into 197 | """ 198 | unpacked_dir = self.path + '.unpacked' 199 | if not os.path.isdir(unpacked_dir): 200 | print("Couldn't copy file {}, no extracted directory".format(source)) 201 | return 202 | 203 | src = os.path.join(unpacked_dir, source) 204 | if not os.path.exists(src): 205 | print("Couldn't copy file {}, doesn't exist".format(src)) 206 | return 207 | 208 | dest = os.path.join(destination, source) 209 | shutil.copyfile(src, dest) 210 | 211 | def _extract_file(self, source, info, destination): 212 | """Locates and writes to disk a given file in the asar archive. 213 | 214 | Parameters 215 | ---------- 216 | source : str 217 | Path of the file to write to disk 218 | info : dict 219 | Contains offset and size if applicable. 220 | If offset is not given, the file is assumed to be 221 | sitting outside of the asar, unpacked. 222 | destination : str 223 | Destination folder to write file into 224 | 225 | See Also 226 | -------- 227 | :meth:`._copy_unpacked_file` 228 | """ 229 | if 'offset' not in info: 230 | self._copy_unpacked_file(source, destination) 231 | return 232 | 233 | self.fp.seek(self.base_offset + int(info['offset'])) 234 | r = self.fp.read(int(info['size'])) 235 | 236 | dest = os.path.join(destination, source) 237 | with open(dest, 'wb') as f: 238 | f.write(r) 239 | 240 | def _extract_link(self, source, link, destination): 241 | """Creates a symbolic link to a file we extracted (or will extract). 242 | 243 | Parameters 244 | ---------- 245 | source : str 246 | Path of the symlink to create 247 | link : str 248 | Path of the file the symlink should point to 249 | destination : str 250 | Destination folder to create the symlink into 251 | """ 252 | dest_filename = os.path.normpath(os.path.join(destination, source)) 253 | link_src_path = os.path.dirname(os.path.join(destination, link)) 254 | link_to = os.path.join(link_src_path, os.path.basename(link)) 255 | 256 | try: 257 | os.symlink(link_to, dest_filename) 258 | except OSError as e: 259 | if e.errno == errno.EXIST: 260 | os.unlink(dest_filename) 261 | os.symlink(link_to, dest_filename) 262 | else: 263 | raise e 264 | 265 | def _extract_directory(self, source, files, destination): 266 | """Extracts all the files in a given directory. 267 | 268 | If a sub-directory is found, this calls itself as necessary. 269 | 270 | Parameters 271 | ---------- 272 | source : str 273 | Path of the directory 274 | files : dict 275 | Maps a file/folder name to another dictionary, 276 | containing either file information, 277 | or more files. 278 | destination : str 279 | Where the files in this folder should go to 280 | """ 281 | dest = os.path.normpath(os.path.join(destination, source)) 282 | 283 | if not os.path.exists(dest): 284 | os.makedirs(dest) 285 | 286 | for name, info in files.items(): 287 | item_path = os.path.join(source, name) 288 | 289 | if 'files' in info: 290 | self._extract_directory(item_path, info['files'], destination) 291 | elif 'link' in info: 292 | self._extract_link(item_path, info['link'], destination) 293 | else: 294 | self._extract_file(item_path, info, destination) 295 | 296 | def extract(self, path): 297 | """Extracts this asar file to ``path``. 298 | 299 | Parameters 300 | ---------- 301 | path : str 302 | Destination of extracted asar file. 303 | """ 304 | if os.path.exists(path): 305 | raise FileExistsError() 306 | 307 | self._extract_directory('.', self.header['files'], path) 308 | 309 | def __enter__(self): 310 | return self 311 | 312 | def __exit__(self, exc_type, exc_value, traceback): 313 | self.fp.close() 314 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | psutil 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | 4 | with open('requirements.txt') as f: 5 | requirements = f.read().splitlines() 6 | 7 | with open('README.md') as f: 8 | readme = f.read() 9 | 10 | 11 | setup( 12 | name='BeautifulDiscord', 13 | author='leovoel', 14 | url='https://github.com/leovoel/BeautifulDiscord', 15 | version='0.2.0', 16 | license='MIT', 17 | description='Adds custom CSS support to Discord.', 18 | long_description=readme, 19 | packages=find_packages(), 20 | install_requires=requirements, 21 | include_package_data=True, 22 | entry_points={'console_scripts': ['beautifuldiscord=beautifuldiscord.app:main']} 23 | ) 24 | --------------------------------------------------------------------------------