Skip to content

Commit bfac165

Browse files
committed
implement basic GUI (monolith-gui)
1 parent de56957 commit bfac165

File tree

8 files changed

+1768
-379
lines changed

8 files changed

+1768
-379
lines changed

Cargo.lock

Lines changed: 1428 additions & 351 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ base64 = "0.22.1" # Used for integrity attributes
2828
chrono = "0.4.40" # Used for formatting timestamps
2929
clap = { version = "4.5.32", features = ["derive"], optional = true } # Used for processing CLI arguments
3030
cssparser = "0.34.0" # Used for dealing with CSS
31+
druid = { version = "0.8.3", optional = true } # Used for GUI
3132
encoding_rs = "0.8.35" # Used for parsing and converting document charsets
3233
html5ever = "0.28.0" # Used for all things DOM
3334
markup5ever_rcdom = "=0.4.0-unofficial" # Used for manipulating DOM
@@ -56,6 +57,7 @@ assert_cmd = "2.0.16"
5657
[features]
5758
default = ["cli", "vendored-openssl"]
5859
cli = ["clap", "tempfile"] # Build a CLI tool that includes main() function
60+
gui = ["druid", "tempfile"] # Build a GUI executable that includes main() function
5961
vendored-openssl = ["openssl/vendored"] # Compile and statically link a copy of OpenSSL
6062

6163
[lib]
@@ -66,3 +68,8 @@ path = "src/lib.rs"
6668
name = "monolith"
6769
path = "src/main.rs"
6870
required-features = ["cli"]
71+
72+
[[bin]]
73+
name = "monolith-gui"
74+
path = "src/gui.rs"
75+
required-features = ["gui"]

Makefile

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
# Makefile for monolith
22

3-
all: build
3+
all: build build_gui
44
.PHONY: all
55

66
build:
77
@cargo build --locked
88
.PHONY: build
99

10+
build_gui:
11+
@cargo build --locked --bin monolith-gui --features="gui"
12+
.PHONY: build_gui
13+
1014
clean:
1115
@cargo clean
1216
.PHONY: clean

src/core.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use std::io::{self, Write};
66
use std::path::{Path, PathBuf};
77
use std::time::Duration;
88

9+
use chrono::{SecondsFormat, Utc};
910
use encoding_rs::Encoding;
1011
use markup5ever_rcdom::RcDom;
1112
use reqwest::blocking::Client;
@@ -522,6 +523,35 @@ pub fn domain_is_within_domain(domain: &str, domain_to_match_against: &str) -> b
522523
ok
523524
}
524525

526+
pub fn format_output_path(destination: &str, document_title: &str) -> String {
527+
let datetime: &str = &Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
528+
529+
destination
530+
.replace("%timestamp%", &datetime.replace(':', "_"))
531+
.replace(
532+
"%title%",
533+
document_title
534+
.to_string()
535+
.replace(['/', '\\'], "_")
536+
.replace('<', "[")
537+
.replace('>', "]")
538+
.replace(':', " - ")
539+
.replace('\"', "")
540+
.replace('|', "-")
541+
.replace('?', "")
542+
.trim_start_matches('.'),
543+
)
544+
.to_string()
545+
.replace('<', "[")
546+
.replace('>', "]")
547+
.replace(':', " - ")
548+
.replace('\"', "")
549+
.replace('|', "-")
550+
.replace('?', "")
551+
.trim_start_matches('.')
552+
.to_string()
553+
}
554+
525555
pub fn is_plaintext_media_type(media_type: &str) -> bool {
526556
media_type.to_lowercase().as_str().starts_with("text/")
527557
|| PLAINTEXT_MEDIA_TYPES.contains(&media_type.to_lowercase().as_str())

src/gui.rs

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
use std::fs;
2+
use std::io::Write;
3+
use std::thread;
4+
5+
use druid::commands;
6+
use druid::widget::{Button, Checkbox, Flex, Label, TextBox};
7+
use druid::{
8+
AppDelegate, AppLauncher, Command, DelegateCtx, Env, FileDialogOptions, Handled,
9+
LocalizedString, PlatformError, Target, Widget, WidgetExt, WindowDesc,
10+
};
11+
use druid::{Data, Lens};
12+
use tempfile::{Builder, NamedTempFile};
13+
14+
use monolith::cache::Cache;
15+
use monolith::core::{create_monolithic_document, format_output_path, MonolithError, Options};
16+
17+
const CACHE_ASSET_FILE_SIZE_THRESHOLD: usize = 1024 * 10; // Minimum file size for on-disk caching (in bytes)
18+
19+
struct Delegate;
20+
21+
#[derive(Clone, Data, Lens)]
22+
struct AppState {
23+
busy: bool,
24+
isolate: bool,
25+
keep_fonts: bool,
26+
keep_frames: bool,
27+
keep_images: bool,
28+
keep_scripts: bool,
29+
keep_styles: bool,
30+
target: String,
31+
output_path: String,
32+
}
33+
34+
const MONOLITH_GUI_WRITE_OUTPUT: druid::Selector<(Vec<u8>, Option<String>)> =
35+
druid::Selector::new("monolith-gui.write-output");
36+
const MONOLITH_GUI_ERROR: druid::Selector<MonolithError> =
37+
druid::Selector::new("monolith-gui.error");
38+
39+
fn main() -> Result<(), PlatformError> {
40+
let mut program_name: String = env!("CARGO_PKG_NAME").to_string();
41+
if let Some(l) = program_name.get_mut(0..1) {
42+
l.make_ascii_uppercase();
43+
}
44+
let main_window = WindowDesc::new(ui_builder()).title(program_name);
45+
let state = AppState {
46+
busy: false,
47+
isolate: false,
48+
keep_fonts: true,
49+
keep_frames: true,
50+
keep_images: true,
51+
keep_scripts: true,
52+
keep_styles: true,
53+
target: "".to_string(),
54+
output_path: "".to_string(),
55+
};
56+
57+
AppLauncher::with_window(main_window)
58+
.delegate(Delegate)
59+
.launch(state)
60+
}
61+
62+
fn ui_builder() -> impl Widget<AppState> {
63+
let target_input = TextBox::new()
64+
.with_placeholder("URL (http:, https:, file:, data:) or local filesystem path")
65+
.lens(AppState::target)
66+
.disabled_if(|state: &AppState, _env| state.busy);
67+
let text = LocalizedString::new("hello-counter").with_arg("count", |state: &AppState, _env| {
68+
state.output_path.clone().into()
69+
});
70+
let label = Label::new(text).center();
71+
let output_path_button = Button::new(LocalizedString::new("browse"))
72+
.on_click(|ctx, _, _| {
73+
ctx.submit_command(commands::SHOW_SAVE_PANEL.with(
74+
FileDialogOptions::new().default_name("%title% - %timestamp%.html"), // .lens(AppState::output_path)
75+
))
76+
})
77+
.disabled_if(|state: &AppState, _env| state.busy);
78+
let fonts_checkbox = Checkbox::new("Fonts")
79+
.lens(AppState::keep_fonts)
80+
.disabled_if(|state: &AppState, _env| state.busy)
81+
.padding(5.0);
82+
let frames_checkbox = Checkbox::new("Frames")
83+
.lens(AppState::keep_frames)
84+
.disabled_if(|state: &AppState, _env| state.busy)
85+
.padding(5.0);
86+
let images_checkbox = Checkbox::new("Images")
87+
.lens(AppState::keep_images)
88+
.disabled_if(|state: &AppState, _env| state.busy)
89+
.padding(5.0);
90+
let styles_checkbox = Checkbox::new("Styles")
91+
.lens(AppState::keep_styles)
92+
.disabled_if(|state: &AppState, _env| state.busy)
93+
.padding(5.0);
94+
let scripts_checkbox = Checkbox::new("Scripts")
95+
.lens(AppState::keep_scripts)
96+
.disabled_if(|state: &AppState, _env| state.busy)
97+
.padding(5.0);
98+
let isolate_checkbox = Checkbox::new("Isolate")
99+
.lens(AppState::isolate)
100+
.disabled_if(|state: &AppState, _env| state.busy)
101+
.padding(5.0);
102+
let button = Button::new(LocalizedString::new("start"))
103+
.on_click(|ctx, state: &mut AppState, _env| {
104+
if state.busy {
105+
return;
106+
}
107+
108+
let mut options: Options = Options::default();
109+
options.ignore_errors = true;
110+
options.insecure = true;
111+
options.silent = true;
112+
options.no_frames = !state.keep_frames;
113+
options.no_fonts = !state.keep_fonts;
114+
options.no_images = !state.keep_images;
115+
options.no_css = !state.keep_styles;
116+
options.no_js = !state.keep_scripts;
117+
options.isolate = state.isolate;
118+
119+
let handle = ctx.get_external_handle();
120+
let thread_state = state.clone();
121+
122+
state.busy = true;
123+
124+
// Set up cache (attempt to create temporary file)
125+
let temp_cache_file: Option<NamedTempFile> =
126+
match Builder::new().prefix("monolith-").tempfile() {
127+
Ok(tempfile) => Some(tempfile),
128+
Err(_) => None,
129+
};
130+
let mut cache = Some(Cache::new(
131+
CACHE_ASSET_FILE_SIZE_THRESHOLD,
132+
if temp_cache_file.is_some() {
133+
Some(
134+
temp_cache_file
135+
.as_ref()
136+
.unwrap()
137+
.path()
138+
.display()
139+
.to_string(),
140+
)
141+
} else {
142+
None
143+
},
144+
));
145+
146+
thread::spawn(move || {
147+
match create_monolithic_document(thread_state.target, &mut options, &mut cache) {
148+
Ok(result) => {
149+
handle
150+
.submit_command(MONOLITH_GUI_WRITE_OUTPUT, result, Target::Auto)
151+
.unwrap();
152+
153+
cache.unwrap().destroy_database_file();
154+
}
155+
Err(error) => {
156+
handle
157+
.submit_command(MONOLITH_GUI_ERROR, error, Target::Auto)
158+
.unwrap();
159+
160+
cache.unwrap().destroy_database_file();
161+
}
162+
}
163+
});
164+
})
165+
.disabled_if(|state: &AppState, _env| {
166+
state.busy || state.target.is_empty() || state.output_path.is_empty()
167+
});
168+
169+
Flex::column()
170+
.with_child(target_input)
171+
.with_child(label)
172+
.with_child(output_path_button)
173+
.with_child(fonts_checkbox)
174+
.with_child(frames_checkbox)
175+
.with_child(images_checkbox)
176+
.with_child(scripts_checkbox)
177+
.with_child(styles_checkbox)
178+
.with_child(isolate_checkbox)
179+
.with_child(button)
180+
}
181+
182+
impl AppDelegate<AppState> for Delegate {
183+
fn command(
184+
&mut self,
185+
_ctx: &mut DelegateCtx,
186+
_target: Target,
187+
cmd: &Command,
188+
state: &mut AppState,
189+
_env: &Env,
190+
) -> Handled {
191+
if let Some(result) = cmd.get(MONOLITH_GUI_WRITE_OUTPUT) {
192+
let (html, title) = result;
193+
194+
if !state.output_path.is_empty() {
195+
match fs::File::create(format_output_path(
196+
&state.output_path,
197+
&title.clone().unwrap_or_default(),
198+
)) {
199+
Ok(mut file) => {
200+
let _ = file.write(&html);
201+
}
202+
Err(_) => {
203+
eprintln!("Error: could not write output");
204+
}
205+
}
206+
} else {
207+
eprintln!("Error: no output specified");
208+
}
209+
210+
state.busy = false;
211+
return Handled::Yes;
212+
}
213+
214+
if let Some(_error) = cmd.get(MONOLITH_GUI_ERROR) {
215+
state.busy = false;
216+
return Handled::Yes;
217+
}
218+
219+
if let Some(_file_info) = cmd.get(commands::OPEN_FILE) {
220+
return Handled::Yes;
221+
}
222+
223+
if let Some(file_info) = cmd.get(commands::SAVE_FILE_AS) {
224+
state.output_path = file_info.path().display().to_string();
225+
226+
return Handled::Yes;
227+
}
228+
229+
Handled::No
230+
}
231+
}

0 commit comments

Comments
 (0)