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.
This commit is contained in:
817 files changed
+143101
No files matched your search
@@ -0,0 +1,275 @@
|
||||
//! 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user