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,488 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs::{self, File},
|
||||
io::{self, Read, Write},
|
||||
net::IpAddr,
|
||||
path::{Path, PathBuf},
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
mpsc::{self, RecvTimeoutError},
|
||||
},
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail, ensure};
|
||||
use semver::Version;
|
||||
use serde::Deserialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use ureq::{
|
||||
Agent, Body,
|
||||
http::{Response, Uri, header::LOCATION},
|
||||
unversioned::{
|
||||
resolver::DefaultResolver,
|
||||
transport::{
|
||||
Buffers, ConnectionDetails, Connector, DefaultConnector, NextTimeout, Transport,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const MANIFEST_LIMIT: u64 = 1 << 20;
|
||||
const DOWNLOAD_LIMIT: u64 = 4 << 30;
|
||||
const MANIFEST_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const MAX_REDIRECTS: usize = 5;
|
||||
const PACKAGE_MANAGED: [&str; 5] = ["/usr", "/opt", "/app", "/snap", "/nix/store"];
|
||||
|
||||
/// Longest a download may go without a byte.
|
||||
pub const IDLE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Release {
|
||||
pub version: Version,
|
||||
pub url: String,
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum Check {
|
||||
Current,
|
||||
Available(Release),
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum InstallPolicy {
|
||||
SelfManaged,
|
||||
PackageManager,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Manifest {
|
||||
version: Version,
|
||||
assets: HashMap<String, Asset>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Asset {
|
||||
url: String,
|
||||
sha256: String,
|
||||
}
|
||||
|
||||
/// The manifest key of this build, such as `macos-aarch64`.
|
||||
pub fn platform() -> String {
|
||||
format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH)
|
||||
}
|
||||
|
||||
pub fn install_policy() -> Result<InstallPolicy> {
|
||||
let exe = std::env::current_exe().context("cannot find the running executable")?;
|
||||
Ok(policy_for(std::env::consts::OS, &exe))
|
||||
}
|
||||
|
||||
fn policy_for(os: &str, exe: &Path) -> InstallPolicy {
|
||||
let managed = PACKAGE_MANAGED.iter().any(|prefix| exe.starts_with(prefix));
|
||||
if os == "linux" && managed {
|
||||
InstallPolicy::PackageManager
|
||||
} else {
|
||||
InstallPolicy::SelfManaged
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches the manifest and compares it with `current`. Never installs.
|
||||
pub fn check(current: &Version, manifest_url: &str) -> Result<Check> {
|
||||
let host = secure_host(manifest_url)?;
|
||||
log::info!("update check: asking {host}");
|
||||
let result = fetch_manifest(current, manifest_url);
|
||||
match &result {
|
||||
Ok(Check::Current) => log::info!("update check: {host} has nothing newer than {current}"),
|
||||
Ok(Check::Available(release)) => {
|
||||
log::info!("update check: {host} offers {}", release.version)
|
||||
}
|
||||
Err(error) => log::warn!("update check: {host} failed: {error:#}"),
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn fetch_manifest(current: &Version, manifest_url: &str) -> Result<Check> {
|
||||
let text = fetch(&manifest_agent(), manifest_url)?
|
||||
.body_mut()
|
||||
.with_config()
|
||||
.limit(MANIFEST_LIMIT)
|
||||
.read_to_string()
|
||||
.context("cannot read the update manifest")?;
|
||||
let manifest: Manifest =
|
||||
serde_json::from_str(&text).context("the update manifest is not valid")?;
|
||||
let key = platform();
|
||||
let Some(asset) = manifest.assets.get(&key) else {
|
||||
bail!("release {} has no build for {key}", manifest.version);
|
||||
};
|
||||
secure_host(&asset.url)?;
|
||||
if manifest.version <= *current {
|
||||
return Ok(Check::Current);
|
||||
}
|
||||
Ok(Check::Available(Release {
|
||||
version: manifest.version,
|
||||
url: asset.url.clone(),
|
||||
sha256: asset.sha256.to_lowercase(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Downloads into `dir`, calling `progress(done, total)`. `Ok(None)` means cancelled.
|
||||
pub fn download(
|
||||
release: &Release,
|
||||
dir: &Path,
|
||||
cancel: &AtomicBool,
|
||||
idle: Duration,
|
||||
progress: &mut dyn FnMut(u64, Option<u64>),
|
||||
) -> Result<Option<PathBuf>> {
|
||||
let host = secure_host(&release.url)?;
|
||||
let name = file_name(&release.url)?;
|
||||
let part = dir.join(format!("{name}.part"));
|
||||
let target = dir.join(name);
|
||||
log::info!("update download: {} from {host}", release.version);
|
||||
|
||||
let outcome = stream(release, &part, cancel, idle, progress);
|
||||
match outcome {
|
||||
Ok(true) => {
|
||||
fs::rename(&part, &target)
|
||||
.with_context(|| format!("cannot move {} into place", part.display()))?;
|
||||
log::info!(
|
||||
"update download: {} saved to {}",
|
||||
release.version,
|
||||
target.display()
|
||||
);
|
||||
Ok(Some(target))
|
||||
}
|
||||
Ok(false) => {
|
||||
log::info!("update download: {} cancelled", release.version);
|
||||
remove_part(&part)?;
|
||||
Ok(None)
|
||||
}
|
||||
Err(error) => {
|
||||
log::warn!("update download: {} failed: {error:#}", release.version);
|
||||
if let Err(cleanup) = remove_part(&part) {
|
||||
log::warn!("update download: {cleanup:#}");
|
||||
}
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks a user's file against its published hash.
|
||||
pub fn verify_file(path: &Path, sha256: &str) -> Result<()> {
|
||||
let mut file = File::open(path).with_context(|| format!("cannot open {}", path.display()))?;
|
||||
let mut hasher = Sha256::new();
|
||||
io::copy(&mut file, &mut hasher).with_context(|| format!("cannot read {}", path.display()))?;
|
||||
expect_hash(hasher, sha256)
|
||||
}
|
||||
|
||||
fn stream(
|
||||
release: &Release,
|
||||
part: &Path,
|
||||
cancel: &AtomicBool,
|
||||
idle: Duration,
|
||||
progress: &mut dyn FnMut(u64, Option<u64>),
|
||||
) -> Result<bool> {
|
||||
let response = fetch(&download_agent(idle), &release.url)?;
|
||||
let total = response.body().content_length();
|
||||
let (_, body) = response.into_parts();
|
||||
let (sender, receiver) = mpsc::sync_channel::<io::Result<Vec<u8>>>(4);
|
||||
thread::spawn(move || {
|
||||
let mut reader = body.into_with_config().limit(DOWNLOAD_LIMIT).reader();
|
||||
loop {
|
||||
let mut chunk = vec![0u8; 64 * 1024];
|
||||
match reader.read(&mut chunk) {
|
||||
Ok(read) => {
|
||||
chunk.truncate(read);
|
||||
if sender.send(Ok(chunk)).is_err() || read == 0 {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = sender.send(Err(error));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mut file =
|
||||
File::create(part).with_context(|| format!("cannot create {}", part.display()))?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut done = 0u64;
|
||||
loop {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
return Ok(false);
|
||||
}
|
||||
match receiver.recv_timeout(Duration::from_millis(100)) {
|
||||
Ok(Ok(chunk)) if chunk.is_empty() => break,
|
||||
Ok(Ok(chunk)) => {
|
||||
hasher.update(&chunk);
|
||||
file.write_all(&chunk)
|
||||
.with_context(|| format!("cannot write {}", part.display()))?;
|
||||
done += chunk.len() as u64;
|
||||
progress(done, total);
|
||||
}
|
||||
Ok(Err(error)) if is_timeout(&error) => {
|
||||
bail!("the download stalled: no data for {idle:?}")
|
||||
}
|
||||
Ok(Err(error)) => return Err(anyhow!(error).context("the download was interrupted")),
|
||||
Err(RecvTimeoutError::Timeout) => {}
|
||||
Err(RecvTimeoutError::Disconnected) => bail!("the download thread ended early"),
|
||||
}
|
||||
}
|
||||
file.sync_all()
|
||||
.with_context(|| format!("cannot flush {}", part.display()))?;
|
||||
expect_hash(hasher, &release.sha256)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn is_timeout(error: &io::Error) -> bool {
|
||||
let from_ureq = error
|
||||
.get_ref()
|
||||
.and_then(|inner| inner.downcast_ref::<ureq::Error>())
|
||||
.is_some_and(|inner| matches!(inner, ureq::Error::Timeout(_)));
|
||||
from_ureq
|
||||
|| matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock
|
||||
)
|
||||
}
|
||||
|
||||
fn remove_part(part: &Path) -> Result<()> {
|
||||
match fs::remove_file(part) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(error).with_context(|| format!("cannot remove {}", part.display())),
|
||||
}
|
||||
}
|
||||
|
||||
fn expect_hash(hasher: Sha256, expected: &str) -> Result<()> {
|
||||
let actual: String = hasher
|
||||
.finalize()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect();
|
||||
ensure!(
|
||||
actual.eq_ignore_ascii_case(expected),
|
||||
"the file has sha256 {actual}, the release says {expected}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn file_name(url: &str) -> Result<&str> {
|
||||
let url = url.split_once(['?', '#']).map_or(url, |(head, _)| head);
|
||||
let rest = url.split_once("://").map_or(url, |(_, rest)| rest);
|
||||
let path = rest.split_once('/').map_or("", |(_, path)| path);
|
||||
let name = path.rsplit_once('/').map_or(path, |(_, name)| name);
|
||||
ensure!(
|
||||
!name.is_empty() && name != "." && name != ".." && !name.contains(['\\', ':']),
|
||||
"{url} names no safe file"
|
||||
);
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
/// Accepts https, or http to this machine. Returns the host.
|
||||
fn secure_host(url: &str) -> Result<String> {
|
||||
let uri: Uri = url.parse().with_context(|| format!("{url} is not a URL"))?;
|
||||
let authority = uri
|
||||
.authority()
|
||||
.with_context(|| format!("{url} has no host"))?;
|
||||
ensure!(
|
||||
!authority.as_str().contains('@'),
|
||||
"{url} carries credentials"
|
||||
);
|
||||
let host = authority.host();
|
||||
let local = host == "localhost"
|
||||
|| host
|
||||
.trim_matches(['[', ']'])
|
||||
.parse::<IpAddr>()
|
||||
.is_ok_and(|ip| ip.is_loopback());
|
||||
ensure!(
|
||||
uri.scheme_str() == Some("https") || (uri.scheme_str() == Some("http") && local),
|
||||
"{url} is not https"
|
||||
);
|
||||
Ok(host.to_string())
|
||||
}
|
||||
|
||||
/// GETs `url`, following redirects only to places `secure_host` accepts.
|
||||
fn fetch(agent: &Agent, url: &str) -> Result<Response<Body>> {
|
||||
let mut url = url.to_string();
|
||||
for _ in 0..=MAX_REDIRECTS {
|
||||
secure_host(&url)?;
|
||||
let response = agent
|
||||
.get(&url)
|
||||
.call()
|
||||
.with_context(|| format!("cannot fetch {url}"))?;
|
||||
if !response.status().is_redirection() {
|
||||
return Ok(response);
|
||||
}
|
||||
let location = response
|
||||
.headers()
|
||||
.get(LOCATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.with_context(|| format!("{url} redirects without a location"))?;
|
||||
url = if location.starts_with('/') {
|
||||
let uri: Uri = url.parse()?;
|
||||
format!(
|
||||
"{}://{}{location}",
|
||||
uri.scheme_str().unwrap_or("https"),
|
||||
uri.authority().map_or("", |a| a.as_str())
|
||||
)
|
||||
} else {
|
||||
location.to_string()
|
||||
};
|
||||
}
|
||||
bail!("too many redirects")
|
||||
}
|
||||
|
||||
fn manifest_agent() -> Agent {
|
||||
Agent::config_builder()
|
||||
.timeout_global(Some(MANIFEST_TIMEOUT))
|
||||
.max_redirects(0)
|
||||
.max_redirects_will_error(false)
|
||||
.build()
|
||||
.into()
|
||||
}
|
||||
|
||||
/// No total timeout; a silent socket times out.
|
||||
fn download_agent(idle: Duration) -> Agent {
|
||||
let config = Agent::config_builder()
|
||||
.timeout_connect(Some(CONNECT_TIMEOUT))
|
||||
.timeout_recv_response(Some(CONNECT_TIMEOUT))
|
||||
.max_redirects(0)
|
||||
.max_redirects_will_error(false)
|
||||
.build();
|
||||
Agent::with_parts(
|
||||
config,
|
||||
DefaultConnector::new().chain(IdleConnector(idle)),
|
||||
DefaultResolver::default(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Caps socket waits at `idle`; a stall frees the socket.
|
||||
#[derive(Debug)]
|
||||
struct IdleConnector(Duration);
|
||||
|
||||
impl<In: Transport> Connector<In> for IdleConnector {
|
||||
type Out = Idle<In>;
|
||||
|
||||
fn connect(
|
||||
&self,
|
||||
_: &ConnectionDetails,
|
||||
chained: Option<In>,
|
||||
) -> Result<Option<Idle<In>>, ureq::Error> {
|
||||
Ok(chained.map(|inner| Idle {
|
||||
inner,
|
||||
idle: self.0,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Idle<T> {
|
||||
inner: T,
|
||||
idle: Duration,
|
||||
}
|
||||
|
||||
impl<T> Idle<T> {
|
||||
fn capped(&self, mut timeout: NextTimeout) -> NextTimeout {
|
||||
timeout.after = timeout.after.min(self.idle.into());
|
||||
timeout
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Transport> Transport for Idle<T> {
|
||||
fn buffers(&mut self) -> &mut dyn Buffers {
|
||||
self.inner.buffers()
|
||||
}
|
||||
|
||||
fn transmit_output(&mut self, amount: usize, timeout: NextTimeout) -> Result<(), ureq::Error> {
|
||||
let timeout = self.capped(timeout);
|
||||
self.inner.transmit_output(amount, timeout)
|
||||
}
|
||||
|
||||
fn await_input(&mut self, timeout: NextTimeout) -> Result<bool, ureq::Error> {
|
||||
let timeout = self.capped(timeout);
|
||||
self.inner.await_input(timeout)
|
||||
}
|
||||
|
||||
fn is_open(&mut self) -> bool {
|
||||
self.inner.is_open()
|
||||
}
|
||||
|
||||
fn is_tls(&self) -> bool {
|
||||
self.inner.is_tls()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::Path;
|
||||
|
||||
use super::{InstallPolicy, file_name, policy_for, secure_host};
|
||||
|
||||
#[test]
|
||||
fn only_a_linux_package_install_defers_to_the_package_manager() {
|
||||
let usr = Path::new("/usr/bin/ely-doc");
|
||||
assert_eq!(policy_for("linux", usr), InstallPolicy::PackageManager);
|
||||
assert_eq!(
|
||||
policy_for("linux", Path::new("/app/bin/ely-doc")),
|
||||
InstallPolicy::PackageManager
|
||||
);
|
||||
assert_eq!(
|
||||
policy_for("linux", Path::new("/home/me/Apps/ely-doc")),
|
||||
InstallPolicy::SelfManaged
|
||||
);
|
||||
assert_eq!(
|
||||
policy_for("linux", Path::new("/tmp/.mount_ElyDoc/usr/bin/ely-doc")),
|
||||
InstallPolicy::SelfManaged
|
||||
);
|
||||
assert_eq!(policy_for("macos", usr), InstallPolicy::SelfManaged);
|
||||
assert_eq!(
|
||||
policy_for("windows", Path::new("C:/Program Files/Ely")),
|
||||
InstallPolicy::SelfManaged
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_http_is_for_this_machine_only() {
|
||||
for ok in [
|
||||
"https://example.com/a.json",
|
||||
"http://127.0.0.1:8080/a",
|
||||
"http://localhost/a",
|
||||
"http://[::1]:9/a",
|
||||
] {
|
||||
secure_host(ok).unwrap();
|
||||
}
|
||||
for bad in [
|
||||
"http://example.com/a",
|
||||
"http://127.0.0.1.evil.com/a",
|
||||
"http://localhost.evil.com/a",
|
||||
"http://127.0.0.1:1@192.168.1.5:80/a",
|
||||
"https://user:pw@example.com/a",
|
||||
"ftp://x/y",
|
||||
"file:///etc/passwd",
|
||||
"/relative",
|
||||
] {
|
||||
assert!(secure_host(bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_download_names_one_file() {
|
||||
assert_eq!(
|
||||
file_name("https://h/p/ElyDoc-0.2.0.dmg?sig=1#x").unwrap(),
|
||||
"ElyDoc-0.2.0.dmg"
|
||||
);
|
||||
for bad in [
|
||||
"https://h/p/",
|
||||
"https://h/..",
|
||||
"https://h/.",
|
||||
"https://h",
|
||||
"https://h/..\\evil.exe",
|
||||
"https://h/c:evil",
|
||||
] {
|
||||
assert!(file_name(bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user