Browse Source

Use a web view instead of opening a browser

This also means we don't need to have the 'exit' button on the panel
page any more, since we can just exit the application when the web view
is closed.
main
charlotte ✨ 7 months ago
parent
commit
7f045fc9e9
  1. 1014
      Cargo.lock
  2. 2
      Cargo.toml
  3. 6
      src/injector.rs
  4. 139
      src/main.rs
  5. 36
      src/open_url.rs
  6. 107
      src/panel.rs
  7. 18
      src/panel/index.html
  8. 33
      src/panel/panel.js
  9. 29
      src/web_view.rs

1014
Cargo.lock
File diff suppressed because it is too large
View File

2
Cargo.toml

@ -5,6 +5,7 @@ version = "0.1.0"
edition = "2018"
[dependencies]
futures = "0.3.15"
include_dir = "0.6.1"
minifier = "0.0.41"
once_cell = "1.8.0"
@ -12,6 +13,7 @@ serde = { version = "1.0.126", features = ["derive"] }
serde_json = "1.0.64"
tokio = { version = "1.8.0", features = ["full"] }
warp = "0.3.1"
wry = { version = "0.10.3", default-features = false, features = ["win32"] }
# Why is opening a browser on Windows so annoying???
[target.'cfg(target_os = "windows")'.dependencies]

6
src/injector.rs

@ -1,14 +1,12 @@
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 {
pub fn render_injector(host: String, theme: &Theme) -> String {
let stylesheet_urls: Vec<String> = theme
.files
.iter()
.map(|f| format!("http://{}/styles/{}/{}", current_addr, &theme.slug, f))
.map(|f| format!("http://{}/styles/{}/{}", host, &theme.slug, f))
.collect();
let stylesheet_urls =

139
src/main.rs

@ -1,133 +1,30 @@
// 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 panel;
mod themes;
mod web_view;
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;
}
use std::{net::SocketAddr, sync::mpsc, thread};
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()
}
async fn serve_panel(addr_listener: mpsc::Sender<SocketAddr>) {
let (panel_addr, panel_listener) = crate::panel::serve_panel();
addr_listener.send(panel_addr).unwrap();
panel_listener.await;
}
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)
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("Couldn't create async runtime");
runtime.block_on(serve_panel(tx));
});
"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;
let panel_addr = rx.recv().unwrap();
crate::web_view::open_webview(&format!("http://{}/", panel_addr))
.expect("Failed to open web view");
}

36
src/open_url.rs

@ -1,36 +0,0 @@
#[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();
}

107
src/panel.rs

@ -0,0 +1,107 @@
use std::{collections::HashMap, net::SocketAddr};
use futures::Future;
use include_dir::{include_dir, Dir};
use once_cell::sync::Lazy;
use warp::{
http::HeaderValue,
hyper::HeaderMap,
reply::{json, Response},
Filter, Rejection, Reply,
};
use crate::themes::{read_theme_from_dir, Theme};
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, host: String) -> String {
if let Some(theme) = THEMES.get(&theme_name) {
// let current_addr = CURRENT_ADDRESS.get().expect("Couldn't get current address");
crate::injector::render_injector(host, theme)
} else {
"// Unknown theme, sorry!".to_string()
}
}
fn themes() -> impl Reply {
let themes: &HashMap<_, _> = &THEMES;
json(themes)
}
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())
}
pub fn serve_panel() -> (SocketAddr, impl Future<Output = ()>) {
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")
.and(warp::header("Host"))
.map(injector);
let panel_route = warp::get()
.and(warp::path::tail())
.and_then(serve_panel_file);
let routes = styles_route
.or(injector_route)
.or(themes_list_route)
.or(panel_route);
warp::serve(routes).bind_ephemeral(([127, 0, 0, 1], 0))
}

18
src/panel/index.html

@ -14,22 +14,8 @@
<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>
<p>choose a theme, hit the 'Copy JS' button, and paste it into the Discord console:</p>
<ul id="themes"></ul>
</main>
<footer>

33
src/panel/panel.js

@ -1,3 +1,34 @@
const copyToClipboard = (str) => {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(str);
}
// Since webkit2gtk doesn't have the async clipboard API,
// we have a polyfill that uses some strange magic instead.
return new Promise(function (resolve, reject) {
// Apparently, the copy event doesn't work with WebKit
// if you don't have anything selected...
var range = document.createRange();
range.selectNodeContents(document.body);
document.getSelection().addRange(range);
var success = false;
function listener(e) {
e.clipboardData.setData("text/plain", str);
e.preventDefault();
success = true;
}
document.addEventListener("copy", listener);
document.execCommand("copy");
document.removeEventListener("copy", listener);
// Make sure we unselect the page!
document.getSelection().removeAllRanges();
success ? resolve() : reject();
});
};
const main = async () => {
const themesContainer = document.querySelector("#themes");
const themes = await fetch("/themes.json").then(r => r.json());
@ -21,7 +52,7 @@ const main = async () => {
(async () => {
const injectorJS = await fetch(`/theme/${slug}/injector.js`).then(r => r.text());
await navigator.clipboard.writeText(injectorJS)
await copyToClipboard(injectorJS)
copyButton.textContent = "Copied!";
copyButton.classList.add("copied");

29
src/web_view.rs

@ -0,0 +1,29 @@
use wry::{
application::{
event::{Event, WindowEvent},
event_loop::{ControlFlow, EventLoop},
window::WindowBuilder,
},
webview::WebViewBuilder,
Result,
};
pub fn open_webview(target_url: &str) -> Result<()> {
let event_loop = EventLoop::new();
let window = WindowBuilder::new()
.with_title("Discord Theme Injector")
.build(&event_loop)?;
let _webview = WebViewBuilder::new(window)?.with_url(target_url)?.build()?;
event_loop.run(move |event, _, control_flow| {
*control_flow = ControlFlow::Wait;
if let Event::WindowEvent {
event: WindowEvent::CloseRequested,
..
} = event
{
*control_flow = ControlFlow::Exit
}
});
}
Loading…
Cancel
Save