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:
ZacharyZhang-NY committed 2026-10-08 21:20:08 -04:00
commit b7fca0146d
817 files changed
+143101

No files matched your search

+94
View File
@@ -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);
}
}
+101
View File
@@ -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"
]
);
}
}
+189
View File
@@ -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:?}");
}
}
+130
View File
@@ -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]);
}
}
+201
View File
@@ -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()));
}
+62
View File
@@ -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(&current).unwrap();
assert_eq!(omarchy_dir(home.path(), None), Some(current));
assert_eq!(
omarchy_dir(home.path(), Some("/elsewhere".into())),
Some("/elsewhere".into())
);
}
}
+36
View File
@@ -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:?}");
}
}
}
+129
View File
@@ -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"));
}
}
+25
View File
@@ -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();
}
}
+23
View File
@@ -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};
+116
View File
@@ -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");
}
}
+94
View File
@@ -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());
}
}
+98
View File
@@ -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");
}
}
+142
View File
@@ -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 {}
}
}
+126
View File
@@ -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())
})
}
}
+104
View File
@@ -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);
}
}
+92
View File
@@ -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;
+122
View File
@@ -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}"))
}
+91
View File
@@ -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");
}
+80
View File
@@ -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}");
}
}
+166
View File
@@ -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");
}
}
+87
View File
@@ -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
);
}
}
}
+488
View File
@@ -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}");
}
}
}
+68
View File
@@ -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());
}
}