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,94 @@
|
||||
use crate::{env, settings::Motion};
|
||||
|
||||
/// `ELY_REDUCE_MOTION` wins, then the setting, then the OS.
|
||||
pub fn reduce_motion(setting: Motion) -> bool {
|
||||
let (env, system) = (env::flag("ELY_REDUCE_MOTION"), os_reduce_motion());
|
||||
if env.is_none() && setting == Motion::System && system.is_none() {
|
||||
log::info!("accessibility: no reduce-motion setting found, using full motion");
|
||||
}
|
||||
reduce_motion_from(env, setting, system)
|
||||
}
|
||||
|
||||
fn reduce_motion_from(env: Option<bool>, setting: Motion, system: Option<bool>) -> bool {
|
||||
let set = match setting {
|
||||
Motion::System => None,
|
||||
Motion::Reduced => Some(true),
|
||||
Motion::Full => Some(false),
|
||||
};
|
||||
env.or(set).or(system).unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn os_reduce_motion() -> Option<bool> {
|
||||
Some(objc2_app_kit::NSWorkspace::sharedWorkspace().accessibilityDisplayShouldReduceMotion())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn os_reduce_motion() -> Option<bool> {
|
||||
let output = std::process::Command::new("gsettings")
|
||||
.args(["get", "org.gnome.desktop.interface", "enable-animations"])
|
||||
.output()
|
||||
.map_err(|error| log::debug!("accessibility: gsettings did not run: {error}"))
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
log::debug!("accessibility: gsettings has no enable-animations key");
|
||||
return None;
|
||||
}
|
||||
animations_enabled(&String::from_utf8_lossy(&output.stdout)).map(|enabled| !enabled)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn os_reduce_motion() -> Option<bool> {
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::{
|
||||
SPI_GETCLIENTAREAANIMATION, SystemParametersInfoW,
|
||||
};
|
||||
|
||||
let mut enabled: i32 = 1;
|
||||
// SAFETY: a live i32, the size the call expects.
|
||||
let read = unsafe {
|
||||
SystemParametersInfoW(
|
||||
SPI_GETCLIENTAREAANIMATION,
|
||||
0,
|
||||
(&mut enabled as *mut i32).cast(),
|
||||
0,
|
||||
)
|
||||
};
|
||||
(read != 0).then_some(enabled == 0)
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
|
||||
fn os_reduce_motion() -> Option<bool> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
fn animations_enabled(gsettings: &str) -> Option<bool> {
|
||||
match gsettings.trim() {
|
||||
"true" => Some(true),
|
||||
"false" => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Motion, animations_enabled, reduce_motion_from};
|
||||
|
||||
#[test]
|
||||
fn the_env_wins_then_the_setting_then_the_os_then_full_motion() {
|
||||
assert!(reduce_motion_from(Some(true), Motion::Full, Some(false)));
|
||||
assert!(!reduce_motion_from(Some(false), Motion::Reduced, Some(true)));
|
||||
assert!(reduce_motion_from(None, Motion::Reduced, Some(false)));
|
||||
assert!(!reduce_motion_from(None, Motion::Full, Some(true)));
|
||||
assert!(reduce_motion_from(None, Motion::System, Some(true)));
|
||||
assert!(!reduce_motion_from(None, Motion::System, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gsettings_answers_parse_and_anything_else_is_no_answer() {
|
||||
assert_eq!(animations_enabled("true\n"), Some(true));
|
||||
assert_eq!(animations_enabled("false\n"), Some(false));
|
||||
assert_eq!(animations_enabled("uint32 7\n"), None);
|
||||
assert_eq!(animations_enabled(""), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/// The eight apps, in PRD order.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum AppId {
|
||||
Doc,
|
||||
Table,
|
||||
Show,
|
||||
Pdf,
|
||||
Mail,
|
||||
Tree,
|
||||
Mark,
|
||||
Agent,
|
||||
}
|
||||
|
||||
impl AppId {
|
||||
pub const ALL: [AppId; 8] = [
|
||||
AppId::Doc,
|
||||
AppId::Table,
|
||||
AppId::Show,
|
||||
AppId::Pdf,
|
||||
AppId::Mail,
|
||||
AppId::Tree,
|
||||
AppId::Mark,
|
||||
AppId::Agent,
|
||||
];
|
||||
|
||||
/// The macOS bundle identifier `packaging/macos/bundle.sh` writes.
|
||||
pub fn bundle_id(self) -> String {
|
||||
format!("ely.zacharyzhang.office.{}", &self.slug()["ely-".len()..])
|
||||
}
|
||||
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
AppId::Doc => "ElyDoc",
|
||||
AppId::Table => "ElyTable",
|
||||
AppId::Show => "ElyShow",
|
||||
AppId::Pdf => "ElyPDF",
|
||||
AppId::Mail => "ElyMail",
|
||||
AppId::Tree => "ElyTree",
|
||||
AppId::Mark => "ElyMark",
|
||||
AppId::Agent => "ElyAgent",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn slug(self) -> &'static str {
|
||||
match self {
|
||||
AppId::Doc => "ely-doc",
|
||||
AppId::Table => "ely-table",
|
||||
AppId::Show => "ely-show",
|
||||
AppId::Pdf => "ely-pdf",
|
||||
AppId::Mail => "ely-mail",
|
||||
AppId::Tree => "ely-tree",
|
||||
AppId::Mark => "ely-mark",
|
||||
AppId::Agent => "ely-agent",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::AppId::{self, *};
|
||||
|
||||
const IN_PRD_ORDER: [AppId; 8] = AppId::ALL;
|
||||
|
||||
#[test]
|
||||
fn bundle_ids_match_the_packaging() {
|
||||
assert_eq!(Doc.bundle_id(), "ely.zacharyzhang.office.doc");
|
||||
let script = include_str!("../../../packaging/macos/bundle.sh");
|
||||
assert!(script.contains("prefix=ely.zacharyzhang.office\n"));
|
||||
assert!(script.contains("<string>$prefix.${slug#ely-}</string>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_follow_the_prd() {
|
||||
let names: Vec<_> = IN_PRD_ORDER.iter().map(|app| app.name()).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
"ElyDoc", "ElyTable", "ElyShow", "ElyPDF", "ElyMail", "ElyTree", "ElyMark",
|
||||
"ElyAgent"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugs_are_the_binary_names() {
|
||||
let slugs: Vec<_> = IN_PRD_ORDER.iter().map(|app| app.slug()).collect();
|
||||
assert_eq!(
|
||||
slugs,
|
||||
[
|
||||
"ely-doc",
|
||||
"ely-table",
|
||||
"ely-show",
|
||||
"ely-pdf",
|
||||
"ely-mail",
|
||||
"ely-tree",
|
||||
"ely-mark",
|
||||
"ely-agent"
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
//! Starting another Ely app installed beside this one.
|
||||
|
||||
use std::{
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
};
|
||||
|
||||
use crate::AppId;
|
||||
|
||||
/// Where `app` sits for an install laid out like `exe`'s.
|
||||
fn beside(exe: &Path, app: AppId) -> Vec<PathBuf> {
|
||||
let file = format!("{}{}", app.slug(), std::env::consts::EXE_SUFFIX);
|
||||
let Some(dir) = exe.parent() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut places = vec![dir.join(&file)];
|
||||
// macOS: Name.app/Contents/MacOS; Windows: Ely Office\Name.
|
||||
if let Some(bundle) = dir.parent().and_then(Path::parent) {
|
||||
let app_dir = format!("{}.app", app.name());
|
||||
if let Some(apps) = bundle.parent() {
|
||||
places.push(apps.join(app_dir).join("Contents/MacOS").join(&file));
|
||||
}
|
||||
}
|
||||
if let Some(suite) = dir.parent() {
|
||||
places.push(suite.join(app.name()).join(&file));
|
||||
}
|
||||
places
|
||||
}
|
||||
|
||||
/// `app`'s binary: beside this one, else wherever macOS has it.
|
||||
fn find(app: AppId) -> io::Result<Option<PathBuf>> {
|
||||
let exe = std::env::current_exe()?;
|
||||
let near = beside(&exe, app).into_iter().find(|place| place.is_file());
|
||||
Ok(near.or_else(|| registered(app)))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn registered(app: AppId) -> Option<PathBuf> {
|
||||
use objc2_app_kit::NSWorkspace;
|
||||
use objc2_foundation::NSString;
|
||||
let id = NSString::from_str(&app.bundle_id());
|
||||
let url = NSWorkspace::sharedWorkspace().URLForApplicationWithBundleIdentifier(&id)?;
|
||||
let bundle = PathBuf::from(url.path()?.to_string());
|
||||
let binary = bundle.join("Contents/MacOS").join(app.slug());
|
||||
binary.is_file().then_some(binary)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn registered(_: AppId) -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether `app` can be started from here.
|
||||
pub fn installed(app: AppId) -> bool {
|
||||
matches!(find(app), Ok(Some(_)))
|
||||
}
|
||||
|
||||
/// The `.app` a macOS binary sits in.
|
||||
fn bundle_of(binary: &Path) -> Option<&Path> {
|
||||
let bundle = binary.parent()?.parent()?.parent()?;
|
||||
(cfg!(target_os = "macos") && bundle.extension().is_some_and(|ext| ext == "app")).then_some(bundle)
|
||||
}
|
||||
|
||||
/// Starts `app` with `files`; a running one takes them.
|
||||
pub fn open(app: AppId, files: &[PathBuf]) -> io::Result<()> {
|
||||
let found = find(app)?.ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::NotFound, format!("{} is not installed", app.name()))
|
||||
})?;
|
||||
// Launch Services hands files to a running app.
|
||||
if let Some(bundle) = bundle_of(&found) {
|
||||
let out = Command::new("open").arg("-a").arg(bundle).args(files).output()?;
|
||||
if !out.status.success() {
|
||||
let said = String::from_utf8_lossy(&out.stderr).trim().to_owned();
|
||||
return Err(io::Error::other(format!("{}: {said}", app.name())));
|
||||
}
|
||||
log::info!("apps: opened {} with {} files", app.name(), files.len());
|
||||
return Ok(());
|
||||
}
|
||||
let mut command = Command::new(&found);
|
||||
command.args(files);
|
||||
let child = fresh_signals(&mut command).spawn()?;
|
||||
log::info!("apps: started {} as {}", app.name(), child.id());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The started app takes signals as from a shell.
|
||||
#[cfg(unix)]
|
||||
fn fresh_signals(command: &mut Command) -> &mut Command {
|
||||
use std::os::unix::process::CommandExt as _;
|
||||
// Chromium's ignored or blocked signals would carry over.
|
||||
unsafe {
|
||||
command.pre_exec(|| {
|
||||
for signal in [libc::SIGTERM, libc::SIGINT, libc::SIGHUP, libc::SIGQUIT] {
|
||||
libc::signal(signal, libc::SIG_DFL);
|
||||
}
|
||||
let mut none: libc::sigset_t = std::mem::zeroed();
|
||||
libc::sigemptyset(&mut none);
|
||||
libc::pthread_sigmask(libc::SIG_SETMASK, &none, std::ptr::null_mut());
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn fresh_signals(command: &mut Command) -> &mut Command {
|
||||
command
|
||||
}
|
||||
|
||||
/// Opens `path` in the default app; a refusal says why.
|
||||
pub fn open_default(path: &Path) -> io::Result<()> {
|
||||
let mut command = if cfg!(target_os = "macos") {
|
||||
Command::new("open")
|
||||
} else if cfg!(windows) {
|
||||
let mut start = Command::new("cmd");
|
||||
start.args(["/C", "start", ""]);
|
||||
start
|
||||
} else {
|
||||
Command::new("xdg-open")
|
||||
};
|
||||
let out = command.arg(path).output()?;
|
||||
if !out.status.success() {
|
||||
let said = String::from_utf8_lossy(&out.stderr).trim().to_owned();
|
||||
return Err(io::Error::other(said));
|
||||
}
|
||||
log::info!("apps: the system opened an output");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::AppId;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn a_missing_output_says_so() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let error = super::open_default(&dir.path().join("gone.txt")).expect_err("no file");
|
||||
assert!(error.to_string().contains("does not exist"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_install_layout_names_its_sibling() {
|
||||
let suffix = std::env::consts::EXE_SUFFIX;
|
||||
let places = super::beside(
|
||||
Path::new("/Apps/ElyTree.app/Contents/MacOS/ely-tree"),
|
||||
AppId::Mark,
|
||||
);
|
||||
assert!(places.contains(&PathBuf::from(format!(
|
||||
"/Apps/ElyMark.app/Contents/MacOS/ely-mark{suffix}"
|
||||
))));
|
||||
let places = super::beside(Path::new("/target/debug/ely-tree"), AppId::Mark);
|
||||
assert_eq!(
|
||||
places[0],
|
||||
PathBuf::from(format!("/target/debug/ely-mark{suffix}"))
|
||||
);
|
||||
let places = super::beside(Path::new("/Ely Office/ElyTree/ely-tree.exe"), AppId::Mark);
|
||||
assert!(places.contains(&PathBuf::from(format!(
|
||||
"/Ely Office/ElyMark/ely-mark{suffix}"
|
||||
))));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod signals {
|
||||
use std::os::unix::process::ExitStatusExt as _;
|
||||
|
||||
/// A starter that ignores and blocks SIGTERM, as Chromium may.
|
||||
#[test]
|
||||
fn a_started_app_still_stops_on_sigterm() {
|
||||
unsafe {
|
||||
libc::signal(libc::SIGTERM, libc::SIG_IGN);
|
||||
let mut term: libc::sigset_t = std::mem::zeroed();
|
||||
libc::sigemptyset(&mut term);
|
||||
libc::sigaddset(&mut term, libc::SIGTERM);
|
||||
libc::pthread_sigmask(libc::SIG_BLOCK, &term, std::ptr::null_mut());
|
||||
}
|
||||
let mut command = std::process::Command::new("/bin/sleep");
|
||||
command.arg("30");
|
||||
let mut child = super::fresh_signals(&mut command).spawn().unwrap();
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
unsafe { libc::kill(child.id() as i32, libc::SIGTERM) };
|
||||
let status = child.wait().unwrap();
|
||||
unsafe { libc::signal(libc::SIGTERM, libc::SIG_DFL) };
|
||||
assert_eq!(status.signal(), Some(libc::SIGTERM), "{status:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//! The system clipboard: text, HTML and PNG images (LOK-06).
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use arboard::{Clipboard, Error, ImageData};
|
||||
|
||||
/// One clipboard payload.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Clip {
|
||||
Text(String),
|
||||
/// Rich text or tables, with a plain fallback.
|
||||
Html {
|
||||
html: String,
|
||||
text: String,
|
||||
},
|
||||
Png(Vec<u8>),
|
||||
}
|
||||
|
||||
/// One handle for the process; Linux serves from it.
|
||||
static BOARD: Mutex<Option<Clipboard>> = Mutex::new(None);
|
||||
|
||||
fn with_board<T>(call: impl FnOnce(&mut Clipboard) -> Result<T>) -> Result<T> {
|
||||
let mut board = BOARD.lock().expect("the clipboard lock");
|
||||
if board.is_none() {
|
||||
*board = Some(Clipboard::new().context("the system clipboard opens")?);
|
||||
}
|
||||
call(board.as_mut().expect("opened above"))
|
||||
}
|
||||
|
||||
/// Puts `clip` on the system clipboard.
|
||||
pub fn write(clip: &Clip) -> Result<()> {
|
||||
with_board(|board| {
|
||||
match clip {
|
||||
Clip::Text(text) => board.set_text(text.as_str())?,
|
||||
Clip::Html { html, text } => board.set_html(html.as_str(), Some(text.as_str()))?,
|
||||
Clip::Png(png) => {
|
||||
let image = image::load_from_memory(png)
|
||||
.context("the copied png decodes")?
|
||||
.to_rgba8();
|
||||
board.set_image(ImageData {
|
||||
width: image.width() as usize,
|
||||
height: image.height() as usize,
|
||||
bytes: image.into_raw().into(),
|
||||
})?
|
||||
}
|
||||
}
|
||||
log::info!("clipboard: wrote {}", kind(clip));
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// The richest payload on the clipboard: HTML, image, then text.
|
||||
pub fn read() -> Result<Option<Clip>> {
|
||||
with_board(|board| {
|
||||
let text = absent_ok(board.get_text())?;
|
||||
if let Some(html) = absent_ok(board.get().html())? {
|
||||
let text = text.unwrap_or_default();
|
||||
return Ok(Some(Clip::Html { html, text }));
|
||||
}
|
||||
if let Some(image) = absent_ok(board.get_image())? {
|
||||
let rgba = image::RgbaImage::from_raw(
|
||||
image.width as u32,
|
||||
image.height as u32,
|
||||
image.bytes.into_owned(),
|
||||
)
|
||||
.context("the clipboard image has its size")?;
|
||||
let mut png = Vec::new();
|
||||
rgba.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.context("the clipboard image encodes")?;
|
||||
return Ok(Some(Clip::Png(png)));
|
||||
}
|
||||
Ok(text.map(Clip::Text))
|
||||
})
|
||||
}
|
||||
|
||||
/// Empty or other-format clipboards read as None.
|
||||
fn absent_ok<T>(outcome: Result<T, Error>) -> Result<Option<T>> {
|
||||
match outcome {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(Error::ContentNotAvailable) => Ok(None),
|
||||
Err(error) => Err(error).context("the clipboard reads"),
|
||||
}
|
||||
}
|
||||
|
||||
fn kind(clip: &Clip) -> &'static str {
|
||||
match clip {
|
||||
Clip::Text(_) => "text",
|
||||
Clip::Html { .. } => "html",
|
||||
Clip::Png(_) => "png",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Writes then reads the real system clipboard.
|
||||
#[test]
|
||||
fn each_kind_round_trips() {
|
||||
if std::env::var_os("ELY_CLIPBOARD_TEST").is_none() {
|
||||
eprintln!("SKIP: set ELY_CLIPBOARD_TEST=1; this replaces the clipboard");
|
||||
return;
|
||||
}
|
||||
let text = Clip::Text("plain 中文".into());
|
||||
write(&text).expect("text writes");
|
||||
assert_eq!(read().expect("text reads"), Some(text));
|
||||
let html = Clip::Html {
|
||||
html: "<table><tr><td>a</td></tr></table>".into(),
|
||||
text: "a".into(),
|
||||
};
|
||||
write(&html).expect("html writes");
|
||||
let Some(Clip::Html { html: back, text }) = read().expect("html reads") else {
|
||||
panic!("html comes back as html");
|
||||
};
|
||||
assert!(back.contains("<td>a</td>"), "the table survives: {back}");
|
||||
assert_eq!(text, "a");
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_pixel(3, 2, image::Rgba([0, 0, 255, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.expect("the png encodes");
|
||||
write(&Clip::Png(png)).expect("png writes");
|
||||
let Some(Clip::Png(back)) = read().expect("png reads") else {
|
||||
panic!("an image comes back as png");
|
||||
};
|
||||
let back = image::load_from_memory(&back).expect("decodes").to_rgba8();
|
||||
assert_eq!((back.width(), back.height()), (3, 2));
|
||||
assert_eq!(back.get_pixel(0, 0).0, [0, 0, 255, 255]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
//! The diagnostics a user may export: shown first, redactable (§18.2).
|
||||
|
||||
use std::{
|
||||
io::{self, Read as _, Seek as _, SeekFrom},
|
||||
path::{Path, PathBuf},
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
|
||||
use crate::AppId;
|
||||
|
||||
/// Only the newest part of a long log goes out.
|
||||
const LOG_TAIL: u64 = 4 * 1024 * 1024;
|
||||
const CRASH_AGE: Duration = Duration::from_secs(7 * 24 * 3600);
|
||||
|
||||
/// One file the bundle would carry.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Part {
|
||||
pub path: PathBuf,
|
||||
pub bytes: u64,
|
||||
}
|
||||
|
||||
/// `app`'s log and its recent crash reports.
|
||||
pub fn gather(app: AppId) -> io::Result<Vec<Part>> {
|
||||
let mut parts = Vec::new();
|
||||
let log = crate::data_dir(app).map_err(io::Error::other)?.join("ely.log");
|
||||
parts.extend(log_part(log)?);
|
||||
for dir in crash_dirs() {
|
||||
parts.extend(crashes_in(&dir, &crashing(app), SystemTime::now())?);
|
||||
}
|
||||
Ok(parts)
|
||||
}
|
||||
|
||||
/// The log's newest part; only a missing log is nothing.
|
||||
fn log_part(log: PathBuf) -> io::Result<Option<Part>> {
|
||||
match std::fs::metadata(&log) {
|
||||
Ok(meta) => Ok(Some(Part { bytes: meta.len().min(LOG_TAIL), path: log })),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
|
||||
Err(error) => Err(io::Error::new(error.kind(), format!("{}: {error}", log.display()))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The processes whose crashes belong to `app`.
|
||||
fn crashing(app: AppId) -> Vec<String> {
|
||||
let mut names = vec![app.slug().to_string()];
|
||||
match app {
|
||||
AppId::Doc | AppId::Table | AppId::Show => names.push("ely-lok-worker".into()),
|
||||
AppId::Pdf => names.push("ely-pdf-worker".into()),
|
||||
_ => {}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// Where the system keeps crash reports.
|
||||
fn crash_dirs() -> Vec<PathBuf> {
|
||||
let home = directories::BaseDirs::new().map(|base| base.home_dir().to_path_buf());
|
||||
match (cfg!(target_os = "macos"), home) {
|
||||
(true, Some(home)) => vec![home.join("Library/Logs/DiagnosticReports")],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn crashes_in(dir: &Path, names: &[String], now: SystemTime) -> io::Result<Vec<Part>> {
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let mut found = Vec::new();
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
// `ely-doc-2026-10-08-013420.ips`, helpers with their suffix.
|
||||
let ours = names.iter().any(|own| name.starts_with(&format!("{own}-")) || name.starts_with(&format!("{own} ")));
|
||||
let meta = entry.metadata()?;
|
||||
let fresh = meta.modified().is_ok_and(|at| now.duration_since(at).is_ok_and(|age| age < CRASH_AGE));
|
||||
if ours && fresh && meta.is_file() {
|
||||
found.push(Part { path: entry.path(), bytes: meta.len() });
|
||||
}
|
||||
}
|
||||
found.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// Redaction replaces every path, URL and user name.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Redact {
|
||||
pub names: Vec<String>,
|
||||
}
|
||||
|
||||
impl Redact {
|
||||
/// This account's names and the set author; unreadable ones fail.
|
||||
pub fn of_account() -> io::Result<Self> {
|
||||
Self::of(crate::user::account(), crate::settings::Shared::load())
|
||||
}
|
||||
|
||||
fn of(account: io::Result<String>, shared: io::Result<crate::settings::Shared>) -> io::Result<Self> {
|
||||
let mut names = vec![account?, shared?.author];
|
||||
names.extend(std::env::var("USER").ok());
|
||||
names.extend(std::env::var("USERNAME").ok());
|
||||
Ok(Self { names })
|
||||
}
|
||||
|
||||
pub fn apply(&self, text: &str) -> String {
|
||||
let mut out: String = text.split_inclusive('\n').map(path_free).collect();
|
||||
// Longest first, so short names never split long ones.
|
||||
let mut names: Vec<&str> = self.names.iter().map(|name| name.trim()).filter(|name| !name.is_empty()).collect();
|
||||
names.sort_by_key(|name| std::cmp::Reverse(name.len()));
|
||||
for name in names {
|
||||
out = out.replace(name, "<user>");
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Paths and URLs become `<path>`, unquoted ones to line end.
|
||||
fn path_free(line: &str) -> String {
|
||||
let chars: Vec<char> = line.chars().collect();
|
||||
let mut out = String::with_capacity(line.len());
|
||||
let mut at = 0;
|
||||
while at < chars.len() {
|
||||
let before = at.checked_sub(1).map(|i| chars[i]);
|
||||
let opens = before.is_none_or(|c| c.is_whitespace() || "\"'([=:<".contains(c));
|
||||
if !(opens && starts_path(&chars[at..])) {
|
||||
out.push(chars[at]);
|
||||
at += 1;
|
||||
continue;
|
||||
}
|
||||
// Quoted: up to the quote; else the whole line.
|
||||
let end = match before.filter(|c| *c == '"' || *c == '\'') {
|
||||
Some(quote) => closing(&chars, at, quote),
|
||||
None => chars.len() - usize::from(chars.last() == Some(&'\n')),
|
||||
};
|
||||
out.push_str("<path>");
|
||||
at = end;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Where `quote` closes; a backslash escapes in double quotes (JSON).
|
||||
fn closing(chars: &[char], from: usize, quote: char) -> usize {
|
||||
let mut at = from;
|
||||
while at < chars.len() {
|
||||
match chars[at] {
|
||||
'\\' if quote == '"' => at += 2,
|
||||
c if c == quote => return at,
|
||||
_ => at += 1,
|
||||
}
|
||||
}
|
||||
chars.len()
|
||||
}
|
||||
|
||||
/// `/x`, `~/x`, `C:\x`, `\\host`, or `scheme://`.
|
||||
fn starts_path(rest: &[char]) -> bool {
|
||||
let at = |n: usize| rest.get(n).copied();
|
||||
let drive = at(0).is_some_and(|c| c.is_ascii_alphabetic()) && at(1) == Some(':') && matches!(at(2), Some('\\' | '/'));
|
||||
let scheme = {
|
||||
let letters = rest.iter().take_while(|c| c.is_ascii_alphanumeric() || **c == '+' || **c == '-').count();
|
||||
letters > 1 && rest[letters..].starts_with(&[':', '/', '/'])
|
||||
};
|
||||
let rooted = matches!(at(0), Some('/') | Some('\\')) && at(1).is_some_and(|c| !c.is_whitespace());
|
||||
rooted || (at(0) == Some('~') && at(1) == Some('/')) || drive || scheme
|
||||
}
|
||||
|
||||
/// One text file holding every part, redacted when asked.
|
||||
pub fn bundle(app: AppId, parts: &[Part], redact: Option<&Redact>) -> io::Result<String> {
|
||||
let mut text = format!(
|
||||
"{} {} diagnostics\nsystem: {} {}\n",
|
||||
app.name(),
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH
|
||||
);
|
||||
for part in parts {
|
||||
let mut file = std::fs::File::open(&part.path)?;
|
||||
let length = file.metadata()?.len();
|
||||
let skipped = length.saturating_sub(part.bytes);
|
||||
// A cut line may hold half a path: skip it.
|
||||
let mut before = [b'\n'];
|
||||
if skipped > 0 {
|
||||
file.seek(SeekFrom::Start(skipped - 1))?;
|
||||
file.read_exact(&mut before)?;
|
||||
}
|
||||
file.seek(SeekFrom::Start(skipped))?;
|
||||
let mut body = Vec::new();
|
||||
file.take(part.bytes).read_to_end(&mut body)?;
|
||||
if before[0] != b'\n' {
|
||||
let whole = body.iter().position(|b| *b == b'\n').map_or(body.len(), |n| n + 1);
|
||||
body.drain(..whole);
|
||||
}
|
||||
let name = part.path.file_name().map(|name| name.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
text.push_str(&format!("\n===== {name} =====\n"));
|
||||
text.push_str(&String::from_utf8_lossy(&body));
|
||||
}
|
||||
Ok(match redact {
|
||||
Some(redact) => redact.apply(&text),
|
||||
None => text,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,88 @@
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use super::{Part, Redact, bundle, crashes_in, log_part};
|
||||
use crate::AppId;
|
||||
|
||||
#[test]
|
||||
fn only_this_apps_recent_crashes_are_picked() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for name in ["ely-doc-2026-10-08-1.ips", "ely-lok-worker-2026-10-08-2.ips", "ely-docs-x.ips", "Safari-1.ips"] {
|
||||
std::fs::write(dir.path().join(name), "report").unwrap();
|
||||
}
|
||||
let names = vec!["ely-doc".to_string(), "ely-lok-worker".to_string()];
|
||||
let picked = crashes_in(dir.path(), &names, SystemTime::now()).unwrap();
|
||||
let files: Vec<_> = picked.iter().map(|part| part.path.file_name().unwrap().to_string_lossy().into_owned()).collect();
|
||||
assert_eq!(files, ["ely-doc-2026-10-08-1.ips", "ely-lok-worker-2026-10-08-2.ips"]);
|
||||
let later = SystemTime::now() + Duration::from_secs(8 * 24 * 3600);
|
||||
assert!(crashes_in(dir.path(), &names, later).unwrap().is_empty(), "a week old is left out");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redaction_hides_every_path_url_and_name() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let log = dir.path().join("ely.log");
|
||||
let lines = [
|
||||
"opened /Users/ana/Documents/plan.docx for Ana Lee",
|
||||
"opened /Volumes/ClientSecret/payroll.pdf",
|
||||
r"saved C:\Users\ana\pay.xlsx",
|
||||
"linked 'https://intra.example/q3' and (~/notes.md)",
|
||||
"signed by Ana",
|
||||
"opened '/Volumes/ClientSecret/Tax Salary Report.pdf' fine",
|
||||
r"saved C:\Clients\Tax Salary Report.pdf",
|
||||
"author: 李",
|
||||
r#"{"usedImages":[{"path":"/Volumes/ClientSecret/Tax \"Wage Sheet\".pdf"}]}"#,
|
||||
];
|
||||
std::fs::write(&log, lines.join("\n")).unwrap();
|
||||
let parts = [Part { path: log.clone(), bytes: 4096 }];
|
||||
// The short account name comes before the full author name.
|
||||
let redact = Redact { names: vec!["Ana".into(), "Ana Lee".into(), "李".into()] };
|
||||
let text = bundle(AppId::Doc, &parts, Some(&redact)).unwrap();
|
||||
for secret in ["/Users/ana", "ClientSecret", "payroll", r"C:\Users", "intra.example", "notes.md", "Lee", "Ana", "Salary", "Report", "李", "Wage", "Sheet"] {
|
||||
assert!(!text.contains(secret), "{secret} stays in:\n{text}");
|
||||
}
|
||||
assert!(text.contains("opened '<path>' fine") && text.contains("linked '<path>' and (<path>"), "{text}");
|
||||
assert!(!text.contains(&dir.path().display().to_string()), "the header names only the file");
|
||||
let kept = bundle(AppId::Doc, &parts, None).unwrap();
|
||||
assert!(kept.contains("/Volumes/ClientSecret/payroll.pdf"), "unredacted when not asked");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn an_unreadable_log_is_an_error_not_nothing() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let log = dir.path().join("ely.log");
|
||||
assert_eq!(log_part(log.clone()).unwrap(), None, "no log yet is nothing");
|
||||
std::os::unix::fs::symlink(&log, &log).unwrap();
|
||||
assert!(log_part(log).is_err(), "a log that cannot be read shows");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cut_log_starts_at_a_whole_line() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let log = dir.path().join("ely.log");
|
||||
let first = "opened '/Volumes/ClientSecret/Tax Salary Report.pdf'\n";
|
||||
std::fs::write(&log, format!("{first}later line\n")).unwrap();
|
||||
// The cut falls inside the path, on its file name.
|
||||
let bytes = (first.len() - "Tax Salary Report.pdf'\n".len() + "later line\n".len()) as u64;
|
||||
let part = Part { path: log, bytes: bytes + "Tax Salary Report.pdf'\n".len() as u64 - 10 };
|
||||
let text = bundle(AppId::Doc, &[part], Some(&Redact::default())).unwrap();
|
||||
assert!(!text.contains("Report") && text.contains("later line"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_log_sends_only_its_newest_part() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let log = dir.path().join("ely.log");
|
||||
std::fs::write(&log, "old line\nnew line\n").unwrap();
|
||||
let text = bundle(AppId::Doc, &[Part { path: log, bytes: 9 }], None).unwrap();
|
||||
assert!(text.ends_with("new line\n") && !text.contains("old line"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_names_stop_the_redaction() {
|
||||
let broken = || Err(std::io::Error::other("settings: author cannot be 3"));
|
||||
assert!(Redact::of(Ok("Ana".into()), broken()).is_err(), "no names, no export");
|
||||
assert!(Redact::of(Err(std::io::Error::other("no account")), Ok(Default::default())).is_err());
|
||||
let names = Redact::of(Ok("Ana".into()), Ok(Default::default())).unwrap().names;
|
||||
assert!(names.contains(&"Ana".to_string()));
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use directories::BaseDirs;
|
||||
|
||||
use crate::AppId;
|
||||
|
||||
/// Everything an app stores lives here; uninstall removes only this.
|
||||
pub fn data_dir(app: AppId) -> Result<PathBuf> {
|
||||
Ok(root()?.join(app.slug()))
|
||||
}
|
||||
|
||||
/// The suite's folder: each app's, and the shared settings.
|
||||
pub(crate) fn root() -> Result<PathBuf> {
|
||||
let base = BaseDirs::new().context("no home directory")?;
|
||||
Ok(base.data_local_dir().join("ElyOffice"))
|
||||
}
|
||||
|
||||
/// Omarchy's active theme directory; `ELY_OMARCHY_DIR` overrides it.
|
||||
pub fn omarchy_current_dir() -> Option<PathBuf> {
|
||||
let home = BaseDirs::new()?.home_dir().to_path_buf();
|
||||
omarchy_dir(&home, crate::env::value("ELY_OMARCHY_DIR"))
|
||||
}
|
||||
|
||||
/// Omarchy keeps its state under `~/.local/state`, whatever `XDG_STATE_HOME` says.
|
||||
fn omarchy_dir(home: &Path, over: Option<String>) -> Option<PathBuf> {
|
||||
if let Some(dir) = over {
|
||||
return Some(PathBuf::from(dir));
|
||||
}
|
||||
let current = home.join(".local/state/omarchy/current");
|
||||
current.is_dir().then_some(current)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{data_dir, omarchy_dir};
|
||||
use crate::AppId;
|
||||
|
||||
#[test]
|
||||
fn each_app_has_its_own_directory_under_elyoffice() {
|
||||
let doc = data_dir(AppId::Doc).unwrap();
|
||||
let mail = data_dir(AppId::Mail).unwrap();
|
||||
|
||||
assert!(doc.ends_with("ElyOffice/ely-doc"), "{}", doc.display());
|
||||
assert!(mail.ends_with("ElyOffice/ely-mail"), "{}", mail.display());
|
||||
assert_ne!(doc, mail);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omarchy_is_found_under_home_and_the_override_wins() {
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
assert_eq!(omarchy_dir(home.path(), None), None);
|
||||
|
||||
let current = home.path().join(".local/state/omarchy/current");
|
||||
std::fs::create_dir_all(¤t).unwrap();
|
||||
assert_eq!(omarchy_dir(home.path(), None), Some(current));
|
||||
assert_eq!(
|
||||
omarchy_dir(home.path(), Some("/elsewhere".into())),
|
||||
Some("/elsewhere".into())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/// An environment variable's text; none when unset.
|
||||
pub fn value(name: &str) -> Option<String> {
|
||||
std::env::var_os(name).map(|value| value.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// A 0 or 1 switch; anything else warns.
|
||||
pub fn flag(name: &str) -> Option<bool> {
|
||||
flag_from(name, value(name).as_deref())
|
||||
}
|
||||
|
||||
fn flag_from(name: &str, value: Option<&str>) -> Option<bool> {
|
||||
match value {
|
||||
None => None,
|
||||
Some("1") => Some(true),
|
||||
Some("0") => Some(false),
|
||||
Some(other) => {
|
||||
log::warn!("env: {name}={other} is not 0 or 1, ignored");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::flag_from;
|
||||
|
||||
#[test]
|
||||
fn only_zero_and_one_are_answers() {
|
||||
assert_eq!(flag_from("X", Some("1")), Some(true));
|
||||
assert_eq!(flag_from("X", Some("0")), Some(false));
|
||||
assert_eq!(flag_from("X", None), None);
|
||||
for other in ["", "true", "false", "off", "yes", " 1", "01"] {
|
||||
assert_eq!(flag_from("X", Some(other)), None, "{other:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
//! Ely's Git: where it is and how it runs (§13.1).
|
||||
|
||||
use std::{
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
};
|
||||
|
||||
use crate::AppId;
|
||||
|
||||
/// ElyTree's setting naming a system Git; empty is Ely's own.
|
||||
pub const PROGRAM: &str = "git";
|
||||
|
||||
/// The oldest system Git counted as verified (user decision).
|
||||
pub const LEAST: (u32, u32) = (2, 40);
|
||||
|
||||
/// `ELY_GIT`, ElyTree's setting, else Ely's own Git.
|
||||
pub fn locate() -> io::Result<(PathBuf, bool)> {
|
||||
if let Some(path) = std::env::var_os("ELY_GIT") {
|
||||
return Ok((PathBuf::from(path), false));
|
||||
}
|
||||
let set = crate::settings::Store::app(AppId::Tree)?.read()?;
|
||||
match set.get(PROGRAM) {
|
||||
None => {}
|
||||
Some(serde_json::Value::String(path)) if path.is_empty() => {}
|
||||
Some(serde_json::Value::String(path)) => return Ok((PathBuf::from(path), false)),
|
||||
Some(other) => return Err(io::Error::new(io::ErrorKind::InvalidData, format!("settings: git cannot be {other}"))),
|
||||
}
|
||||
let exe = std::env::current_exe()?;
|
||||
Ok((own(exe.parent().unwrap_or(Path::new("."))), true))
|
||||
}
|
||||
|
||||
/// `git version 2.56.0…` as (2, 56); None for anything else.
|
||||
pub fn parse_version(text: &str) -> Option<(u32, u32)> {
|
||||
let number = text.trim().strip_prefix("git version ")?;
|
||||
let mut parts = number.split(|c: char| !c.is_ascii_digit());
|
||||
Some((parts.next()?.parse().ok()?, parts.next()?.parse().ok()?))
|
||||
}
|
||||
|
||||
/// What `git --version` says, once it succeeds.
|
||||
pub fn version_of(path: &Path) -> io::Result<(String, (u32, u32))> {
|
||||
let output = Command::new(path).arg("--version").output()?;
|
||||
if !output.status.success() {
|
||||
let said = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
return Err(io::Error::other(format!("{} --version failed ({}): {said}", path.display(), output.status)));
|
||||
}
|
||||
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let number = parse_version(&version).ok_or_else(|| io::Error::other(format!("{} is no Git: {version:?}", path.display())))?;
|
||||
Ok((version, number))
|
||||
}
|
||||
|
||||
/// A system Git's version, if 2.40 or later.
|
||||
pub fn system_version(path: &Path) -> io::Result<String> {
|
||||
let (version, number) = version_of(path)?;
|
||||
if number < LEAST {
|
||||
let message = format!("{version} at {} is older than {}.{}", path.display(), LEAST.0, LEAST.1);
|
||||
return Err(io::Error::new(io::ErrorKind::Unsupported, message));
|
||||
}
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
/// Beside `dir`, in its bundle, in ElyTree's, or built.
|
||||
fn own(dir: &Path) -> PathBuf {
|
||||
// ElyTree carries Git; the other apps use its copy.
|
||||
let tree = format!("../../../{}.app/Contents/Resources/git/bin/git", AppId::Tree.name());
|
||||
let built = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../engines/git/bin/git");
|
||||
[
|
||||
dir.join("git/bin/git"),
|
||||
dir.join("../Resources/git/bin/git"),
|
||||
dir.join(tree),
|
||||
]
|
||||
.into_iter()
|
||||
.find(|path| path.is_file())
|
||||
.unwrap_or(built)
|
||||
}
|
||||
|
||||
/// Git at `git` in `dir`, as Ely runs it.
|
||||
pub fn command(git: &Path, dir: &Path, args: &[&str]) -> Command {
|
||||
let mut command = Command::new(git);
|
||||
command
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
// Reads never take the index lock from an outside Git.
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
// A path is a path, never a pattern.
|
||||
.env("GIT_LITERAL_PATHSPECS", "1")
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
// No terminal for an editor; Git's own message stands.
|
||||
.env("GIT_EDITOR", "true")
|
||||
.env("GIT_SEQUENCE_EDITOR", "true");
|
||||
command
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_failing_version_query_is_no_version() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let fake = dir.path().join("git");
|
||||
std::fs::write(&fake, "#!/bin/sh\necho 'git version 2.56.0'\necho 'fatal: version query failed' >&2\nexit 7\n").unwrap();
|
||||
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let error = super::system_version(&fake).expect_err("a failed run");
|
||||
assert!(error.to_string().contains("version query failed"), "{error}");
|
||||
assert!(super::version_of(&fake).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versions_read_as_major_and_minor() {
|
||||
assert_eq!(super::parse_version("git version 2.56.0\n"), Some((2, 56)));
|
||||
assert_eq!(super::parse_version("git version 2.54.0 (Apple Git-157)"), Some((2, 54)));
|
||||
assert_eq!(super::parse_version("hello"), None);
|
||||
}
|
||||
|
||||
use super::own;
|
||||
|
||||
#[test]
|
||||
fn apps_beside_elytree_use_its_git() {
|
||||
let apps = tempfile::tempdir().expect("a temp dir");
|
||||
let mark = apps.path().join("ElyMark.app/Contents/MacOS");
|
||||
let git = apps.path().join("ElyTree.app/Contents/Resources/git/bin");
|
||||
std::fs::create_dir_all(&mark).expect("ElyMark");
|
||||
assert!(own(&mark).ends_with("engines/git/bin/git"));
|
||||
std::fs::create_dir_all(&git).expect("ElyTree's Git");
|
||||
std::fs::write(git.join("git"), "").expect("git");
|
||||
assert_eq!(own(&mark), mark.join("../../../ElyTree.app/Contents/Resources/git/bin/git"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
use anyhow::Result;
|
||||
|
||||
/// Fails when no graphics device exists; probed on macOS only.
|
||||
pub fn check() -> Result<()> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let devices = metal::Device::all();
|
||||
anyhow::ensure!(
|
||||
!devices.is_empty() || metal::Device::system_default().is_some(),
|
||||
"no Metal graphics device was found"
|
||||
);
|
||||
log::info!("graphics: {} Metal device(s)", devices.len());
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
log::info!("graphics: the backend is not probed on this OS");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "macos"))]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn this_mac_has_a_metal_device() {
|
||||
super::check().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
pub mod accessibility;
|
||||
mod app_id;
|
||||
pub mod apps;
|
||||
pub mod clipboard;
|
||||
pub mod diagnostics;
|
||||
mod dirs;
|
||||
pub mod env;
|
||||
pub mod git;
|
||||
pub mod graphics;
|
||||
pub mod locale;
|
||||
pub mod lock;
|
||||
pub mod logging;
|
||||
pub mod media;
|
||||
pub mod print;
|
||||
pub mod settings;
|
||||
pub mod startup;
|
||||
pub mod storage;
|
||||
pub mod trust;
|
||||
pub mod update;
|
||||
pub mod user;
|
||||
|
||||
pub use app_id::AppId;
|
||||
pub use dirs::{data_dir, omarchy_current_dir};
|
||||
@@ -0,0 +1,116 @@
|
||||
use crate::env;
|
||||
|
||||
/// The locale tag: `ELY_LOCALE`, else the OS one.
|
||||
pub fn system() -> String {
|
||||
if let Some(tag) = env::value("ELY_LOCALE") {
|
||||
return tag;
|
||||
}
|
||||
platform()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn platform() -> String {
|
||||
use objc2_foundation::NSLocale;
|
||||
if let Some(tag) = NSLocale::preferredLanguages()
|
||||
.firstObject()
|
||||
.map(|tag| tag.to_string())
|
||||
&& !tag.is_empty()
|
||||
{
|
||||
return tag;
|
||||
}
|
||||
NSLocale::currentLocale()
|
||||
.localeIdentifier()
|
||||
.to_string()
|
||||
.replace('_', "-")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn platform() -> String {
|
||||
resolve_env(|key| env::value(key))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn platform() -> String {
|
||||
use windows_sys::Win32::Globalization::GetUserDefaultLocaleName;
|
||||
let mut buffer = [0u16; 85];
|
||||
let length = unsafe { GetUserDefaultLocaleName(buffer.as_mut_ptr(), buffer.len() as i32) };
|
||||
if length == 0 {
|
||||
log::warn!("locale: GetUserDefaultLocaleName failed");
|
||||
return "en".into();
|
||||
}
|
||||
String::from_utf16_lossy(&buffer[..length as usize - 1])
|
||||
}
|
||||
|
||||
/// First non-empty variable wins; C/POSIX ends as en.
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
fn resolve_env(get: impl Fn(&str) -> Option<String>) -> String {
|
||||
for key in ["LC_ALL", "LC_MESSAGES", "LANG"] {
|
||||
let Some(value) = get(key) else { continue };
|
||||
if value.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let tag = value
|
||||
.split(['.', '@'])
|
||||
.next()
|
||||
.unwrap_or(&value)
|
||||
.replace('_', "-");
|
||||
return match tag.as_str() {
|
||||
"C" | "POSIX" => "en".into(),
|
||||
_ => tag,
|
||||
};
|
||||
}
|
||||
"en".into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::resolve_env;
|
||||
|
||||
fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
|
||||
let map: HashMap<String, String> = pairs
|
||||
.iter()
|
||||
.map(|(key, value)| (key.to_string(), value.to_string()))
|
||||
.collect();
|
||||
move |key| map.get(key).cloned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_non_empty_variable_wins() {
|
||||
assert_eq!(resolve_env(env(&[("LANG", "zh_CN.UTF-8")])), "zh-CN");
|
||||
assert_eq!(
|
||||
resolve_env(env(&[
|
||||
("LC_MESSAGES", "fr_FR.UTF-8"),
|
||||
("LANG", "zh_CN.UTF-8")
|
||||
])),
|
||||
"fr-FR"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_env(env(&[("LC_ALL", ""), ("LANG", "ja_JP.UTF-8")])),
|
||||
"ja-JP"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c_and_posix_resolve_to_en_and_stop() {
|
||||
assert_eq!(
|
||||
resolve_env(env(&[("LC_ALL", "C.UTF-8"), ("LANG", "zh_CN.UTF-8")])),
|
||||
"en"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_env(env(&[("LC_MESSAGES", "POSIX"), ("LANG", "zh_CN.UTF-8")])),
|
||||
"en"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_env(env(&[("LC_ALL", "C"), ("LC_MESSAGES", "zh_CN.UTF-8")])),
|
||||
"en"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_set_resolves_to_en() {
|
||||
assert_eq!(resolve_env(env(&[])), "en");
|
||||
assert_eq!(resolve_env(env(&[("LANG", "")])), "en");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//! An OS-level exclusive file lock, released with its holder.
|
||||
|
||||
use std::{fs::File, io, path::Path};
|
||||
|
||||
pub struct FileLock(File);
|
||||
|
||||
impl FileLock {
|
||||
/// The lock, or None while another process holds it.
|
||||
pub fn try_take(path: &Path) -> io::Result<Option<Self>> {
|
||||
let file = open(path)?;
|
||||
match lock(&file, false) {
|
||||
Ok(()) => Ok(Some(Self(file))),
|
||||
Err(error) if busy(&error) => Ok(None),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// The lock, once another holder lets go.
|
||||
pub fn take(path: &Path) -> io::Result<Self> {
|
||||
let file = open(path)?;
|
||||
lock(&file, true)?;
|
||||
Ok(Self(file))
|
||||
}
|
||||
}
|
||||
|
||||
fn open(path: &Path) -> io::Result<File> {
|
||||
std::fs::OpenOptions::new().write(true).create(true).truncate(false).open(path)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn lock(file: &File, wait: bool) -> io::Result<()> {
|
||||
use std::os::unix::io::AsRawFd;
|
||||
let how = if wait { libc::LOCK_EX } else { libc::LOCK_EX | libc::LOCK_NB };
|
||||
match unsafe { libc::flock(file.as_raw_fd(), how) } {
|
||||
0 => Ok(()),
|
||||
_ => Err(io::Error::last_os_error()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn busy(error: &io::Error) -> bool {
|
||||
error.kind() == io::ErrorKind::WouldBlock
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
impl Drop for FileLock {
|
||||
fn drop(&mut self) {
|
||||
use std::os::unix::io::AsRawFd;
|
||||
unsafe { libc::flock(self.0.as_raw_fd(), libc::LOCK_UN) };
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn lock(file: &File, wait: bool) -> io::Result<()> {
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
use windows_sys::Win32::Storage::FileSystem::{LOCKFILE_EXCLUSIVE_LOCK, LOCKFILE_FAIL_IMMEDIATELY, LockFileEx};
|
||||
let flags = if wait { LOCKFILE_EXCLUSIVE_LOCK } else { LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY };
|
||||
let mut overlapped = unsafe { std::mem::zeroed() };
|
||||
match unsafe { LockFileEx(file.as_raw_handle(), flags, 0, u32::MAX, u32::MAX, &mut overlapped) } {
|
||||
0 => Err(io::Error::last_os_error()),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn busy(error: &io::Error) -> bool {
|
||||
const ERROR_LOCK_VIOLATION: i32 = 33;
|
||||
error.raw_os_error() == Some(ERROR_LOCK_VIOLATION)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
impl Drop for FileLock {
|
||||
fn drop(&mut self) {
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
use windows_sys::Win32::Storage::FileSystem::UnlockFileEx;
|
||||
let mut overlapped = unsafe { std::mem::zeroed() };
|
||||
unsafe { UnlockFileEx(self.0.as_raw_handle(), 0, u32::MAX, u32::MAX, &mut overlapped) };
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::FileLock;
|
||||
|
||||
#[test]
|
||||
fn a_held_lock_refuses_a_second_try_until_released() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("held.lock");
|
||||
let held = FileLock::take(&path).unwrap();
|
||||
assert!(FileLock::try_take(&path).unwrap().is_none(), "flock is per open file");
|
||||
drop(held);
|
||||
assert!(FileLock::try_take(&path).unwrap().is_some());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
use std::{
|
||||
fs::{self, File, OpenOptions},
|
||||
io::{self, Write},
|
||||
panic,
|
||||
path::{Path, PathBuf},
|
||||
sync::OnceLock,
|
||||
};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use env_logger::{Builder, Env, Target};
|
||||
|
||||
use crate::{AppId, data_dir};
|
||||
|
||||
static LOG_PATH: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
struct Tee(File);
|
||||
|
||||
impl Write for Tee {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
self.0.write_all(buf)?;
|
||||
io::stderr().write_all(buf)?;
|
||||
Ok(buf.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
self.0.flush()?;
|
||||
io::stderr().flush()
|
||||
}
|
||||
}
|
||||
|
||||
/// The file logging writes to; none until `init` succeeds.
|
||||
pub fn path() -> Option<&'static Path> {
|
||||
LOG_PATH.get().map(PathBuf::as_path)
|
||||
}
|
||||
|
||||
/// Past this a log keeps only its newer half (§18.3).
|
||||
const LOG_CAP: u64 = 8 * 1024 * 1024;
|
||||
|
||||
/// Keeps a long log's newer half, from a whole line.
|
||||
fn trimmed(path: &Path, cap: u64) -> std::io::Result<()> {
|
||||
let length = match fs::metadata(path) {
|
||||
Ok(meta) => meta.len(),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if length <= cap {
|
||||
return Ok(());
|
||||
}
|
||||
let bytes = fs::read(path)?;
|
||||
let tail = &bytes[(length - cap / 2) as usize..];
|
||||
let whole = tail.iter().position(|b| *b == b'\n').map_or(tail.len(), |n| n + 1);
|
||||
fs::write(path, &tail[whole..])
|
||||
}
|
||||
|
||||
/// Logs to stderr and a file. `ELY_LOG` sets level.
|
||||
pub fn init(app: AppId) -> Result<()> {
|
||||
init_in(app, &data_dir(app)?)
|
||||
}
|
||||
|
||||
pub fn init_in(app: AppId, dir: &Path) -> Result<()> {
|
||||
fs::create_dir_all(dir).with_context(|| format!("cannot create {}", dir.display()))?;
|
||||
let path = dir.join("ely.log");
|
||||
trimmed(&path, LOG_CAP).with_context(|| format!("cannot trim {}", path.display()))?;
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.with_context(|| format!("cannot open {}", path.display()))?;
|
||||
Builder::from_env(Env::new().filter_or("ELY_LOG", "info"))
|
||||
.target(Target::Pipe(Box::new(Tee(file))))
|
||||
.try_init()
|
||||
.context("a logger is already set")?;
|
||||
LOG_PATH.set(path).expect("the logger was set once");
|
||||
|
||||
let previous = panic::take_hook();
|
||||
panic::set_hook(Box::new(move |info| {
|
||||
log::error!("panic: {info}");
|
||||
previous(info);
|
||||
}));
|
||||
log::info!("{} {} started", app.name(), env!("CARGO_PKG_VERSION"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod trim_tests {
|
||||
#[test]
|
||||
fn a_long_log_keeps_its_newer_half_from_a_line() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("ely.log");
|
||||
let lines: String = (0..200).map(|n| format!("line {n:03}\n")).collect();
|
||||
std::fs::write(&path, &lines).unwrap();
|
||||
super::trimmed(&path, 1000).unwrap();
|
||||
let kept = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(kept.len() <= 500 && kept.starts_with("line ") && kept.ends_with("line 199\n"), "{kept}");
|
||||
super::trimmed(&path, 1000).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), kept, "a short log stays");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Plays local audio and video with the system's player (SHOW-10).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos;
|
||||
|
||||
/// What the player knows of its file.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum State {
|
||||
Loading,
|
||||
/// Seconds long, and the picture size of a video.
|
||||
Ready {
|
||||
seconds: f64,
|
||||
video: Option<(u32, u32)>,
|
||||
},
|
||||
/// The system cannot play it; the reason is its own.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// A picture of the playing video.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub type Frame = core_video::pixel_buffer::CVPixelBuffer;
|
||||
/// No platform here makes frames.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub enum Frame {}
|
||||
|
||||
/// One file in the system player, paused at first.
|
||||
pub struct Player {
|
||||
/// Paused by a person, not at its end.
|
||||
held: std::cell::Cell<bool>,
|
||||
#[cfg(target_os = "macos")]
|
||||
inner: macos::Player,
|
||||
// Nothing opens a player where none is built.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
inner: std::convert::Infallible,
|
||||
}
|
||||
|
||||
impl Player {
|
||||
/// Opens `path`; a file that is not there fails here.
|
||||
pub fn open(path: &Path) -> anyhow::Result<Self> {
|
||||
if !path.is_file() {
|
||||
anyhow::bail!("{} is not there", path.display());
|
||||
}
|
||||
log::info!("media: opens a file");
|
||||
#[cfg(target_os = "macos")]
|
||||
return Ok(Player {
|
||||
held: std::cell::Cell::new(false),
|
||||
inner: macos::Player::open(path)?,
|
||||
});
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
anyhow::bail!("playing {} is not built on this platform", path.display())
|
||||
}
|
||||
|
||||
pub fn state(&self) -> State {
|
||||
self.inner.state()
|
||||
}
|
||||
|
||||
pub fn play(&self) {
|
||||
self.held.set(false);
|
||||
self.inner.play()
|
||||
}
|
||||
|
||||
pub fn pause(&self) {
|
||||
self.held.set(true);
|
||||
self.inner.pause()
|
||||
}
|
||||
|
||||
pub fn playing(&self) -> bool {
|
||||
self.inner.playing()
|
||||
}
|
||||
|
||||
/// Where it is, in seconds.
|
||||
pub fn position(&self) -> f64 {
|
||||
self.inner.position()
|
||||
}
|
||||
|
||||
pub fn seek(&self, seconds: f64) {
|
||||
self.inner.seek(seconds)
|
||||
}
|
||||
|
||||
/// The picture due now, when a new one is.
|
||||
pub fn frame(&self) -> Option<Frame> {
|
||||
self.inner.frame()
|
||||
}
|
||||
|
||||
/// It stopped at its end, not by a pause.
|
||||
pub fn ended(&self) -> bool {
|
||||
matches!(self.state(), State::Ready { seconds, .. }
|
||||
if !self.held.get() && !self.playing() && self.position() >= seconds - 0.05)
|
||||
}
|
||||
|
||||
/// Restarts a loop; true while frames change.
|
||||
pub fn tick(&self, repeat: bool) -> Result<bool, String> {
|
||||
if repeat && self.ended() {
|
||||
self.seek(0.0);
|
||||
self.play();
|
||||
}
|
||||
match self.state() {
|
||||
State::Loading => Ok(true),
|
||||
State::Ready { .. } => Ok(self.playing()),
|
||||
State::Failed(reason) => Err(reason),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What an uninhabited player answers: nothing, ever.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
trait Never {
|
||||
fn state(&self) -> State;
|
||||
fn play(&self);
|
||||
fn pause(&self);
|
||||
fn playing(&self) -> bool;
|
||||
fn position(&self) -> f64;
|
||||
fn seek(&self, seconds: f64);
|
||||
fn frame(&self) -> Option<Frame>;
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
impl Never for std::convert::Infallible {
|
||||
fn state(&self) -> State {
|
||||
match *self {}
|
||||
}
|
||||
fn play(&self) {
|
||||
match *self {}
|
||||
}
|
||||
fn pause(&self) {
|
||||
match *self {}
|
||||
}
|
||||
fn playing(&self) -> bool {
|
||||
match *self {}
|
||||
}
|
||||
fn position(&self) -> f64 {
|
||||
match *self {}
|
||||
}
|
||||
fn seek(&self, _: f64) {
|
||||
match *self {}
|
||||
}
|
||||
fn frame(&self) -> Option<Frame> {
|
||||
match *self {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
use std::path::Path;
|
||||
|
||||
use objc2::{AllocAnyThread, MainThreadMarker, rc::Retained, runtime::AnyObject};
|
||||
use objc2_av_foundation::{
|
||||
AVPlayer, AVPlayerItem, AVPlayerItemOutput, AVPlayerItemStatus, AVPlayerItemVideoOutput,
|
||||
};
|
||||
use objc2_core_media::{CMTime, CMTimeFlags};
|
||||
use objc2_core_video::kCVPixelBufferPixelFormatTypeKey;
|
||||
use objc2_foundation::{NSDictionary, NSNumber, NSString, NSURL};
|
||||
|
||||
use super::State;
|
||||
|
||||
/// Biplanar full-range YCbCr, as GPUI's surfaces draw.
|
||||
const PIXELS: u32 = u32::from_be_bytes(*b"420f");
|
||||
|
||||
pub struct Player {
|
||||
player: Retained<AVPlayer>,
|
||||
item: Retained<AVPlayerItem>,
|
||||
output: Retained<AVPlayerItemVideoOutput>,
|
||||
}
|
||||
|
||||
fn seconds(time: CMTime) -> Option<f64> {
|
||||
(time.flags.contains(CMTimeFlags::Valid) && !time.flags.contains(CMTimeFlags::Indefinite))
|
||||
.then(|| time.value as f64 / time.timescale as f64)
|
||||
}
|
||||
|
||||
impl Player {
|
||||
pub fn open(path: &Path) -> anyhow::Result<Self> {
|
||||
let mtm = MainThreadMarker::new()
|
||||
.ok_or_else(|| anyhow::anyhow!("media plays on the main thread"))?;
|
||||
let url = NSURL::fileURLWithPath(&NSString::from_str(&path.to_string_lossy()));
|
||||
let player = unsafe { AVPlayer::playerWithURL(&url, mtm) };
|
||||
let item = unsafe { player.currentItem() }
|
||||
.ok_or_else(|| anyhow::anyhow!("no player item for {}", path.display()))?;
|
||||
// The key is a CFString; NSString is its toll-free twin.
|
||||
let key: &NSString =
|
||||
unsafe { &*(kCVPixelBufferPixelFormatTypeKey as *const _ as *const NSString) };
|
||||
let format = NSNumber::new_u32(PIXELS);
|
||||
let value: &AnyObject = &format;
|
||||
let attributes = NSDictionary::<NSString, AnyObject>::from_slices(&[key], &[value]);
|
||||
let output = unsafe {
|
||||
AVPlayerItemVideoOutput::initWithPixelBufferAttributes(
|
||||
AVPlayerItemVideoOutput::alloc(),
|
||||
Some(&attributes),
|
||||
)
|
||||
};
|
||||
unsafe { item.addOutput(&output as &AVPlayerItemOutput) };
|
||||
Ok(Player {
|
||||
player,
|
||||
item,
|
||||
output,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn state(&self) -> State {
|
||||
match unsafe { self.item.status() } {
|
||||
AVPlayerItemStatus::ReadyToPlay => {
|
||||
let size = unsafe { self.item.presentationSize() };
|
||||
let video = (size.width > 0.0 && size.height > 0.0)
|
||||
.then(|| (size.width.round() as u32, size.height.round() as u32));
|
||||
State::Ready {
|
||||
seconds: seconds(unsafe { self.item.duration() }).unwrap_or(0.0),
|
||||
video,
|
||||
}
|
||||
}
|
||||
AVPlayerItemStatus::Failed => {
|
||||
let reason = unsafe { self.item.error() }
|
||||
.map(|error| error.localizedDescription().to_string())
|
||||
.unwrap_or_else(|| "the system player gave no reason".into());
|
||||
State::Failed(reason)
|
||||
}
|
||||
_ => State::Loading,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn play(&self) {
|
||||
unsafe { self.player.play() }
|
||||
}
|
||||
|
||||
pub fn pause(&self) {
|
||||
unsafe { self.player.pause() }
|
||||
}
|
||||
|
||||
pub fn playing(&self) -> bool {
|
||||
(unsafe { self.player.rate() }) > 0.0
|
||||
}
|
||||
|
||||
pub fn position(&self) -> f64 {
|
||||
seconds(unsafe { self.player.currentTime() }).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
pub fn seek(&self, to: f64) {
|
||||
let time = CMTime {
|
||||
value: (to * 600.0).round() as i64,
|
||||
timescale: 600,
|
||||
flags: CMTimeFlags::Valid,
|
||||
epoch: 0,
|
||||
};
|
||||
let exact = CMTime {
|
||||
value: 0,
|
||||
timescale: 1,
|
||||
flags: CMTimeFlags::Valid,
|
||||
epoch: 0,
|
||||
};
|
||||
unsafe {
|
||||
self.player
|
||||
.seekToTime_toleranceBefore_toleranceAfter(time, exact, exact)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn frame(&self) -> Option<super::Frame> {
|
||||
let now = unsafe { self.item.currentTime() };
|
||||
if !unsafe { self.output.hasNewPixelBufferForItemTime(now) } {
|
||||
return None;
|
||||
}
|
||||
let buffer = unsafe {
|
||||
self.output
|
||||
.copyPixelBufferForItemTime_itemTimeForDisplay(now, std::ptr::null_mut())
|
||||
}?;
|
||||
let raw = Retained::into_raw(buffer);
|
||||
// The copy hands its one reference over.
|
||||
Some(unsafe {
|
||||
<core_video::pixel_buffer::CVPixelBuffer as core_foundation::base::TCFType>::wrap_under_create_rule(raw.cast())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//! Prints a PDF through the system print panel.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Shows the print panel for `pdf`; false when cancelled.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn print_pdf(pdf: &Path) -> anyhow::Result<bool> {
|
||||
use objc2::{AllocAnyThread, MainThreadMarker};
|
||||
use objc2_app_kit::{NSPaperOrientation, NSPrintInfo, NSPrintJobSavingURL, NSPrintSaveJob};
|
||||
use objc2_foundation::{NSCopying, NSSize, NSString, NSURL};
|
||||
use objc2_pdf_kit::{PDFDisplayBox, PDFDocument, PDFPrintScalingMode};
|
||||
|
||||
let mtm =
|
||||
MainThreadMarker::new().ok_or_else(|| anyhow::anyhow!("printing needs the main thread"))?;
|
||||
let url = NSURL::fileURLWithPath(&NSString::from_str(&pdf.to_string_lossy()));
|
||||
let document = unsafe { PDFDocument::initWithURL(PDFDocument::alloc(), &url) }
|
||||
.ok_or_else(|| anyhow::anyhow!("{} is no readable PDF", pdf.display()))?;
|
||||
let sizes: Vec<(f64, f64)> = (0..unsafe { document.pageCount() })
|
||||
.filter_map(|n| unsafe { document.pageAtIndex(n) })
|
||||
.map(|page| {
|
||||
let size = unsafe { page.boundsForBox(PDFDisplayBox::MediaBox) }.size;
|
||||
// A page turned a quarter shows its other side up.
|
||||
match unsafe { page.rotation() }.rem_euclid(180) == 90 {
|
||||
true => (size.height, size.width),
|
||||
false => (size.width, size.height),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let (width, height) = paper_for(&sizes).ok_or_else(|| anyhow::anyhow!("{} has no pages", pdf.display()))?;
|
||||
let paper = NSSize::new(width, height);
|
||||
if let Some(scale) = crate::env::value("ELY_PRINT_SHARED_SCALE") {
|
||||
// Tests start from a shared scale the copy must undo.
|
||||
NSPrintInfo::sharedPrintInfo().setScalingFactor(scale.parse()?);
|
||||
}
|
||||
// The PDF already holds the document's paper, margins and scale.
|
||||
let info = NSPrintInfo::sharedPrintInfo().copy();
|
||||
info.setScalingFactor(1.0);
|
||||
info.setOrientation(match paper.width > paper.height {
|
||||
true => NSPaperOrientation::Landscape,
|
||||
false => NSPaperOrientation::Portrait,
|
||||
});
|
||||
info.setPaperSize(paper);
|
||||
info.setTopMargin(0.0);
|
||||
info.setBottomMargin(0.0);
|
||||
info.setLeftMargin(0.0);
|
||||
info.setRightMargin(0.0);
|
||||
log::info!("print: paper {}x{} points", paper.width, paper.height);
|
||||
let operation = unsafe {
|
||||
document.printOperationForPrintInfo_scalingMode_autoRotate(
|
||||
Some(&info),
|
||||
PDFPrintScalingMode::PageScaleNone,
|
||||
true,
|
||||
mtm,
|
||||
)
|
||||
}
|
||||
.ok_or_else(|| anyhow::anyhow!("no print operation for {}", pdf.display()))?;
|
||||
// Tests read the real job: saved to a file.
|
||||
let saved = crate::env::value("ELY_PRINT_TO");
|
||||
if let Some(to) = &saved {
|
||||
let info = operation.printInfo();
|
||||
info.setJobDisposition(unsafe { NSPrintSaveJob });
|
||||
let to = NSURL::fileURLWithPath(&NSString::from_str(to));
|
||||
unsafe { info.dictionary().insert(NSPrintJobSavingURL, &to) };
|
||||
}
|
||||
operation.setShowsPrintPanel(saved.is_none());
|
||||
operation.setShowsProgressPanel(saved.is_none());
|
||||
let printed = operation.runOperation();
|
||||
log::info!("print: the panel closed, printed {printed}");
|
||||
Ok(printed)
|
||||
}
|
||||
|
||||
/// One paper holding every page, turned as needed.
|
||||
fn paper_for(sizes: &[(f64, f64)]) -> Option<(f64, f64)> {
|
||||
let long = sizes.iter().map(|(w, h)| w.max(*h)).fold(None, |most: Option<f64>, side| Some(most.map_or(side, |m| m.max(side))))?;
|
||||
let short = sizes.iter().map(|(w, h)| w.min(*h)).fold(0.0, f64::max);
|
||||
// The largest page sets the paper's way up.
|
||||
let (w, h) = sizes.iter().copied().fold((0.0, 0.0), |big, size| if size.0 * size.1 > big.0 * big.1 { size } else { big });
|
||||
// A square page leaves it to the others.
|
||||
let wide = match w == h {
|
||||
true => sizes.iter().any(|(w, h)| w > h),
|
||||
false => w > h,
|
||||
};
|
||||
Some(if wide { (long, short) } else { (short, long) })
|
||||
}
|
||||
|
||||
/// Printing needs a system panel this platform build lacks.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn print_pdf(pdf: &Path) -> anyhow::Result<bool> {
|
||||
anyhow::bail!("printing {} is not built on this platform", pdf.display())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::paper_for;
|
||||
|
||||
#[test]
|
||||
fn the_paper_holds_every_page() {
|
||||
assert_eq!(paper_for(&[(595.0, 420.0)]), Some((595.0, 420.0)));
|
||||
assert_eq!(paper_for(&[(420.0, 595.0), (842.0, 1191.0)]), Some((842.0, 1191.0)));
|
||||
// Wide and square pages: neither side may be cut.
|
||||
assert_eq!(paper_for(&[(1000.0, 200.0), (600.0, 600.0)]), Some((1000.0, 600.0)));
|
||||
assert_eq!(paper_for(&[]), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! Versioned settings files, shared and per app (§18.1).
|
||||
|
||||
use std::{
|
||||
io::{self, Write as _},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::{AppId, lock::FileLock};
|
||||
|
||||
mod shared;
|
||||
pub use shared::{APPEARANCE, AUTHOR, Appearance, LANGUAGE, Language, MOTION, Motion, OMARCHY_SYNC, Shared, word};
|
||||
|
||||
/// The layout this build reads and writes.
|
||||
pub const VERSION: u64 = 1;
|
||||
|
||||
/// One settings file; writes keep unknown fields.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Store {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
/// Theme, language and motion, read by every app.
|
||||
pub fn shared() -> io::Result<Self> {
|
||||
let root = crate::dirs::root().map_err(io::Error::other)?;
|
||||
Ok(Self::at(root.join("settings.json")))
|
||||
}
|
||||
|
||||
/// One app's own preferences.
|
||||
pub fn app(app: AppId) -> io::Result<Self> {
|
||||
Ok(Self::at(crate::data_dir(app).map_err(io::Error::other)?.join("settings.json")))
|
||||
}
|
||||
|
||||
pub fn at(path: PathBuf) -> Self {
|
||||
Self { path }
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// The stored fields; none when the file is absent.
|
||||
pub fn read(&self) -> io::Result<Map<String, Value>> {
|
||||
let bytes = match std::fs::read(&self.path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Map::new()),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let invalid = |why: String| io::Error::new(io::ErrorKind::InvalidData, format!("{}: {why}", self.path.display()));
|
||||
let Value::Object(fields) = serde_json::from_slice(&bytes).map_err(|error| invalid(error.to_string()))? else {
|
||||
return Err(invalid("not a JSON object".into()));
|
||||
};
|
||||
match fields.get("version") {
|
||||
None => Ok(fields),
|
||||
Some(Value::Number(n)) if n.as_u64().is_some_and(|n| n <= VERSION) => Ok(fields),
|
||||
Some(Value::Number(n)) => Err(invalid(format!("version {n} is newer than this Ely reads"))),
|
||||
Some(_) => Err(invalid("the version is not a number".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets `key` under the lock; other fields stay.
|
||||
pub fn set(&self, key: &str, value: Value) -> io::Result<()> {
|
||||
self.set_if(key, value, || true).map(|_| ())
|
||||
}
|
||||
|
||||
/// Sets `key` if `still` holds once locked.
|
||||
pub fn set_if(&self, key: &str, value: Value, still: impl FnOnce() -> bool) -> io::Result<bool> {
|
||||
let dir = self.path.parent().ok_or_else(|| io::Error::other("settings have no folder"))?;
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let _held = FileLock::take(&dir.join(".settings.lock"))?;
|
||||
// A newer choice made while we waited wins.
|
||||
if !still() {
|
||||
log::info!("settings: {key} overtaken while waiting");
|
||||
return Ok(false);
|
||||
}
|
||||
// Read under the lock: another app may just have written.
|
||||
let mut fields = self.read()?;
|
||||
fields.insert(key.into(), value);
|
||||
fields.insert("version".into(), VERSION.into());
|
||||
let mut temp = tempfile::NamedTempFile::new_in(dir)?;
|
||||
temp.write_all(&serde_json::to_vec_pretty(&Value::Object(fields))?)?;
|
||||
temp.as_file().sync_all()?;
|
||||
temp.persist(&self.path).map_err(|error| error.error)?;
|
||||
log::info!("settings: {key} saved in {}", self.path.display());
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,122 @@
|
||||
//! The fields every app reads from the shared file.
|
||||
|
||||
use std::io;
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use super::Store;
|
||||
|
||||
/// Light, dark, or as the system is.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Appearance {
|
||||
#[default]
|
||||
System,
|
||||
Light,
|
||||
Dark,
|
||||
}
|
||||
|
||||
/// The interface language; System follows the OS.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Language {
|
||||
#[default]
|
||||
System,
|
||||
English,
|
||||
Chinese,
|
||||
}
|
||||
|
||||
/// Interface motion; System follows the OS setting.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Motion {
|
||||
#[default]
|
||||
System,
|
||||
Reduced,
|
||||
Full,
|
||||
}
|
||||
|
||||
pub const APPEARANCE: &str = "appearance";
|
||||
pub const LANGUAGE: &str = "language";
|
||||
pub const MOTION: &str = "motion";
|
||||
pub const OMARCHY_SYNC: &str = "omarchy_sync";
|
||||
pub const AUTHOR: &str = "author";
|
||||
|
||||
/// The shared settings; absent fields take their defaults.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Shared {
|
||||
pub appearance: Appearance,
|
||||
pub language: Language,
|
||||
pub motion: Motion,
|
||||
pub omarchy_sync: bool,
|
||||
/// Signs comments and changes; empty means the account name.
|
||||
pub author: String,
|
||||
}
|
||||
|
||||
impl Default for Shared {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
appearance: Appearance::System,
|
||||
language: Language::System,
|
||||
motion: Motion::System,
|
||||
omarchy_sync: true,
|
||||
author: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Appearance {
|
||||
pub const ALL: [(Self, &'static str); 3] = [(Self::System, "system"), (Self::Light, "light"), (Self::Dark, "dark")];
|
||||
}
|
||||
|
||||
impl Language {
|
||||
pub const ALL: [(Self, &'static str); 3] = [(Self::System, "system"), (Self::English, "en-US"), (Self::Chinese, "zh-CN")];
|
||||
}
|
||||
|
||||
impl Motion {
|
||||
pub const ALL: [(Self, &'static str); 3] = [(Self::System, "system"), (Self::Reduced, "reduce"), (Self::Full, "full")];
|
||||
}
|
||||
|
||||
/// The stored word for `choice` among `all`.
|
||||
pub fn word<T: PartialEq>(all: &[(T, &'static str)], choice: &T) -> &'static str {
|
||||
all.iter().find(|(each, _)| each == choice).map(|(_, word)| *word).expect("every choice has a word")
|
||||
}
|
||||
|
||||
impl Shared {
|
||||
/// The shared file as read now.
|
||||
pub fn load() -> io::Result<Self> {
|
||||
Self::from_fields(&Store::shared()?.read()?)
|
||||
}
|
||||
|
||||
/// Known fields, typed; a wrong value is an error.
|
||||
pub fn from_fields(fields: &Map<String, Value>) -> io::Result<Self> {
|
||||
let defaults = Self::default();
|
||||
Ok(Self {
|
||||
appearance: pick(fields, APPEARANCE, &Appearance::ALL)?.unwrap_or(defaults.appearance),
|
||||
language: pick(fields, LANGUAGE, &Language::ALL)?.unwrap_or(defaults.language),
|
||||
motion: pick(fields, MOTION, &Motion::ALL)?.unwrap_or(defaults.motion),
|
||||
omarchy_sync: match fields.get(OMARCHY_SYNC) {
|
||||
None => defaults.omarchy_sync,
|
||||
Some(Value::Bool(on)) => *on,
|
||||
Some(other) => return Err(wrong(OMARCHY_SYNC, other)),
|
||||
},
|
||||
author: match fields.get(AUTHOR) {
|
||||
None => defaults.author,
|
||||
Some(Value::String(name)) => name.trim().to_string(),
|
||||
Some(other) => return Err(wrong(AUTHOR, other)),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn pick<T: Copy>(fields: &Map<String, Value>, key: &str, all: &[(T, &'static str)]) -> io::Result<Option<T>> {
|
||||
match fields.get(key) {
|
||||
None => Ok(None),
|
||||
Some(Value::String(word)) => match all.iter().find(|(_, each)| each == word) {
|
||||
Some((choice, _)) => Ok(Some(*choice)),
|
||||
None => Err(wrong(key, &Value::String(word.clone()))),
|
||||
},
|
||||
Some(other) => Err(wrong(key, other)),
|
||||
}
|
||||
}
|
||||
|
||||
fn wrong(key: &str, value: &Value) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, format!("settings: {key} cannot be {value}"))
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
use serde_json::json;
|
||||
|
||||
use super::{Store, VERSION};
|
||||
|
||||
fn store() -> (tempfile::TempDir, Store) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = Store::at(dir.path().join("ElyOffice").join("settings.json"));
|
||||
(dir, store)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_absent_file_is_empty_and_a_write_makes_it_versioned() {
|
||||
let (_dir, store) = store();
|
||||
assert!(store.read().unwrap().is_empty());
|
||||
store.set("appearance", json!("dark")).unwrap();
|
||||
let read = store.read().unwrap();
|
||||
assert_eq!(read["appearance"], "dark");
|
||||
assert_eq!(read["version"], VERSION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_write_keeps_fields_another_app_wrote() {
|
||||
let (_dir, store) = store();
|
||||
std::fs::create_dir_all(store.path().parent().unwrap()).unwrap();
|
||||
std::fs::write(store.path(), r#"{"version":1,"later":{"kept":true},"language":"en"}"#).unwrap();
|
||||
store.set("language", json!("zh-CN")).unwrap();
|
||||
let read = store.read().unwrap();
|
||||
assert_eq!(read["language"], "zh-CN");
|
||||
assert_eq!(read["later"], json!({"kept": true}), "unknown fields stay");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_writers_at_once_lose_nothing() {
|
||||
let (_dir, store) = store();
|
||||
let writers: Vec<_> = (0..8)
|
||||
.map(|n| {
|
||||
let store = store.clone();
|
||||
std::thread::spawn(move || store.set(&format!("field{n}"), json!(n)).unwrap())
|
||||
})
|
||||
.collect();
|
||||
for writer in writers {
|
||||
writer.join().unwrap();
|
||||
}
|
||||
let read = store.read().unwrap();
|
||||
assert!((0..8).all(|n| read[&format!("field{n}")] == n), "{read:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_newer_or_broken_file_is_refused_and_left_alone() {
|
||||
let (_dir, store) = store();
|
||||
std::fs::create_dir_all(store.path().parent().unwrap()).unwrap();
|
||||
for text in [r#"{"version":2}"#, r#"{"version":"1"}"#, "[1]", "{"] {
|
||||
std::fs::write(store.path(), text).unwrap();
|
||||
assert!(store.read().is_err(), "{text} is refused");
|
||||
assert!(store.set("language", json!("en")).is_err(), "{text} is not overwritten");
|
||||
assert_eq!(std::fs::read_to_string(store.path()).unwrap(), text);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_fields_type_strictly_and_default_when_absent() {
|
||||
use super::{Appearance, Language, Motion, Shared};
|
||||
let fields = |text: &str| serde_json::from_str::<serde_json::Map<_, _>>(text).unwrap();
|
||||
assert_eq!(Shared::from_fields(&fields("{}")).unwrap(), Shared::default());
|
||||
let read = Shared::from_fields(&fields(
|
||||
r#"{"appearance":"dark","language":"zh-CN","motion":"reduce","omarchy_sync":false,"author":" Ana "}"#,
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(read.appearance, read.language, read.motion, read.omarchy_sync, read.author.as_str()),
|
||||
(Appearance::Dark, Language::Chinese, Motion::Reduced, false, "Ana")
|
||||
);
|
||||
for bad in [r#"{"appearance":"blue"}"#, r#"{"omarchy_sync":"yes"}"#, r#"{"author":3}"#] {
|
||||
assert!(Shared::from_fields(&fields(bad)).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_write_overtaken_while_waiting_leaves_the_file() {
|
||||
let (_dir, store) = store();
|
||||
store.set("author", json!("Ana")).unwrap();
|
||||
let held = crate::lock::FileLock::take(&store.path().parent().unwrap().join(".settings.lock")).unwrap();
|
||||
let waiting = {
|
||||
let store = store.clone();
|
||||
std::thread::spawn(move || store.set_if("author", json!("Bob"), || false).unwrap())
|
||||
};
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
drop(held);
|
||||
assert!(!waiting.join().unwrap(), "the overtaken write is skipped");
|
||||
assert_eq!(store.read().unwrap()["author"], "Ana");
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
use std::{
|
||||
panic, process,
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
|
||||
use crate::{AppId, logging};
|
||||
|
||||
static STARTED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// The text a failed start shows, in both languages.
|
||||
pub fn message(app: AppId, reason: &str, log: &str) -> String {
|
||||
let name = app.name();
|
||||
format!("{name} 无法启动。\n{name} could not start.\n\n{reason}\n\n日志 / Log: {log}")
|
||||
}
|
||||
|
||||
/// Logs and shows why `app` cannot start, then exits 1.
|
||||
pub fn fail(app: AppId, reason: &str) -> ! {
|
||||
log::error!("{} cannot start: {reason}", app.name());
|
||||
let log = logging::path().map_or_else(
|
||||
|| "none, logging did not start".to_string(),
|
||||
|path| path.display().to_string(),
|
||||
);
|
||||
let text = message(app, reason, &log);
|
||||
eprintln!("{text}");
|
||||
if std::env::var_os("ELY_NO_DIALOG").is_none() {
|
||||
rfd::MessageDialog::new()
|
||||
.set_level(rfd::MessageLevel::Error)
|
||||
.set_title(app.name())
|
||||
.set_description(&text)
|
||||
.show();
|
||||
}
|
||||
process::exit(1)
|
||||
}
|
||||
|
||||
/// Until `started`, a panic is a failed start.
|
||||
pub fn guard(app: AppId) {
|
||||
let previous = panic::take_hook();
|
||||
panic::set_hook(Box::new(move |info| {
|
||||
if STARTED.load(Ordering::Relaxed) {
|
||||
previous(info);
|
||||
} else {
|
||||
fail(
|
||||
app,
|
||||
&format!("the app panicked before its first frame: {info}"),
|
||||
);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// Ely restores sessions itself; AppKit's "reopen windows?" would block.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn no_window_restore() {
|
||||
use objc2_foundation::{NSString, NSUserDefaults};
|
||||
let key = NSString::from_str("ApplePersistenceIgnoreState");
|
||||
NSUserDefaults::standardUserDefaults().setBool_forKey(true, &key);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn no_window_restore() {}
|
||||
|
||||
/// Marks the start as done; later panics are ordinary panics.
|
||||
pub fn started() {
|
||||
STARTED.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::message;
|
||||
use crate::AppId;
|
||||
|
||||
#[test]
|
||||
fn the_message_names_the_app_the_reason_and_the_log_in_both_languages() {
|
||||
let text = message(AppId::Pdf, "no usable GPU", "/logs/ely.log");
|
||||
|
||||
assert!(text.contains("ElyPDF 无法启动"), "{text}");
|
||||
assert!(text.contains("ElyPDF could not start"), "{text}");
|
||||
assert!(text.contains("no usable GPU"), "{text}");
|
||||
assert!(text.contains("/logs/ely.log"), "{text}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//! An app's kept data, and clearing it (§18.3).
|
||||
|
||||
use std::{
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use crate::AppId;
|
||||
|
||||
/// Kept data the user may see and clear.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
Log,
|
||||
/// The web content cache (ElyMark).
|
||||
Cache,
|
||||
Recovery,
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
pub const ALL: [Kind; 3] = [Kind::Log, Kind::Cache, Kind::Recovery];
|
||||
|
||||
fn place(self) -> &'static str {
|
||||
match self {
|
||||
Kind::Log => "ely.log",
|
||||
Kind::Cache => "web",
|
||||
Kind::Recovery => "recovery",
|
||||
}
|
||||
}
|
||||
|
||||
/// In use while running, so cleared at the next start.
|
||||
pub fn at_next_start(self) -> bool {
|
||||
self != Kind::Log
|
||||
}
|
||||
|
||||
fn mark(self) -> String {
|
||||
format!(".clear-{}", self.place())
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes each kind holds now; a missing one holds none.
|
||||
pub fn usage(app: AppId) -> io::Result<Vec<(Kind, u64)>> {
|
||||
let dir = crate::data_dir(app).map_err(io::Error::other)?;
|
||||
Kind::ALL.into_iter().map(|kind| Ok((kind, size(&dir.join(kind.place()))?))).collect()
|
||||
}
|
||||
|
||||
fn size(path: &Path) -> io::Result<u64> {
|
||||
let meta = match std::fs::symlink_metadata(path) {
|
||||
Ok(meta) => meta,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(0),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if !meta.is_dir() {
|
||||
return Ok(meta.len());
|
||||
}
|
||||
std::fs::read_dir(path)?.map(|entry| size(&entry?.path())).sum()
|
||||
}
|
||||
|
||||
/// Clears `kind` now, or marks it for the next start.
|
||||
pub fn clear(app: AppId, kind: Kind) -> io::Result<()> {
|
||||
let dir = crate::data_dir(app).map_err(io::Error::other)?;
|
||||
if kind.at_next_start() {
|
||||
std::fs::write(dir.join(kind.mark()), b"")?;
|
||||
log::info!("storage: {kind:?} clears at the next start");
|
||||
return Ok(());
|
||||
}
|
||||
// The logger appends; emptying keeps its handle valid.
|
||||
std::fs::OpenOptions::new().write(true).open(dir.join(kind.place()))?.set_len(0)?;
|
||||
log::info!("storage: {kind:?} cleared");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether `kind` waits to be cleared at the next start.
|
||||
pub fn clearing(app: AppId, kind: Kind) -> io::Result<bool> {
|
||||
let dir = crate::data_dir(app).map_err(io::Error::other)?;
|
||||
dir.join(kind.mark()).try_exists()
|
||||
}
|
||||
|
||||
/// At start, before anything opens them: the clears asked for.
|
||||
pub fn clear_marked(app: AppId) -> io::Result<()> {
|
||||
clear_marked_in(&crate::data_dir(app).map_err(io::Error::other)?)
|
||||
}
|
||||
|
||||
fn clear_marked_in(dir: &Path) -> io::Result<()> {
|
||||
for kind in Kind::ALL.into_iter().filter(|kind| kind.at_next_start()) {
|
||||
let mark = dir.join(kind.mark());
|
||||
if !mark.try_exists()? {
|
||||
continue;
|
||||
}
|
||||
let place: PathBuf = dir.join(kind.place());
|
||||
match std::fs::remove_dir_all(&place) {
|
||||
Err(error) if error.kind() != io::ErrorKind::NotFound => return Err(error),
|
||||
_ => {}
|
||||
}
|
||||
std::fs::remove_file(&mark)?;
|
||||
log::info!("storage: {kind:?} cleared as asked");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Recovery points kept per document, and space for them (§6.2).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Limits {
|
||||
pub keep: usize,
|
||||
pub megabytes: u64,
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
Self { keep: 20, megabytes: 64 }
|
||||
}
|
||||
}
|
||||
|
||||
const LIMITS: &str = "recovery";
|
||||
|
||||
/// `app`'s recovery limits; a bad entry is an error.
|
||||
pub fn limits(app: AppId) -> io::Result<Limits> {
|
||||
limits_of(crate::settings::Store::app(app)?.read()?.get(LIMITS))
|
||||
}
|
||||
|
||||
fn limits_of(saved: Option<&serde_json::Value>) -> io::Result<Limits> {
|
||||
let Some(saved) = saved else {
|
||||
return Ok(Limits::default());
|
||||
};
|
||||
let number = |key: &str, least: u64, most: u64| {
|
||||
saved.get(key).and_then(serde_json::Value::as_u64).filter(|n| (least..=most).contains(n)).ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::InvalidData, format!("settings: recovery {key} cannot be {saved}"))
|
||||
})
|
||||
};
|
||||
Ok(Limits { keep: number("keep", 1, 100)? as usize, megabytes: number("megabytes", 1, 4096)? })
|
||||
}
|
||||
|
||||
/// Keeps new limits for `app` if `still` holds once locked.
|
||||
pub fn set_limits(app: AppId, limits: Limits, still: impl FnOnce() -> bool) -> io::Result<bool> {
|
||||
let saved = serde_json::json!({ "keep": limits.keep, "megabytes": limits.megabytes });
|
||||
crate::settings::Store::app(app)?.set_if(LIMITS, saved, still)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Kind, Limits, clear_marked_in, limits_of, size};
|
||||
|
||||
#[test]
|
||||
fn limits_read_strictly() {
|
||||
assert_eq!(limits_of(None).unwrap(), Limits::default());
|
||||
let set = serde_json::json!({"keep": 10, "megabytes": 32});
|
||||
assert_eq!(limits_of(Some(&set)).unwrap(), Limits { keep: 10, megabytes: 32 });
|
||||
for bad in [serde_json::json!({"keep": 0, "megabytes": 32}), serde_json::json!({"keep": "ten"}), serde_json::json!(5)] {
|
||||
assert!(limits_of(Some(&bad)).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marked_kinds_go_at_start_and_others_stay() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("web/Cache")).unwrap();
|
||||
std::fs::write(dir.path().join("web/Cache/blob"), vec![0u8; 4096]).unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("recovery/a")).unwrap();
|
||||
std::fs::write(dir.path().join("recovery/a/point"), b"text").unwrap();
|
||||
assert_eq!(size(&dir.path().join("web")).unwrap(), 4096);
|
||||
std::fs::write(dir.path().join(Kind::Cache.mark()), b"").unwrap();
|
||||
clear_marked_in(dir.path()).unwrap();
|
||||
assert!(!dir.path().join("web").exists(), "the cache went");
|
||||
assert!(dir.path().join("recovery/a/point").exists(), "unmarked kinds stay");
|
||||
assert!(!dir.path().join(Kind::Cache.mark()).exists(), "the mark is spent");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Whether this system trusts a certificate chain (PDF-12).
|
||||
|
||||
use std::io;
|
||||
|
||||
/// The system's answer for a chain, leaf first.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Trust {
|
||||
Trusted,
|
||||
/// The system's own words for why not.
|
||||
Untrusted(String),
|
||||
}
|
||||
|
||||
/// Evaluates `chain` (DER, leaf first) against the system's trust settings.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn check(chain: &[Vec<u8>]) -> io::Result<Trust> {
|
||||
mac::check(chain, None)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn check(_chain: &[Vec<u8>]) -> io::Result<Trust> {
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"certificate trust is not built on this platform",
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac {
|
||||
use std::io;
|
||||
|
||||
use security_framework::{certificate::SecCertificate, policy::SecPolicy, trust::SecTrust};
|
||||
|
||||
use super::Trust;
|
||||
|
||||
/// `anchors` replaces the system's roots; tests only.
|
||||
pub fn check(chain: &[Vec<u8>], anchors: Option<&[Vec<u8>]>) -> io::Result<Trust> {
|
||||
let certs = |ders: &[Vec<u8>]| -> io::Result<Vec<SecCertificate>> {
|
||||
ders.iter()
|
||||
.map(|der| SecCertificate::from_der(der).map_err(io::Error::other))
|
||||
.collect()
|
||||
};
|
||||
let leaf = certs(chain)?;
|
||||
if leaf.is_empty() {
|
||||
return Err(io::Error::other("a signature with no certificate"));
|
||||
}
|
||||
let mut trust = SecTrust::create_with_certificates(&leaf, &[SecPolicy::create_x509()])
|
||||
.map_err(io::Error::other)?;
|
||||
// No network: revocation is never checked.
|
||||
trust
|
||||
.set_network_fetch_allowed(false)
|
||||
.map_err(io::Error::other)?;
|
||||
if let Some(anchors) = anchors {
|
||||
trust
|
||||
.set_anchor_certificates(&certs(anchors)?)
|
||||
.map_err(io::Error::other)?;
|
||||
}
|
||||
Ok(match trust.evaluate_with_error() {
|
||||
Ok(()) => Trust::Trusted,
|
||||
Err(why) => Trust::Untrusted(why.description().to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Trust, check};
|
||||
|
||||
/// The certificate in tests/fixtures/pdf/signer.p12, self-signed.
|
||||
fn signer() -> Vec<u8> {
|
||||
include_bytes!("../../../tests/fixtures/pdf/signer.der").to_vec()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_self_signed_signer_is_not_trusted_by_the_system() {
|
||||
assert!(
|
||||
matches!(check(&[signer()], None).expect("checks"), Trust::Untrusted(why) if !why.is_empty())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chain_to_a_trusted_root_is_trusted() {
|
||||
assert_eq!(
|
||||
check(&[signer()], Some(&[signer()])).expect("checks"),
|
||||
Trust::Trusted
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//! The name documents sign new comments and changes with.
|
||||
|
||||
use std::io;
|
||||
|
||||
/// The author name from settings, else the account's.
|
||||
pub fn name() -> io::Result<String> {
|
||||
let set = crate::settings::Shared::load()?.author;
|
||||
if !set.is_empty() {
|
||||
return Ok(set);
|
||||
}
|
||||
account()
|
||||
}
|
||||
|
||||
/// The account's full name, else its login name.
|
||||
pub fn account() -> io::Result<String> {
|
||||
let full = full_name()?;
|
||||
if !full.trim().is_empty() {
|
||||
return Ok(full.trim().to_string());
|
||||
}
|
||||
let key = if cfg!(windows) { "USERNAME" } else { "USER" };
|
||||
log::info!("user: no full name; the login name signs");
|
||||
std::env::var(key).map_err(|error| io::Error::other(format!("no {key}: {error}")))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn full_name() -> io::Result<String> {
|
||||
Ok(objc2_foundation::NSFullUserName().to_string())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn full_name() -> io::Result<String> {
|
||||
// The first comma field of the passwd comment.
|
||||
let entry = unsafe { libc::getpwuid(libc::getuid()) };
|
||||
if entry.is_null() {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
let gecos = unsafe { (*entry).pw_gecos };
|
||||
if gecos.is_null() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
let text = unsafe { std::ffi::CStr::from_ptr(gecos) }.to_string_lossy();
|
||||
Ok(text.split(',').next().unwrap_or_default().to_string())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn full_name() -> io::Result<String> {
|
||||
use windows_sys::Win32::Security::Authentication::Identity::{GetUserNameExW, NameDisplay};
|
||||
let mut size = 0u32;
|
||||
unsafe { GetUserNameExW(NameDisplay, std::ptr::null_mut(), &mut size) };
|
||||
// Accounts outside a domain have no display name.
|
||||
if size == 0 {
|
||||
return Ok(String::new());
|
||||
}
|
||||
let mut buffer = vec![0u16; size as usize];
|
||||
if unsafe { GetUserNameExW(NameDisplay, buffer.as_mut_ptr(), &mut size) } == 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(String::from_utf16_lossy(&buffer[..size as usize]))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn the_account_has_a_name() {
|
||||
let name = super::name().expect("a name");
|
||||
assert!(!name.is_empty());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user