Initial commit - Split from Lesbos theme

This commit is contained in:
charlotte ✨ 2021-07-10 16:57:08 +01:00
commit 147cc3e0fa
15 changed files with 1793 additions and 0 deletions

12
.editorconfig Normal file
View File

@ -0,0 +1,12 @@
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = false
insert_final_newline = true
[*.rs]
indent_size = 4

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

1168
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

18
Cargo.toml Normal file
View File

@ -0,0 +1,18 @@
[package]
name = "discord-css-injector"
authors = ["videogame hacker <half-kh-hacker@hackery.site>"]
version = "0.1.0"
edition = "2018"
[dependencies]
include_dir = "0.6.1"
minifier = "0.0.41"
once_cell = "1.8.0"
serde = { version = "1.0.126", features = ["derive"] }
serde_json = "1.0.64"
tokio = { version = "1.8.0", features = ["full"] }
warp = "0.3.1"
# Why is opening a browser on Windows so annoying???
[target.'cfg(target_os = "windows")'.dependencies]
winapi = { version = "0.3.9", features = ["shellapi"] }

38
LICENSE.md Normal file
View File

@ -0,0 +1,38 @@
The Charlotte Public License version 0.1
Copyright 2021, Charlotte Som (the "Author" henceforth).
This license gives everyone permission to examine, modify, and use this software
and the associated documentation (the "Inator"), without patent obstacles, while protecting
the Author and any contributors (the "Composers") from liability.
Each Composer permits you to examine, modify, utilize, and distribute the Inator
where it would otherwise infringe upon that Composer's copyright or any patent claims that
they hold.
No contributor may revoke this license, but the Author may choose to release the Inator
(including the contributed works of any other Composer) under a different license.
You may not use the Inator to accrue revenue without explicit permission from the Author.
You may not use the Inator to do Malevolence. If you are
notified that you have committed a Malevolence instrumented by the Inator, your license is
terminated unless you take all practical steps to comply within a reasonable timeframe.
The definition of Malevolence is at the discretion of the Author. It may include, but is not
limited to:
- The promotion of bigotry, including: sexism, transphobia, homophobia, ableism, or the
perpetuation of racial oppression.
- Causing a detriment to public health.
- Instigating political, economic, or corporeal violence.
- Entrenching an empire.
- Where applicable, use of the Inator without the informed consent of a second party who may
object to its use.
The Inator is provided without any warranty, "as-is". No Composer is liable for any damages
related to the Inator.
In order to receive this license, you must agree to the terms set out in this document.
This license, authorial attribution, and copyright notice must be distributed with
any copies or large portions of the Inator.

18
README.md Normal file
View File

@ -0,0 +1,18 @@
# discord-css-injector
This is a theme injector that doesn't require patching the Discord client.
As a bonus, this means that you can easily use themes in Discord's web application,
but the downside is that you have to apply your theme manually every time you start Discord.
## Usage
```shell
$ cargo run
[...]
Listening on: http://127.0.0.1:12345/
```
1. A browser should open at the given URL
2. Choose the theme you want to apply
3. Hit 'Copy JS'
4. Paste into Discord's DevTools console (you can hit Ctrl + Shift + I to open this)

20
src/injector.rs Normal file
View File

@ -0,0 +1,20 @@
use std::net::SocketAddr;
use crate::themes::Theme;
const INJECTOR_TEMPLATE: &str = include_str!("./panel/injector.template.js");
pub fn render_injector(current_addr: &SocketAddr, theme: &Theme) -> String {
let stylesheet_urls: Vec<String> = theme
.files
.iter()
.map(|f| format!("http://{}/styles/{}/{}", current_addr, &theme.slug, f))
.collect();
let stylesheet_urls =
serde_json::to_string(&stylesheet_urls).expect("Couldn't encode stylesheet URLs");
let injector_src = INJECTOR_TEMPLATE.replace("/* STYLESHEET_URLS */null", &stylesheet_urls);
minifier::js::minify(&injector_src)
}

133
src/main.rs Normal file
View File

@ -0,0 +1,133 @@
// Incantation to not allocate a Windows console when compiled in release mode
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::{collections::HashMap, net::SocketAddr, time::Duration};
use include_dir::{include_dir, Dir};
use once_cell::sync::{Lazy, OnceCell};
use warp::{
http::HeaderValue,
hyper::HeaderMap,
reply::{json, Response},
Filter, Rejection, Reply,
};
mod injector;
mod open_url;
mod themes;
use crate::themes::{read_theme_from_dir, Theme};
static CURRENT_ADDRESS: OnceCell<SocketAddr> = OnceCell::new();
static THEMES: Lazy<HashMap<String, Theme>> = Lazy::new(|| {
let mut themes = HashMap::new();
for entry in std::fs::read_dir("styles").unwrap() {
if entry.is_err() {
continue;
}
let path = entry.unwrap().path();
if path.is_dir() {
if let Some(theme) = read_theme_from_dir(path) {
themes.insert(theme.slug.clone(), theme);
}
}
}
themes
});
fn injector(theme_name: String) -> String {
if let Some(theme) = THEMES.get(&theme_name) {
let current_addr = CURRENT_ADDRESS.get().expect("Couldn't get current address");
injector::render_injector(current_addr, theme)
} else {
"// Unknown theme, sorry!".to_string()
}
}
fn themes() -> impl Reply {
let themes: &HashMap<_, _> = &THEMES;
json(themes)
}
fn shutdown() -> String {
let _ = std::thread::spawn(|| {
std::thread::sleep(Duration::from_millis(500));
std::process::exit(0)
});
"Goodbye!".to_string()
}
const PANEL_DIR: Dir = include_dir!("./src/panel");
async fn serve_panel_file(path: warp::path::Tail) -> Result<Response, Rejection> {
let mut file_name = path.as_str().to_string();
if ("/".to_string() + &file_name).ends_with("/") {
file_name.push_str("index.html");
}
if let Some(file) = PANEL_DIR.get_file(&file_name) {
let (mut head, body) = Response::new(file.contents().into()).into_parts();
let extension = file_name.rfind('.').map(|idx| &file_name[idx..]);
let content_type = match extension {
Some(".html") => "text/html; charset=utf-8",
Some(".css") => "text/css; charset=utf-8",
Some(".js") => "application/javascript; charset=utf-8",
Some(".txt") => "text/plain; charset=utf-8",
_ => "application/octet-stream",
};
head.headers
.insert("Content-Type", HeaderValue::from_str(content_type).unwrap());
return Ok(Response::from_parts(head, body));
}
Err(warp::reject::not_found())
}
#[tokio::main]
async fn main() {
let styles_route = {
let mut static_headers = HeaderMap::new();
for (h, v) in [
("Access-Control-Allow-Origin", "*"),
("Pragma", "no-cache"),
("Cache-Control", "no-cache"),
] {
static_headers.insert(h, HeaderValue::from_static(v));
}
warp::path("styles")
.and(warp::fs::dir("styles"))
.with(warp::reply::with::headers(static_headers))
};
let themes_list_route = warp::path("themes.json").map(themes);
let injector_route = warp::path!("theme" / String / "injector.js").map(injector);
let panel_route = warp::get()
.and(warp::path::tail())
.and_then(serve_panel_file);
let shutdown_route = warp::post().and(warp::path("shutdown")).map(shutdown);
let routes = styles_route
.or(injector_route)
.or(themes_list_route)
.or(panel_route)
.or(shutdown_route);
let (addr, listener) = warp::serve(routes).bind_ephemeral(([127, 0, 0, 1], 0));
println!("Listening on: http://{}/", &addr);
open_url::open(&format!("http://{}/", addr));
CURRENT_ADDRESS
.set(addr)
.expect("Could not set current address");
listener.await;
}

36
src/open_url.rs Normal file
View File

@ -0,0 +1,36 @@
#[cfg(target_os = "windows")]
pub fn open(url: &str) {
extern crate winapi;
use std::ffi::CString;
use std::ptr;
use winapi::um::shellapi::ShellExecuteA;
unsafe {
let open_str = CString::new("open").unwrap();
let url_str = CString::new(url.to_string().replace("\n", "%0A")).unwrap();
ShellExecuteA(
ptr::null_mut(),
open_str.as_ptr(),
url_str.as_ptr(),
ptr::null(),
ptr::null(),
winapi::um::winuser::SW_SHOWNORMAL,
);
}
}
#[cfg(target_os = "macos")]
pub fn open(url: &str) {
let _ = std::process::Command::new("open")
.arg(url.to_string())
.output();
}
#[cfg(target_os = "linux")]
pub fn open(url: &str) {
let _ = std::process::Command::new("xdg-open")
.arg(url.to_string())
.output();
}

51
src/panel/index.html Normal file
View File

@ -0,0 +1,51 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>discord themes - local injector</title>
<link rel="stylesheet" href="/panel.css">
</head>
<body>
<main>
<header>
<h1>discord themes</h1>
<em>without patching your client :)</em>
</header>
<section>
<h2>Themes</h2>
<p>choose a theme, hit the 'Copy JS' button, and paste it into the Discord console:</p>
<ul id="themes"></ul>
</section>
<section>
<form id="shutdown-form" method="POST" action="/shutdown">
<h2>Exit</h2>
<p>To exit the theming provider, you can hit this button.</p>
<input type="submit" value="Exit">
</form>
</section>
</main>
<footer>
<p>
hey, uh, discord say that pasting untrusted things into the console
can give malicious actors access to your account.
this is <strong>entirely true</strong>. be careful!
</p>
<p>i pinkie swear that I'm not trying to hack you, though</p>
<p>a project by <a href="https://som.codes">charlotte som</a>.</p>
</footer>
<script src="/panel.js"></script>
</body>
</html>

View File

@ -0,0 +1,114 @@
{
const STYLESHEET_URLS = /* STYLESHEET_URLS */null;
const denormalisedRegex = /(.*)-[A-Za-z0-9\-\_]{6}$/;
const setupNormalization = () => {
const addNormalizedClassNames = (element) => {
const normalizedClasses = [];
if (!element.classList)
return;
for (const className of element.classList) {
const matches = className.match(denormalisedRegex);
if (matches) {
if (matches[0].startsWith("hljs-"))
continue;
const normalized = matches[1];
normalizedClasses.push(normalized);
}
}
normalizedClasses.forEach(it => element.classList.add(it));
};
for (const element of document.querySelectorAll("*"))
addNormalizedClassNames(element);
const obs = new MutationObserver((mutations, observer) => {
for (const mutation of mutations) {
if (mutation.type === "childList") {
for (const node of mutation.addedNodes) {
addNormalizedClassNames(node);
for (const subNode of node.querySelectorAll("*"))
addNormalizedClassNames(subNode);
}
}
if (mutation.type === "attributes") {
// If we don't check this, we get into some infinite loop shenanigans
let alreadyNormalized = false;
for (const className of mutation.target.classList) {
if (!className.match(denormalisedRegex)) {
alreadyNormalized = true;
break;
}
}
if (!alreadyNormalized)
addNormalizedClassNames(mutation.target);
}
}
});
obs.observe(
document.documentElement,
{
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["class"]
}
);
};
const injectSingleCSS = async (url) => {
const text = await fetch(url).then(r => r.text());
const style = document.createElement("style");
style.innerText = text;
document.head.appendChild(style);
return style;
};
const main = async () => {
if (window.stylesheetsToInject) {
window.stylesheetsToInject = STYLESHEET_URLS;
window.reloadCSS();
return;
}
setupNormalization();
let tags = [];
const applyCSS = async (urls) => {
const promises = [];
for (const url of urls)
promises.push(injectSingleCSS(url));
tags = [];
for (const t of (await Promise.all(promises)))
tags.push(t);
};
window.stylesheetsToInject = STYLESHEET_URLS;
await applyCSS(window.stylesheetsToInject);
window.reloadCSS = async () => {
for (const tag of tags) {
tag.remove();
}
await applyCSS(window.stylesheetsToInject);
};
};
if (document.readyState === "complete") {
main();
} else {
document.addEventListener("DOMContentLoaded", main);
}
}

87
src/panel/panel.css Normal file
View File

@ -0,0 +1,87 @@
:root {
--bg: #292841 !important;
--accent: #b4a4f8;
--accent-dark: #8971f4;
--success: #ffd1dc;
--fg: #fbfbfb !important;
}
html, body {
font-family: sans-serif;
background-color: var(--bg);
color: var(--fg);
}
body {
margin-top: 0;
margin-bottom: 0;
padding-top: 0;
padding-bottom: 0;
min-height: 100vh;
display: flex;
flex-direction: column;
}
a {
color: var(--accent);
}
main {
flex: 1;
}
main, footer {
margin: 0 auto;
max-width: 60ch;
}
header {
padding-bottom: 1em;
margin-bottom: 1em;
border-bottom: 1px solid var(--fg);
}
footer {
padding-top: 1em;
margin-top: 1em;
border-top: 1px solid var(--fg);
}
button, input[type=submit] {
background-color: var(--accent);
border: var(--accent-dark);
border-radius: 6px;
color: var(--fg);
padding: 0.25em 0.5em;
display: inline-block;
text-align: center;
white-space: nowrap;
vertical-align: middle;
user-select: none;
border: 1px solid rgba(0,0,0,0);
font-size: 1rem;
line-height: 1.5;
transition: color .15s ease-in-out, background-color .15s ease-in-out;
}
button.copied {
background-color: var(--success);
color: var(--bg);
}
li {
display: flex;
flex-direction: row;
align-items: center;
}
#shutdown-form {
margin-top: 4em;
}
#shutdown-form > input[type=submit] {
background-color: red;
}

47
src/panel/panel.js Normal file
View File

@ -0,0 +1,47 @@
const main = async () => {
const themesContainer = document.querySelector("#themes");
const themes = await fetch("/themes.json").then(r => r.json());
for (const slug in themes) {
const theme = themes[slug];
// TODO
const listItem = document.createElement("li");
const themeName = document.createElement("strong");
themeName.textContent = theme.name;
const themeDescription = document.createElement("em");
themeDescription.textContent = theme.description;
const copyButton = document.createElement("button");
copyButton.textContent = "Copy JS";
copyButton.addEventListener("click", e => {
e.preventDefault();
(async () => {
const injectorJS = await fetch(`/theme/${slug}/injector.js`).then(r => r.text());
await navigator.clipboard.writeText(injectorJS)
copyButton.textContent = "Copied!";
copyButton.classList.add("copied");
setTimeout(() => {
copyButton.textContent = "Copy JS";
copyButton.classList.remove("copied");
}, 2500);
})();
});
listItem.appendChild(themeName);
listItem.appendChild(themeDescription);
listItem.appendChild(copyButton);
themesContainer.appendChild(listItem);
}
};
if (document.readyState === "complete") {
main();
} else {
document.addEventListener("DOMContentLoaded", main);
}

48
src/themes.rs Normal file
View File

@ -0,0 +1,48 @@
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct Theme {
pub name: String,
pub description: String,
#[serde(skip)]
pub slug: String,
#[serde(skip)]
pub files: Vec<String>,
}
pub fn read_theme_from_dir(dir: PathBuf) -> Option<Theme> {
if let Some((slug, mut theme)) = dir
.file_name()
.map(|dir_name| dir_name.to_string_lossy().to_string())
.and_then(|slug| {
std::fs::read_to_string(dir.join("theme.json"))
.map(|theme_json| (slug, theme_json))
.ok()
})
.and_then(|(slug, theme_json)| {
serde_json::from_str::<Theme>(&theme_json)
.ok()
.map(|theme| (slug, theme))
})
{
theme.slug = slug.clone();
let files: Vec<_> = std::fs::read_dir(dir)
.unwrap()
.into_iter()
.filter(|r| r.is_ok())
.map(|r| r.unwrap())
.map(|e| e.file_name().to_string_lossy().to_string())
.filter(|n| n.ends_with(".css"))
.collect();
theme.files = files;
return Some(theme);
}
None
}

2
styles/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore