Files
ElyOffice/crates/ely-web-content/src/surface.rs
T
ZacharyZhang-NY b7fca0146d Ely Office: six offline-first desktop apps in Rust and GPUI
ElyDoc, ElyTable, ElyShow (LibreOfficeKit), ElyPDF (MuPDF), ElyTree (Git),
ElyMark (Milkdown and CodeMirror in CEF). ElyMail and ElyAgent follow.
2026-10-08 21:20:08 -04:00

276 lines
9.4 KiB
Rust

//! What CEF's handlers leave the view; the page fence.
use std::{
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
use cef::{wrapper::message_router::BrowserSideCallback, *};
pub(crate) type Shared = Arc<Mutex<Surface>>;
pub(crate) struct Surface {
/// Points, as GPUI lays the view out.
pub size: (f32, f32),
pub scale: f32,
pub frame: Option<(u32, u32, Vec<u8>)>,
/// Composing characters' boxes, in points.
pub ime: Vec<(f32, f32, f32, f32)>,
pub cursor: CursorType,
pub loaded: bool,
/// The main frame's load failed since it started.
pub errored: bool,
pub queries: Vec<(String, Arc<Mutex<dyn BrowserSideCallback>>)>,
pub leaving: Vec<String>,
/// PDFs printed: where, and whether it worked.
pub printed: Vec<(PathBuf, bool)>,
/// Why the page cannot show: load, renderer or start.
pub failed: Option<String>,
/// Made on the pump's turn, after the view.
pub browser: Option<Browser>,
/// The view went before its browser came.
pub closed: bool,
/// The only folder pages load from, real path.
root: PathBuf,
/// A folder images alone may also come from.
pub images: Option<PathBuf>,
}
impl Surface {
pub fn new(root: PathBuf, scale: f32) -> Self {
Surface {
size: (1.0, 1.0),
scale,
frame: None,
ime: Vec::new(),
cursor: CursorType::POINTER,
loaded: false,
errored: false,
queries: Vec::new(),
leaving: Vec::new(),
printed: Vec::new(),
failed: None,
browser: None,
closed: false,
root,
images: None,
}
}
/// A file under `root`, by its real path.
fn holds(root: &Path, url: &str) -> bool {
let Some(path) = url.strip_prefix("file://") else {
return false;
};
let path = path.split(['?', '#']).next().unwrap_or_default();
let Ok(path) = String::from_utf8(decoded(path)) else {
return false;
};
#[cfg(windows)]
let path = windows_path(&path);
std::fs::canonicalize(path).is_ok_and(|real| real.starts_with(root))
}
/// Where the page may go: its own files, or blank.
pub fn navigable(&self, url: &str) -> bool {
url == "about:blank" || Self::holds(&self.root, url)
}
/// What a page loads: its files, data, allowed images.
pub fn loadable(&self, url: &str, image: bool) -> bool {
let pictured = image
&& self
.images
.as_deref()
.is_some_and(|dir| Self::holds(dir, url));
self.navigable(url)
|| url.starts_with("data:")
|| url.starts_with("blob:file://")
|| pictured
}
}
impl Drop for Surface {
fn drop(&mut self) {
log::info!("web: a page's memory is freed");
}
}
/// `%XX` decoded; a file URL's path.
fn decoded(text: &str) -> Vec<u8> {
let bytes = text.as_bytes();
let (mut out, mut at) = (Vec::with_capacity(bytes.len()), 0);
while at < bytes.len() {
let hex = bytes
.get(at + 1..at + 3)
.and_then(|pair| u8::from_str_radix(std::str::from_utf8(pair).ok()?, 16).ok());
match (bytes[at], hex) {
(b'%', Some(byte)) => {
out.push(byte);
at += 3;
}
(byte, _) => {
out.push(byte);
at += 1;
}
}
}
out
}
/// Bytes as URL text, reserved ones escaped.
fn escaped(bytes: &[u8]) -> String {
let mut out = String::new();
for &byte in bytes {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => {
out.push(byte as char)
}
_ => out.push_str(&format!("%{byte:02X}")),
}
}
out
}
/// A path as a file URL, every reserved byte escaped.
pub fn file_url(path: &Path) -> String {
#[cfg(windows)]
return windows_url(&path.to_string_lossy());
#[cfg(not(windows))]
format!("file://{}", escaped(path.as_os_str().as_encoded_bytes()))
}
/// A drive or UNC path as `file:///C:/…` or `file://host/…`.
#[cfg(any(windows, test))]
fn windows_url(path: &str) -> String {
let path = match path.strip_prefix(r"\\?\UNC\") {
Some(rest) => format!(r"\\{rest}"),
None => path.strip_prefix(r"\\?\").unwrap_or(path).to_owned(),
};
let slashed = |text: &str| escaped(text.replace('\\', "/").as_bytes());
match path.strip_prefix(r"\\") {
Some(unc) => {
let (host, rest) = unc.split_once('\\').unwrap_or((unc, ""));
format!("file://{}/{}", slashed(host), slashed(rest))
}
None if path.get(1..2) == Some(":") => {
format!("file:///{}{}", &path[..2], slashed(&path[2..]))
}
None => format!("file:///{}", slashed(&path)),
}
}
/// A decoded file URL's rest as a Windows path.
#[cfg(any(windows, test))]
fn windows_path(rest: &str) -> String {
match rest.strip_prefix('/') {
Some(drive) => drive.replace('/', "\\"),
None => format!(r"\\{}", rest.replace('/', "\\")),
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
fn surface(root: &Path) -> super::Surface {
super::Surface::new(std::fs::canonicalize(root).expect("real"), 1.0)
}
#[test]
fn only_real_files_under_the_root_load() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = dir.path().join("content");
let outside = dir.path().join("outside");
std::fs::create_dir_all(&root).expect("makes");
std::fs::create_dir_all(&outside).expect("makes");
std::fs::write(root.join("index.html"), "x").expect("writes");
std::fs::write(root.join("a #b?.html"), "x").expect("writes");
std::fs::write(outside.join("o.html"), "x").expect("writes");
let surface = surface(&root);
let url = |path: &Path| super::file_url(&std::fs::canonicalize(path).expect("real"));
assert!(surface.navigable(&url(&root.join("index.html"))));
assert!(
surface.navigable(&url(&root.join("a #b?.html"))),
"reserved bytes"
);
assert!(surface.navigable("about:blank"));
assert!(!surface.navigable(&url(&outside.join("o.html"))));
let up = format!("{}/../outside/o.html", super::file_url(&surface.root));
assert!(!surface.navigable(&up));
assert!(!surface.navigable(&format!("{}/missing.html", super::file_url(&surface.root))));
assert!(!surface.navigable("https://example.com/"));
assert!(
!surface.navigable("data:text/html,x")
&& surface.loadable("data:image/png;base64,x", true)
);
assert!(!surface.loadable("http://127.0.0.1:9/pixel", true));
}
#[test]
fn an_image_folder_admits_images_alone() {
let dir = tempfile::tempdir().expect("a temp dir");
let (root, docs) = (dir.path().join("content"), dir.path().join("docs"));
std::fs::create_dir_all(&root).expect("makes");
std::fs::create_dir_all(&docs).expect("makes");
std::fs::write(docs.join("a.png"), "x").expect("writes");
let mut surface = surface(&root);
let picture = super::file_url(&std::fs::canonicalize(docs.join("a.png")).expect("real"));
assert!(
!surface.loadable(&picture, true),
"not before it is allowed"
);
surface.images = Some(std::fs::canonicalize(&docs).expect("real"));
assert!(surface.loadable(&picture, true));
assert!(!surface.loadable(&picture, false), "a script from there");
assert!(!surface.navigable(&picture));
}
#[cfg(unix)]
#[test]
fn a_link_out_of_the_root_is_outside() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = dir.path().join("content");
let outside = dir.path().join("outside");
std::fs::create_dir_all(&root).expect("makes");
std::fs::create_dir_all(&outside).expect("makes");
std::fs::write(outside.join("o.html"), "x").expect("writes");
std::os::unix::fs::symlink(&outside, root.join("link")).expect("links");
let surface = surface(&root);
let through = format!("{}/link/o.html", super::file_url(&surface.root));
assert!(!surface.navigable(&through));
}
#[test]
fn windows_paths_make_valid_file_urls_and_back() {
let cases = [
(r"C:\Users\A b\doc.md", "file:///C:/Users/A%20b/doc.md"),
(r"\\?\C:\notes\doc.md", "file:///C:/notes/doc.md"),
(r"\\server\share\doc.md", "file://server/share/doc.md"),
(r"\\?\UNC\server\share\doc.md", "file://server/share/doc.md"),
];
for (path, url) in cases {
assert_eq!(super::windows_url(path), url, "{path}");
}
assert_eq!(
super::windows_path("/C:/Users/A b/doc.md"),
r"C:\Users\A b\doc.md"
);
assert_eq!(
super::windows_path("server/share/doc.md"),
r"\\server\share\doc.md"
);
}
#[test]
fn percent_escapes_decode_and_bad_ones_stay() {
assert_eq!(super::decoded("a%20b%e4%b8%ad"), "a b中".as_bytes());
assert_eq!(super::decoded("100%"), b"100%");
assert_eq!(super::decoded("%zz"), b"%zz");
assert_eq!(
super::file_url(Path::new("/a b/中#?.html")),
"file:///a%20b/%E4%B8%AD%23%3F.html"
);
}
}