Skip to content
Merged

GUI #456

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,779 changes: 1,428 additions & 351 deletions Cargo.lock

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ base64 = "0.22.1" # Used for integrity attributes
chrono = "0.4.40" # Used for formatting timestamps
clap = { version = "4.5.32", features = ["derive"], optional = true } # Used for processing CLI arguments
cssparser = "0.34.0" # Used for dealing with CSS
druid = { version = "0.8.3", optional = true } # Used for GUI
encoding_rs = "0.8.35" # Used for parsing and converting document charsets
html5ever = "0.28.0" # Used for all things DOM
markup5ever_rcdom = "=0.4.0-unofficial" # Used for manipulating DOM
Expand Down Expand Up @@ -56,6 +57,7 @@ assert_cmd = "2.0.16"
[features]
default = ["cli", "vendored-openssl"]
cli = ["clap", "tempfile"] # Build a CLI tool that includes main() function
gui = ["druid", "tempfile"] # Build a GUI executable that includes main() function
vendored-openssl = ["openssl/vendored"] # Compile and statically link a copy of OpenSSL

[lib]
Expand All @@ -66,3 +68,8 @@ path = "src/lib.rs"
name = "monolith"
path = "src/main.rs"
required-features = ["cli"]

[[bin]]
name = "monolith-gui"
path = "src/gui.rs"
required-features = ["gui"]
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
# Makefile for monolith

all: build
all: build build_gui
.PHONY: all

build:
@cargo build --locked
.PHONY: build

build_gui:
@cargo build --locked --bin monolith-gui --features="gui"
.PHONY: build_gui

clean:
@cargo clean
.PHONY: clean
Expand Down
30 changes: 30 additions & 0 deletions src/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::time::Duration;

use chrono::{SecondsFormat, Utc};
use encoding_rs::Encoding;
use markup5ever_rcdom::RcDom;
use reqwest::blocking::Client;
Expand Down Expand Up @@ -522,6 +523,35 @@ pub fn domain_is_within_domain(domain: &str, domain_to_match_against: &str) -> b
ok
}

pub fn format_output_path(destination: &str, document_title: &str) -> String {
let datetime: &str = &Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);

destination
.replace("%timestamp%", &datetime.replace(':', "_"))
.replace(
"%title%",
document_title
.to_string()
.replace(['/', '\\'], "_")
.replace('<', "[")
.replace('>', "]")
.replace(':', " - ")
.replace('\"', "")
.replace('|', "-")
.replace('?', "")
.trim_start_matches('.'),
)
.to_string()
.replace('<', "[")
.replace('>', "]")
.replace(':', " - ")
.replace('\"', "")
.replace('|', "-")
.replace('?', "")
.trim_start_matches('.')
.to_string()
}

pub fn is_plaintext_media_type(media_type: &str) -> bool {
media_type.to_lowercase().as_str().starts_with("text/")
|| PLAINTEXT_MEDIA_TYPES.contains(&media_type.to_lowercase().as_str())
Expand Down
231 changes: 231 additions & 0 deletions src/gui.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
use std::fs;
use std::io::Write;
use std::thread;

use druid::commands;
use druid::widget::{Button, Checkbox, Flex, Label, TextBox};
use druid::{
AppDelegate, AppLauncher, Command, DelegateCtx, Env, FileDialogOptions, Handled,
LocalizedString, PlatformError, Target, Widget, WidgetExt, WindowDesc,
};
use druid::{Data, Lens};
use tempfile::{Builder, NamedTempFile};

use monolith::cache::Cache;
use monolith::core::{create_monolithic_document, format_output_path, MonolithError, Options};

const CACHE_ASSET_FILE_SIZE_THRESHOLD: usize = 1024 * 10; // Minimum file size for on-disk caching (in bytes)

struct Delegate;

#[derive(Clone, Data, Lens)]
struct AppState {
busy: bool,
isolate: bool,
keep_fonts: bool,
keep_frames: bool,
keep_images: bool,
keep_scripts: bool,
keep_styles: bool,
target: String,
output_path: String,
}

const MONOLITH_GUI_WRITE_OUTPUT: druid::Selector<(Vec<u8>, Option<String>)> =
druid::Selector::new("monolith-gui.write-output");
const MONOLITH_GUI_ERROR: druid::Selector<MonolithError> =
druid::Selector::new("monolith-gui.error");

fn main() -> Result<(), PlatformError> {
let mut program_name: String = env!("CARGO_PKG_NAME").to_string();
if let Some(l) = program_name.get_mut(0..1) {
l.make_ascii_uppercase();
}
let main_window = WindowDesc::new(ui_builder()).title(program_name);
let state = AppState {
busy: false,
isolate: false,
keep_fonts: true,
keep_frames: true,
keep_images: true,
keep_scripts: true,
keep_styles: true,
target: "".to_string(),
output_path: "".to_string(),
};

AppLauncher::with_window(main_window)
.delegate(Delegate)
.launch(state)
}

fn ui_builder() -> impl Widget<AppState> {
let target_input = TextBox::new()
.with_placeholder("URL (http:, https:, file:, data:) or local filesystem path")
.lens(AppState::target)
.disabled_if(|state: &AppState, _env| state.busy);
let text = LocalizedString::new("hello-counter").with_arg("count", |state: &AppState, _env| {
state.output_path.clone().into()
});
let label = Label::new(text).center();
let output_path_button = Button::new(LocalizedString::new("browse"))
.on_click(|ctx, _, _| {
ctx.submit_command(commands::SHOW_SAVE_PANEL.with(
FileDialogOptions::new().default_name("%title% - %timestamp%.html"), // .lens(AppState::output_path)
))
})
.disabled_if(|state: &AppState, _env| state.busy);
let fonts_checkbox = Checkbox::new("Fonts")
.lens(AppState::keep_fonts)
.disabled_if(|state: &AppState, _env| state.busy)
.padding(5.0);
let frames_checkbox = Checkbox::new("Frames")
.lens(AppState::keep_frames)
.disabled_if(|state: &AppState, _env| state.busy)
.padding(5.0);
let images_checkbox = Checkbox::new("Images")
.lens(AppState::keep_images)
.disabled_if(|state: &AppState, _env| state.busy)
.padding(5.0);
let styles_checkbox = Checkbox::new("Styles")
.lens(AppState::keep_styles)
.disabled_if(|state: &AppState, _env| state.busy)
.padding(5.0);
let scripts_checkbox = Checkbox::new("Scripts")
.lens(AppState::keep_scripts)
.disabled_if(|state: &AppState, _env| state.busy)
.padding(5.0);
let isolate_checkbox = Checkbox::new("Isolate")
.lens(AppState::isolate)
.disabled_if(|state: &AppState, _env| state.busy)
.padding(5.0);
let button = Button::new(LocalizedString::new("start"))
.on_click(|ctx, state: &mut AppState, _env| {
if state.busy {
return;
}

let mut options: Options = Options::default();
options.ignore_errors = true;
options.insecure = true;
options.silent = true;
options.no_frames = !state.keep_frames;
options.no_fonts = !state.keep_fonts;
options.no_images = !state.keep_images;
options.no_css = !state.keep_styles;
options.no_js = !state.keep_scripts;
options.isolate = state.isolate;

let handle = ctx.get_external_handle();
let thread_state = state.clone();

state.busy = true;

// Set up cache (attempt to create temporary file)
let temp_cache_file: Option<NamedTempFile> =
match Builder::new().prefix("monolith-").tempfile() {
Ok(tempfile) => Some(tempfile),
Err(_) => None,
};
let mut cache = Some(Cache::new(
CACHE_ASSET_FILE_SIZE_THRESHOLD,
if temp_cache_file.is_some() {
Some(
temp_cache_file
.as_ref()
.unwrap()
.path()
.display()
.to_string(),
)
} else {
None
},
));

thread::spawn(move || {
match create_monolithic_document(thread_state.target, &mut options, &mut cache) {
Ok(result) => {
handle
.submit_command(MONOLITH_GUI_WRITE_OUTPUT, result, Target::Auto)
.unwrap();

cache.unwrap().destroy_database_file();
}
Err(error) => {
handle
.submit_command(MONOLITH_GUI_ERROR, error, Target::Auto)
.unwrap();

cache.unwrap().destroy_database_file();
}
}
});
})
.disabled_if(|state: &AppState, _env| {
state.busy || state.target.is_empty() || state.output_path.is_empty()
});

Flex::column()
.with_child(target_input)
.with_child(label)
.with_child(output_path_button)
.with_child(fonts_checkbox)
.with_child(frames_checkbox)
.with_child(images_checkbox)
.with_child(scripts_checkbox)
.with_child(styles_checkbox)
.with_child(isolate_checkbox)
.with_child(button)
}

impl AppDelegate<AppState> for Delegate {
fn command(
&mut self,
_ctx: &mut DelegateCtx,
_target: Target,
cmd: &Command,
state: &mut AppState,
_env: &Env,
) -> Handled {
if let Some(result) = cmd.get(MONOLITH_GUI_WRITE_OUTPUT) {
let (html, title) = result;

if !state.output_path.is_empty() {
match fs::File::create(format_output_path(
&state.output_path,
&title.clone().unwrap_or_default(),
)) {
Ok(mut file) => {
let _ = file.write(&html);
}
Err(_) => {
eprintln!("Error: could not write output");
}
}
} else {
eprintln!("Error: no output specified");
}

state.busy = false;
return Handled::Yes;
}

if let Some(_error) = cmd.get(MONOLITH_GUI_ERROR) {
state.busy = false;
return Handled::Yes;
}

if let Some(_file_info) = cmd.get(commands::OPEN_FILE) {
return Handled::Yes;
}

if let Some(file_info) = cmd.get(commands::SAVE_FILE_AS) {
state.output_path = file_info.path().display().to_string();

return Handled::Yes;
}

Handled::No
}
}
Loading