diff --git a/Cargo.lock b/Cargo.lock
index 6c02af47..0162c8c0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1271,6 +1271,18 @@ dependencies = [
"zune-inflate",
]
+[[package]]
+name = "fallible-iterator"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
+
+[[package]]
+name = "fallible-streaming-iterator"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
+
[[package]]
name = "fancy-regex"
version = "0.11.0"
@@ -1694,6 +1706,15 @@ dependencies = [
"foldhash",
]
+[[package]]
+name = "hashlink"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
+dependencies = [
+ "hashbrown 0.14.5",
+]
+
[[package]]
name = "hassle-rs"
version = "0.10.0"
@@ -1771,6 +1792,39 @@ dependencies = [
"syn 2.0.85",
]
+[[package]]
+name = "http"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258"
+dependencies = [
+ "bytes",
+ "fnv",
+ "itoa",
+]
+
+[[package]]
+name = "http-cache-semantics"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92baf25cf0b8c9246baecf3a444546360a97b569168fdf92563ee6a47829920c"
+dependencies = [
+ "http",
+ "http-serde",
+ "serde",
+ "time",
+]
+
+[[package]]
+name = "http-serde"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1133cafcce27ea69d35e56b3a8772e265633e04de73c5f4e1afdffc1d19b5419"
+dependencies = [
+ "http",
+ "serde",
+]
+
[[package]]
name = "httpdate"
version = "1.0.3"
@@ -1883,6 +1937,7 @@ dependencies = [
"anstyle",
"anyhow",
"base64 0.22.1",
+ "bincode",
"bytemuck",
"clap",
"comrak",
@@ -1896,6 +1951,8 @@ dependencies = [
"glyphon",
"html-escape",
"html5ever",
+ "http",
+ "http-cache-semantics",
"human-panic",
"image",
"indexmap 2.6.0",
@@ -1914,6 +1971,7 @@ dependencies = [
"pretty_assertions",
"raw-window-handle",
"resvg",
+ "rusqlite",
"serde",
"serde_yaml",
"smart-debug",
@@ -1928,6 +1986,7 @@ dependencies = [
"two-face",
"twox-hash",
"ureq",
+ "url",
"wgpu",
"winit",
]
@@ -2188,6 +2247,17 @@ dependencies = [
"redox_syscall 0.5.7",
]
+[[package]]
+name = "libsqlite3-sys"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f"
+dependencies = [
+ "cc",
+ "pkg-config",
+ "vcpkg",
+]
+
[[package]]
name = "linked-hash-map"
version = "0.5.6"
@@ -3549,6 +3619,20 @@ version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
+[[package]]
+name = "rusqlite"
+version = "0.31.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae"
+dependencies = [
+ "bitflags 2.6.0",
+ "fallible-iterator",
+ "fallible-streaming-iterator",
+ "hashlink",
+ "libsqlite3-sys",
+ "smallvec",
+]
+
[[package]]
name = "rust-ini"
version = "0.18.0"
@@ -4549,6 +4633,7 @@ checksum = "b74fc6b57825be3373f7054754755f03ac3a8f5d70015ccad699ba2029956f4a"
dependencies = [
"base64 0.22.1",
"flate2",
+ "http",
"log",
"once_cell",
"rustls",
@@ -4639,6 +4724,12 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d"
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
[[package]]
name = "vec_map"
version = "0.8.2"
diff --git a/Cargo.toml b/Cargo.toml
index 444dad29..8504232a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -37,6 +37,8 @@ anstream = "0.6.17"
anstyle = "1.0.9"
# Easier error handling
anyhow = "1.0.91"
+# (De)serializing cache data
+bincode = "1.3.3"
# System preferred color scheme detection
dark-light = "1.1.1"
# System specific directories
@@ -51,6 +53,8 @@ glyphon = "0.3"
html-escape = "0.2.13"
# Parsing the HTML document that the markdown+html was converted into
html5ever = "0.27.0"
+http = "1.1.0"
+http-cache-semantics = "2.1.0"
# Provides some extra helpers that we use for our custom panic hook
human-panic = "2.0.0"
# Generic image decoding
@@ -74,7 +78,9 @@ pollster = "0.4.0"
raw-window-handle = "0.5.2"
# SVG rendering
resvg = "0.39.0"
-# Parses the optional YAML frontmatter (replace with just a yaml parser)
+# Sqlite DB for our image cache
+rusqlite = { version = "0.31.0", features = ["bundled"] }
+# Parses the optional YAML frontmatter (TODO: replace with just a yaml parser)
serde_yaml = "0.9.34"
# Easy `Debug` formatting changes used to keep snapshot tests more succinct
smart-debug = "0.0.3"
@@ -92,7 +98,8 @@ two-face = "0.4.0"
# More text hashing...
twox-hash = "1.6.3"
# HTTP client for requesting images from urls
-ureq = "2.10.1"
+ureq = { version = "2.10.1", features = ["http-crate"] }
+url = "2.5.0"
# Cross platform GPU magic sauce
wgpu = "0.16"
@@ -195,13 +202,18 @@ lto = true
# Selectively bump up opt level for some dependencies to improve dev build perf
[profile.dev.package]
-ttf-parser.opt-level = 2
-rustybuzz.opt-level = 2
+backtrace.opt-level = 2
cosmic-text.opt-level = 2
-png.opt-level = 2
fontdb.opt-level = 2
+gif.opt-level = 2
+image.opt-level = 2
+image-webp.opt-level = 2
+lz4_flex.opt-level = 2
miniz_oxide.opt-level = 2
-backtrace.opt-level = 2
+png.opt-level = 2
+rustybuzz.opt-level = 2
+tiny-skia.opt-level = 2
+ttf-parser.opt-level = 2
[lints.rust.unexpected_cfgs]
level = "warn"
diff --git a/assets/test_data/cargo_public_api.webp b/assets/test_data/cargo_public_api.webp
index 23c0c10f..820ae73a 100644
Binary files a/assets/test_data/cargo_public_api.webp and b/assets/test_data/cargo_public_api.webp differ
diff --git a/src/file_watcher/mod.rs b/src/file_watcher/mod.rs
index 053d8f64..b5240204 100644
--- a/src/file_watcher/mod.rs
+++ b/src/file_watcher/mod.rs
@@ -3,6 +3,7 @@ mod tests;
use std::path::{Path, PathBuf};
use std::sync::mpsc;
+use std::thread;
use std::time::Duration;
use crate::InlyneEvent;
@@ -106,9 +107,12 @@ impl Watcher {
let notify_watcher =
new_debouncer(Duration::from_millis(10), None, MsgHandler(msg_tx)).unwrap();
- std::thread::spawn(move || {
- endlessly_handle_messages(notify_watcher, msg_rx, reload_callback, file_path);
- });
+ thread::Builder::new()
+ .name("file-watcher".into())
+ .spawn(move || {
+ endlessly_handle_messages(notify_watcher, msg_rx, reload_callback, file_path);
+ })
+ .expect("failed to spawn thread");
watcher
}
diff --git a/src/file_watcher/tests.rs b/src/file_watcher/tests.rs
index e7740e8d..14247963 100644
--- a/src/file_watcher/tests.rs
+++ b/src/file_watcher/tests.rs
@@ -3,6 +3,8 @@ use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::time::Duration;
+use crate::test_utils::temp;
+
use super::{Callback, Watcher};
use tempfile::TempDir;
@@ -62,11 +64,7 @@ impl Delays {
fn init_test_env() -> (TestEnv, TempDir) {
// Create our dummy test env
- let temp_dir = tempfile::Builder::new()
- .prefix("inlyne-tests-")
- .tempdir()
- .unwrap();
- let base = temp_dir.path();
+ let (temp_dir, base) = temp::dir();
let main_file = base.join("main.md");
let rel_file = base.join("rel.md");
fs::write(&main_file, "# Main\n\n[rel](./rel.md)").unwrap();
diff --git a/src/history.rs b/src/history.rs
index cd7d9a4a..8820d170 100644
--- a/src/history.rs
+++ b/src/history.rs
@@ -59,14 +59,11 @@ mod tests {
use std::fs;
use super::*;
+ use crate::test_utils::temp;
#[test]
fn sanity() {
- let temp_dir = tempfile::Builder::new()
- .prefix("inlyne-tests-")
- .tempdir()
- .unwrap();
- let temp_path = temp_dir.path().canonicalize().unwrap();
+ let (_temp_dir, temp_path) = temp::dir();
let root = temp_path.join("a");
let fork1 = temp_path.join("b");
diff --git a/src/image/cache/global/db.rs b/src/image/cache/global/db.rs
new file mode 100644
index 00000000..473036d0
--- /dev/null
+++ b/src/image/cache/global/db.rs
@@ -0,0 +1,145 @@
+use std::{
+ fs,
+ path::{Path, PathBuf},
+ time::SystemTime,
+};
+
+use crate::{
+ image::cache::{global::RemoteMeta, RemoteKey, StableImage},
+ utils,
+};
+
+use anyhow::Context;
+use http_cache_semantics::CachePolicy;
+use rusqlite::{types::FromSqlError, Connection, OptionalExtension};
+
+use super::wrappers::{CachePolicyBytes, StableImageBytes, SystemTimeSecs};
+
+/// The current version for our database file
+///
+/// We're a cache so we don't really have to keep worrying about preserving data permanently. If we
+/// want to make some really nasty changes without dealing with migrations then we can bump this
+/// version and rotate to a totally new file entirely. Old versions are handled durring garbage
+/// collection
+const VERSION: u32 = 0;
+
+fn file_name() -> String {
+ format!("image-cache-v{VERSION}.db3")
+}
+
+const SCHEMA: &str = include_str!("db_schema.sql");
+
+// TODO: create a connection pool so that we can actually re-use connections (and their cache)
+// instead of having to create a new one for each worker or serialize all cache interactions
+pub struct Db(Connection);
+
+impl Db {
+ pub fn default_path() -> anyhow::Result {
+ let cache_dir = utils::inlyne_cache_dir().context("Failed to locate cache dir")?;
+ let db_path = cache_dir.join(file_name());
+ Ok(db_path)
+ }
+
+ pub fn open_or_create(path: &Path) -> anyhow::Result {
+ let db_dir = path.parent().with_context(|| {
+ format!(
+ "Unable to locate database directory from: {}",
+ path.display()
+ )
+ })?;
+ fs::create_dir_all(db_dir)
+ .with_context(|| format!("Failed creating db directory at: {}", db_dir.display()))?;
+ let conn = Connection::open(path)?;
+ Self::create_schema(&conn)?;
+ Ok(Self(conn))
+ }
+
+ fn create_schema(conn: &Connection) -> anyhow::Result<()> {
+ conn.execute(SCHEMA, ())?;
+ Ok(())
+ }
+
+ pub fn get_meta(&self, remote: &RemoteKey) -> rusqlite::Result
-"#,
+
+
+
+
+
+"#,
);
for color_scheme in [None, Some(ResolvedTheme::Dark), Some(ResolvedTheme::Light)] {
@@ -972,11 +977,7 @@ fn custom_user_agent() {
let maybe_ua = req.headers().iter().find_map(|Header { field, value }| {
field.equiv("user-agent").then(|| value.as_str().to_owned())
});
- let _ = state
- .send
- .as_ref()
- .unwrap()
- .send(server::FromServer::UserAgent(maybe_ua));
+ let _ = state.send_msg(server::FromServer::UserAgent(maybe_ua));
let sample_body = Sample::Png(SamplePng::Bun).pre_decode();
Response::from_data(sample_body).boxed()
});
diff --git a/src/main.rs b/src/main.rs
index d3f17204..17309ea4 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -57,7 +57,7 @@ use tracing_subscriber::prelude::*;
use tracing_subscriber::util::SubscriberInitExt;
use utils::{ImageCache, Point, Rect, Size};
-use crate::opts::{Commands, ConfigCmd, MetricsExporter};
+use crate::opts::{CacheCmd, Commands, ConfigCmd, MetricsExporter};
use crate::selection::Selection;
use anyhow::Context;
use clap::Parser;
@@ -814,6 +814,17 @@ fn main() -> anyhow::Result<()> {
edit::edit_file(config_path)?;
}
+ Commands::Cache(CacheCmd::Gc) => image::cache::run_global_garbage_collector()?,
+ Commands::Cache(CacheCmd::Stats) => {
+ let image::cache::GlobalStats { path, inner } = image::cache::GlobalStats::detect()?;
+ match inner {
+ None => {
+ // TODO: colors
+ println!("Path: {} (not found)", path.display());
+ }
+ Some(image::cache::GlobalStatsInner { size }) => todo!(),
+ }
+ }
}
Ok(())
diff --git a/src/opts/cli.rs b/src/opts/cli.rs
index d34c9cc8..bf312d51 100644
--- a/src/opts/cli.rs
+++ b/src/opts/cli.rs
@@ -115,6 +115,8 @@ pub enum Commands {
View(View),
#[command(subcommand)]
Config(ConfigCmd),
+ #[command(subcommand)]
+ Cache(CacheCmd),
}
/// View a markdown file with inlyne
@@ -156,3 +158,12 @@ pub enum ConfigCmd {
/// Opens the configuration file in the default text editor
Open,
}
+
+/// Actions dealing with the image cache
+#[derive(Subcommand, PartialEq, Clone, Debug)]
+pub enum CacheCmd {
+ /// Run the garbage collector on the image cache file
+ Gc,
+ /// Display different info about your cache
+ Stats,
+}
diff --git a/src/opts/mod.rs b/src/opts/mod.rs
index d377a787..7e2726eb 100644
--- a/src/opts/mod.rs
+++ b/src/opts/mod.rs
@@ -9,7 +9,7 @@ use std::{
};
use crate::color;
-pub use cli::{Cli, Commands, ConfigCmd, Position, Size, ThemeType, View};
+pub use cli::{CacheCmd, Cli, Commands, ConfigCmd, Position, Size, ThemeType, View};
pub use config::{Config, DebugSection, FontOptions, KeybindingsSection, MetricsExporter};
use crate::history::History;
diff --git a/src/opts/tests/parse.rs b/src/opts/tests/parse.rs
index c9658d67..ae54ec89 100644
--- a/src/opts/tests/parse.rs
+++ b/src/opts/tests/parse.rs
@@ -9,7 +9,7 @@ use crate::color::{SyntaxTheme, Theme, ThemeDefaults};
use crate::history::History;
use crate::opts::config::{self, FontOptions, LinesToScroll};
use crate::opts::{Cli, Opts, Position, ResolvedTheme, Size, ThemeType};
-use crate::test_utils::log;
+use crate::test_utils::{log, temp};
fn gen_args(args: Vec<&str>) -> Vec {
std::iter::once("inlyne")
@@ -19,12 +19,8 @@ fn gen_args(args: Vec<&str>) -> Vec {
}
fn temp_md_file() -> (NamedTempFile, String) {
- let temp_file = tempfile::Builder::new()
- .prefix("inlyne-tests-")
- .suffix(".md")
- .tempfile()
- .unwrap();
- let path = temp_file.path().to_str().unwrap().to_owned();
+ let (temp_file, path) = temp::file_with_suffix(".md");
+ let path = path.to_str().unwrap().to_owned();
(temp_file, path)
}
diff --git a/src/test_utils/image.rs b/src/test_utils/image.rs
index 54e0956b..291e979d 100644
--- a/src/test_utils/image.rs
+++ b/src/test_utils/image.rs
@@ -1,4 +1,7 @@
-use crate::image::ImageData;
+use crate::image::{
+ cache::{StableImage, SvgContext},
+ ImageData,
+};
#[derive(Clone, Copy)]
pub enum Sample {
@@ -86,60 +89,65 @@ pub enum SampleWebp {
}
impl Sample {
- pub fn pre_decode(self) -> Vec {
+ pub fn pre_decode(self) -> &'static [u8] {
match self {
Self::Jpg(jpg) => match jpg {
- SampleJpg::Rgb8 => include_bytes!("../../assets/test_data/rgb8.jpg").as_slice(),
- SampleJpg::Rgb8a => include_bytes!("../../assets/test_data/rgba8.jpg").as_slice(),
+ SampleJpg::Rgb8 => include_bytes!("../../assets/test_data/rgb8.jpg"),
+ SampleJpg::Rgb8a => include_bytes!("../../assets/test_data/rgba8.jpg"),
},
Self::Gif(gif) => match gif {
SampleGif::AtuinDemo => {
- include_bytes!("../../assets/test_data/atuin_demo.gif").as_slice()
+ include_bytes!("../../assets/test_data/atuin_demo.gif")
}
- SampleGif::Rgb8 => include_bytes!("../../assets/test_data/rgb8.gif").as_slice(),
- SampleGif::Rgba8 => include_bytes!("../../assets/test_data/rgba8.gif").as_slice(),
+ SampleGif::Rgb8 => include_bytes!("../../assets/test_data/rgb8.gif"),
+ SampleGif::Rgba8 => include_bytes!("../../assets/test_data/rgba8.gif"),
},
Self::Png(png) => match png {
SamplePng::Ariadne => {
- include_bytes!("../../assets/test_data/ariadne_example.png").as_slice()
+ include_bytes!("../../assets/test_data/ariadne_example.png")
}
- SamplePng::Bun => include_bytes!("../../assets/test_data/bun_logo.png").as_slice(),
- SamplePng::Rgb8 => include_bytes!("../../assets/test_data/rgb8.png").as_slice(),
- SamplePng::Rgba8 => include_bytes!("../../assets/test_data/rgba8.png").as_slice(),
+ SamplePng::Bun => include_bytes!("../../assets/test_data/bun_logo.png"),
+ SamplePng::Rgb8 => include_bytes!("../../assets/test_data/rgb8.png"),
+ SamplePng::Rgba8 => include_bytes!("../../assets/test_data/rgba8.png"),
},
Self::Qoi(qoi) => match qoi {
- SampleQoi::Rgb8 => include_bytes!("../../assets/test_data/rgb8.qoi").as_slice(),
- SampleQoi::Rgba8 => include_bytes!("../../assets/test_data/rgba8.qoi").as_slice(),
+ SampleQoi::Rgb8 => include_bytes!("../../assets/test_data/rgb8.qoi"),
+ SampleQoi::Rgba8 => include_bytes!("../../assets/test_data/rgba8.qoi"),
},
Self::Svg(svg) => match svg {
SampleSvg::Corro => include_bytes!("../../assets/test_data/corro.svg"),
SampleSvg::Cargo => {
- include_bytes!("../../assets/test_data/sample_cargo_badge.svg").as_slice()
+ include_bytes!("../../assets/test_data/sample_cargo_badge.svg")
}
SampleSvg::Repology => {
- include_bytes!("../../assets/test_data/sample_repology_badge.svg").as_slice()
+ include_bytes!("../../assets/test_data/sample_repology_badge.svg")
}
},
Self::Webp(SampleWebp::CargoPublicApi) => {
- include_bytes!("../../assets/test_data/cargo_public_api.webp").as_slice()
+ include_bytes!("../../assets/test_data/cargo_public_api.webp")
}
}
- .into()
}
- // TODO: adapt this to work with svg images too
- pub fn post_decode(self) -> ImageData {
- ImageData::load(&self.pre_decode(), true).unwrap()
+ // TODO: replace this with the common image loading function
+ pub fn post_decode(self, svg_ctx: &SvgContext) -> ImageData {
+ if let Self::Svg(_) = self {
+ let text = std::str::from_utf8(self.pre_decode()).unwrap();
+ let image = StableImage::from_svg(text);
+ image.render(svg_ctx).unwrap()
+ } else {
+ ImageData::load(&self.pre_decode(), true).unwrap()
+ }
}
- pub fn content_type(self) -> &'static str {
+ pub fn suffix(self) -> &'static str {
match self {
- Sample::Gif(_) => "image/gif",
- Sample::Jpg(_) => "image/jpeg",
- Sample::Png(_) => "image/png",
- Sample::Qoi(_) => "image/qoi",
- Sample::Svg(_) => "image/svg+xml",
- Sample::Webp(_) => "image/webp",
+ Self::Gif(_) => ".gif",
+ Self::Jpg(_) => ".jpg",
+ Self::Png(_) => ".png",
+ Self::Qoi(_) => ".qoi",
+ Self::Svg(_) => ".svg",
+ Self::Webp(_) => ".webp",
}
}
}
diff --git a/src/test_utils/log.rs b/src/test_utils/log.rs
index 63adf036..94cdf1ca 100644
--- a/src/test_utils/log.rs
+++ b/src/test_utils/log.rs
@@ -1,6 +1,11 @@
+use crate::metrics;
+
use tracing_subscriber::prelude::*;
pub fn init() {
+ let recorder = metrics::LogRecorder::default();
+ let _ = metrics::set_global_recorder(recorder);
+
let filter = tracing_subscriber::filter::Targets::new()
.with_default(tracing_subscriber::filter::LevelFilter::WARN)
.with_target("inlyne", tracing_subscriber::filter::LevelFilter::TRACE);
diff --git a/src/test_utils/mod.rs b/src/test_utils/mod.rs
index a107b15f..28b54dcd 100644
--- a/src/test_utils/mod.rs
+++ b/src/test_utils/mod.rs
@@ -1,3 +1,4 @@
pub mod image;
pub mod log;
pub mod server;
+pub mod temp;
diff --git a/src/test_utils/server.rs b/src/test_utils/server.rs
index cbf87b2d..6fd23b5c 100644
--- a/src/test_utils/server.rs
+++ b/src/test_utils/server.rs
@@ -1,8 +1,20 @@
-use std::{sync::mpsc::Sender, thread};
+use std::{
+ collections::{btree_map, BTreeMap},
+ hash::Hasher,
+ sync::{mpsc::Sender, Arc},
+ thread,
+ time::Duration,
+};
+use super::image::Sample;
+use crate::{debug_impls::DebugBytesPrefix, image::cache::RemoteKey};
+
+use http::{header, HeaderMap, HeaderValue};
use once_cell::sync::Lazy;
use parking_lot::RwLock;
+use smart_debug::SmartDebug;
use tiny_http::{Header, Method, Request, Response, ResponseBox, Server};
+use twox_hash::XxHash64;
type HandlerFn = fn(&State, &Request, &str) -> ResponseBox;
@@ -23,7 +35,11 @@ static META_SERVER: Lazy = Lazy::new(|| {
});
pub fn spawn(state: State, handler_fn: HandlerFn) -> MiniServerHandle {
- let mini_server = MiniServer { handler_fn, state };
+ let state = SharedState::new(state.into());
+ let mini_server = MiniServer {
+ handler_fn,
+ state: state.clone(),
+ };
let mut meta = META_SERVER.slots.write();
let index = meta.len();
meta.push(Some(mini_server));
@@ -31,16 +47,26 @@ pub fn spawn(state: State, handler_fn: HandlerFn) -> MiniServerHandle {
let base_url = META_SERVER.base_url.clone();
let url = format!("{base_url}/{index}");
- MiniServerHandle { url, index }
+ MiniServerHandle { url, index, state }
}
fn spawn_router(server: Server) {
- thread::spawn(move || {
- for req in server.incoming_requests() {
- let resp = try_respond(&req).unwrap_or_else(|| Response::empty(404).boxed());
- let _ = req.respond(resp);
- }
- });
+ thread::Builder::new()
+ .name("test-server-router".into())
+ .spawn(move || {
+ for req in server.incoming_requests() {
+ // Run each request in its own thread to isolate potential panics
+ thread::Builder::new()
+ .name("test-server-handler".into())
+ .spawn(move || {
+ let resp =
+ try_respond(&req).unwrap_or_else(|| Response::empty(404).boxed());
+ let _ = req.respond(resp);
+ })
+ .unwrap();
+ }
+ })
+ .unwrap();
}
fn try_respond(req: &Request) -> Option {
@@ -55,7 +81,10 @@ fn try_respond(req: &Request) -> Option {
let meta = META_SERVER.slots.read();
let MiniServer { state, handler_fn } = meta.get(server_index)?.as_ref()?;
- let resp = (handler_fn)(state, req, subserver_url);
+ let resp = {
+ let state = state.read();
+ (handler_fn)(&*state, req, subserver_url)
+ };
Some(resp)
}
@@ -67,12 +96,50 @@ struct MetaServer {
pub struct MiniServerHandle {
url: String,
index: usize,
+ state: SharedState,
}
impl MiniServerHandle {
pub fn url(&self) -> &str {
&self.url
}
+
+ pub fn mount_image>(&self, file: F) -> RemoteKey {
+ let file = file.into();
+ let mut state = self.state.write();
+ let mut num_files = state.files.len();
+ let new_name = loop {
+ let new_name = format!(
+ "/file_{}{}",
+ num_files,
+ file.mime.to_ext().unwrap_or(".unknown")
+ );
+ if !state.files.contains_key(&new_name) {
+ break new_name;
+ } else {
+ num_files += 1;
+ }
+ };
+
+ let full_url = format!("{}{}", self.url(), new_name);
+ let key = RemoteKey::new_unchecked(full_url);
+ state.files.insert(new_name, file);
+ key
+ }
+
+ pub fn swap_image>(&self, key: &RemoteKey, file: F) -> Option<()> {
+ let file = file.into();
+ let url = key.get();
+ let rel_path = url.strip_prefix(self.url())?;
+ let mut state = self.state.write();
+ match state.files.entry(rel_path.to_owned()) {
+ btree_map::Entry::Vacant(_) => None,
+ btree_map::Entry::Occupied(mut slot) => {
+ slot.insert(file);
+ Some(())
+ }
+ }
+ }
}
impl Drop for MiniServerHandle {
@@ -86,13 +153,15 @@ impl Drop for MiniServerHandle {
struct MiniServer {
handler_fn: HandlerFn,
- state: State,
+ state: SharedState,
}
+type SharedState = Arc>;
+
#[derive(Default)]
pub struct State {
- pub files: Vec,
- pub send: Option>,
+ files: BTreeMap,
+ send: Option>,
}
impl State {
@@ -100,8 +169,8 @@ impl State {
Self::default()
}
- pub fn file(mut self, file: File) -> Self {
- self.files.push(file);
+ pub fn file(mut self, url_path: String, file: File) -> Self {
+ self.files.insert(url_path, file);
self
}
@@ -109,44 +178,263 @@ impl State {
self.send = Some(send);
self
}
+
+ pub fn send_msg(&self, msg: FromServer) {
+ let _ = self.send.as_ref().unwrap().send(msg);
+ }
}
pub enum FromServer {
UserAgent(Option),
}
+// TODO: split out some of this logic into some cache control test server crate? There's a lot of
+// low-level cache control server side stuff that we wind up implementing just to test things
/// Spin up a server, so we can test network requests without external services
-pub fn mock_file_server(files: Vec) -> (MiniServerHandle, String) {
+pub fn mock_file_server(files: Vec<(String, File)>) -> MiniServerHandle {
+ let files = files
+ .into_iter()
+ .map(|(path, file)| (path, file.into()))
+ .collect();
let state = State { files, send: None };
- let server = spawn(state, |state, req, req_url| match req.method() {
- Method::Get => match state.files.iter().find(|file| file.url_path == req_url) {
- Some(file) => {
- let header = Header::from_bytes(b"Content-Type", file.mime.as_bytes()).unwrap();
- Response::from_data(file.bytes.clone())
- .with_header(header)
- .boxed()
+ spawn(state, |state, req, req_url| {
+ if *req.method() != Method::Get {
+ return Response::empty(404).boxed();
+ }
+
+ let Some(file) = state.files.get(req_url) else {
+ return Response::empty(404).boxed();
+ };
+
+ //
+ //
+ // > The server compares the client's `ETag` (sent with `If-None-Match`) with the
+ // > `ETag` for its current version of the resource, and if both values match (that
+ // > is, the resource has not changed), the server sends back a `304 Not Modified`
+ // > status, without a body, which tells the client that the cached version of the
+ // > response is still good to use (fresh).
+ let desired_header_name: tiny_http::HeaderField =
+ http::header::IF_NONE_MATCH.as_str().parse().unwrap();
+ let maybe_client_etag = req.headers().iter().find_map(|header| {
+ (header.field == desired_header_name).then(|| header.value.to_string())
+ });
+ match (file.include_etag, maybe_client_etag.as_deref()) {
+ (true, Some(client_etag)) => {
+ let body_hash = hash(&file.bytes);
+ let server_etag = format!("\"{body_hash:x}\"");
+ if server_etag == client_etag {
+ let header_name = http::header::ETAG.as_str().as_bytes();
+ let header =
+ Header::from_bytes(header_name, server_etag.as_bytes())
+ .unwrap();
+ Response::empty(http::status::StatusCode::NOT_MODIFIED.as_u16())
+ .with_header(header)
+ .boxed()
+ } else {
+ file.to_owned().into()
+ }
}
- None => Response::empty(404).boxed(),
- },
- _ => Response::empty(404).boxed(),
- });
+ _ => file.to_owned().into(),
+ }
+ })
+}
+
+#[derive(Clone, Copy, Debug)]
+pub struct CacheControl {
+ immutable: bool,
+ max_age: Option,
+ no_store: bool,
+ private: bool,
+}
+
+impl CacheControl {
+ // Same as what `derive(Default)` would do, but const
+ pub const fn new() -> Self {
+ Self {
+ immutable: false,
+ max_age: None,
+ no_store: false,
+ private: false,
+ }
+ }
+
+ pub const fn immutable(mut self) -> Self {
+ self.immutable = true;
+ self
+ }
+
+ pub const fn max_age(mut self, age: Duration) -> Self {
+ self.max_age = Some(age);
+ self
+ }
+
+ pub const fn no_store(mut self) -> Self {
+ self.no_store = true;
+ self
+ }
+
+ pub const fn private(mut self) -> Self {
+ self.private = true;
+ self
+ }
+
+ fn to_header_value(&self) -> Option {
+ let CacheControl {
+ immutable,
+ max_age,
+ no_store,
+ private,
+ } = self;
+ let mut cache_control = Vec::new();
+ if *immutable {
+ cache_control.push("immutable".to_owned());
+ }
+ if let Some(age) = max_age {
+ cache_control.push(format!("max-age={}", age.as_secs()));
+ }
+ if *no_store {
+ cache_control.push("no-store".to_owned());
+ }
+ if *private {
+ cache_control.push("private".to_owned());
+ }
- let url = server.url().to_owned();
- (server, url)
+ if !cache_control.is_empty() {
+ let cc = cache_control.join(", ");
+ cc.parse().ok()
+ } else {
+ None
+ }
+ }
}
+impl From for Header {
+ fn from(cache_control: CacheControl) -> Self {
+ let value = cache_control.to_header_value().unwrap();
+ Self::from_bytes(header::CACHE_CONTROL.as_str(), value).unwrap()
+ }
+}
+
+impl From for HeaderMap {
+ fn from(cache_control: CacheControl) -> Self {
+ let mut map = HeaderMap::new();
+
+ if let Some(value) = cache_control.to_header_value() {
+ let value = HeaderValue::from_str(&value).unwrap();
+ map.insert(header::CACHE_CONTROL, value);
+ }
+
+ map
+ }
+}
+
+#[derive(Clone, Copy, Debug)]
+pub enum ContentType {
+ Gif,
+ Jpg,
+ Png,
+ Qoi,
+ Svg,
+ Webp,
+ Other(&'static str),
+}
+
+impl From for ContentType {
+ fn from(sample: Sample) -> Self {
+ match sample {
+ Sample::Gif(_) => Self::Gif,
+ Sample::Jpg(_) => Self::Jpg,
+ Sample::Png(_) => Self::Png,
+ Sample::Qoi(_) => Self::Qoi,
+ Sample::Svg(_) => Self::Svg,
+ Sample::Webp(_) => Self::Webp,
+ }
+ }
+}
+
+impl ContentType {
+ fn to_str(self) -> &'static str {
+ match self {
+ Self::Gif => "image/gif",
+ Self::Jpg => "image/jpeg",
+ Self::Png => "image/png",
+ Self::Qoi => "image/qoi",
+ Self::Svg => "image/svg+xml",
+ Self::Webp => "image/webp",
+ Self::Other(other) => other,
+ }
+ }
+
+ fn to_ext(self) -> Option<&'static str> {
+ match self {
+ Self::Gif => Some(".gif"),
+ Self::Jpg => Some(".jpeg"),
+ Self::Png => Some(".png"),
+ Self::Qoi => Some(".qoi"),
+ Self::Svg => Some(".svg"),
+ Self::Webp => Some(".webp"),
+ Self::Other(_) => None,
+ }
+ }
+}
+
+impl From for Header {
+ fn from(content_ty: ContentType) -> Self {
+ let header_name = header::CONTENT_TYPE.as_str().as_bytes();
+ let content_ty = content_ty.to_str().as_bytes();
+ Header::from_bytes(header_name, content_ty).unwrap()
+ }
+}
+
+#[derive(Clone, SmartDebug)]
pub struct File {
- pub url_path: String,
- pub mime: String,
+ pub mime: ContentType,
+ pub cache_control: Option,
+ pub include_etag: bool,
+ #[debug(wrapper = DebugBytesPrefix)]
pub bytes: Vec,
}
impl File {
- pub fn new(url_path: &str, mime: &str, bytes: &[u8]) -> Self {
+ pub fn new(mime: ContentType, cache_control: Option, bytes: &[u8]) -> Self {
Self {
- url_path: url_path.to_owned(),
- mime: mime.to_owned(),
- bytes: bytes.to_owned(),
+ mime,
+ cache_control,
+ include_etag: false,
+ bytes: bytes.into(),
+ }
+ }
+}
+
+fn hash(bytes: &[u8]) -> u64 {
+ let mut hasher = XxHash64::default();
+ hasher.write(bytes);
+ hasher.finish()
+}
+
+impl From for ResponseBox {
+ fn from(file: File) -> Self {
+ let File {
+ mime,
+ cache_control,
+ include_etag,
+ bytes,
+ } = file;
+
+ let body_hash = hash(&bytes);
+ let mut resp = Response::from_data(bytes).with_header(mime);
+
+ if let Some(c_c) = cache_control {
+ resp.add_header(c_c);
}
+
+ if include_etag {
+ let header_name = http::header::ETAG.as_str().as_bytes();
+ let header_val = format!("\"{body_hash:x}\"");
+ let header = Header::from_bytes(header_name, header_val.as_bytes()).unwrap();
+ resp.add_header(header);
+ }
+
+ resp.boxed()
}
}
diff --git a/src/test_utils/temp.rs b/src/test_utils/temp.rs
new file mode 100644
index 00000000..a7bc7557
--- /dev/null
+++ b/src/test_utils/temp.rs
@@ -0,0 +1,21 @@
+use std::path::PathBuf;
+
+use tempfile::{Builder, NamedTempFile, TempDir};
+
+const TEST_PREFIX: &str = "inlyne-tests-";
+
+pub fn dir() -> (TempDir, PathBuf) {
+ let dir = Builder::new().prefix(TEST_PREFIX).tempdir().unwrap();
+ let path = dir.path().canonicalize().unwrap();
+ (dir, path)
+}
+
+pub fn file_with_suffix(suffix: &str) -> (NamedTempFile, PathBuf) {
+ let file = Builder::new()
+ .prefix(TEST_PREFIX)
+ .suffix(suffix)
+ .tempfile()
+ .unwrap();
+ let path = file.path().canonicalize().unwrap();
+ (file, path)
+}
diff --git a/src/utils.rs b/src/utils.rs
index d50614f8..a818e73e 100644
--- a/src/utils.rs
+++ b/src/utils.rs
@@ -14,6 +14,10 @@ use syntect::highlighting::{Theme as SyntectTheme, ThemeSet as SyntectThemeSet};
use syntect::parsing::SyntaxSet;
use winit::window::CursorIcon;
+pub fn inlyne_cache_dir() -> Option {
+ dirs::cache_dir().map(|dir| dir.join("inlyne"))
+}
+
pub fn format_title(file_path: &Path) -> String {
match root_filepath_to_vcs_dir(file_path) {
Some(path) => format!("Inlyne - {}", path.to_string_lossy()),