commit
147cc3e0fa
15 changed files with 1793 additions and 0 deletions
-
12.editorconfig
-
1.gitignore
-
1168Cargo.lock
-
18Cargo.toml
-
38LICENSE.md
-
18README.md
-
20src/injector.rs
-
133src/main.rs
-
36src/open_url.rs
-
51src/panel/index.html
-
114src/panel/injector.template.js
-
87src/panel/panel.css
-
47src/panel/panel.js
-
48src/themes.rs
-
2styles/.gitignore
@ -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 |
@ -0,0 +1 @@ |
|||
/target |
1168
Cargo.lock
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
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"] } |
@ -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. |
@ -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) |
@ -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)
|
|||
}
|
@ -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;
|
|||
}
|
@ -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();
|
|||
}
|
@ -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> |
@ -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); |
|||
} |
|||
} |
@ -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; |
|||
} |
@ -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); |
|||
} |
@ -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
|
|||
}
|
@ -0,0 +1,2 @@ |
|||
* |
|||
!.gitignore |
Write
Preview
Loading…
Cancel
Save
Reference in new issue