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.
This commit is contained in:
charlotte ✨ 2021-07-11 07:35:51 +01:00
parent 7d0593515f
commit 7f045fc9e9
9 changed files with 1198 additions and 186 deletions

1014
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -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]

View File

@ -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 =

View File

@ -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};
use std::{net::SocketAddr, sync::mpsc, thread};
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()
}
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");
}

View File

@ -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 Normal file
View File

@ -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))
}

View File

@ -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>

View File

@ -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 Normal file
View File

@ -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
}
});
}