ElyPDF: an OS permission error shows as an error, never a missing text key (P-01); component library f834f55

This commit is contained in:
ZacharyZhang-NY committed 2026-10-09 10:58:47 -04:00
1 parent ab85673eea
commit 943411fb1a
17 files changed
+90 -25

No files matched your search

Generated
+1 -1
View File
@@ -1964,7 +1964,7 @@ dependencies = [
[[package]]
name = "ely-gpui-component"
version = "0.1.1"
source = "git+https://github.com/ZacharyZhang-NY/Ely-GPUI-Components?rev=b0dbfd429c17819f6c143fd365852244d497514a#b0dbfd429c17819f6c143fd365852244d497514a"
source = "git+https://github.com/ZacharyZhang-NY/Ely-GPUI-Components?rev=f834f55c16b38d86fbc243b2d7719667503f4b01#f834f55c16b38d86fbc243b2d7719667503f4b01"
dependencies = [
"alacritty_terminal",
"anyhow",
+1 -1
View File
@@ -17,7 +17,7 @@ encoding_rs = "0.8"
jiff = "0.2"
anyhow = "1"
directories = "6"
ely-gpui-component = { git = "https://github.com/ZacharyZhang-NY/Ely-GPUI-Components", rev = "b0dbfd429c17819f6c143fd365852244d497514a" }
ely-gpui-component = { git = "https://github.com/ZacharyZhang-NY/Ely-GPUI-Components", rev = "f834f55c16b38d86fbc243b2d7719667503f4b01" }
env_logger = "0.11"
gpui = { package = "gpui-pre", version = "=0.3.8", default-features = false, features = ["stacker"] }
gpui_platform = { package = "gpui-pre-platform", version = "=0.3.8", features = ["font-kit"] }
+2 -2
View File
@@ -39,7 +39,7 @@ site/ 官网(Vite 8、pnpm、TypeScript,无框架;等轴 SVG 积木
## 组件映射
依赖 `ely-gpui-component`(pin b0dbfd4,gpui 为 crates.io 的 `gpui-pre` =0.3.8)。名字取自 `reference/Ely-GPUI-Components/src`。gpui 用法规则见该仓库 `AGENTS.md`。`tests/scripts/agents_md.py` 核对下表每个名字都是组件库公开项。
依赖 `ely-gpui-component`(pin f834f55,gpui 为 crates.io 的 `gpui-pre` =0.3.8)。名字取自 `reference/Ely-GPUI-Components/src`。gpui 用法规则见该仓库 `AGENTS.md`。`tests/scripts/agents_md.py` 核对下表每个名字都是组件库公开项。
| 需要 | 用 |
| --- | --- |
@@ -184,7 +184,7 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
## 决策
- 2026-10-01:上游版本以 `scripts/fetch-references.sh` 为准。LibreOffice 26.2.6(8221e31)、MuPDF 1.28.5(8ad45e9)、gpui 为 `gpui-pre` =0.3.8 与 `gpui-pre-platform` =0.3.8(crates.io,2026-10-07 起,随组件库 K01)、组件库 b0dbfd4(2026-10-09 起)。
- 2026-10-01:上游版本以 `scripts/fetch-references.sh` 为准。LibreOffice 26.2.6(8221e31)、MuPDF 1.28.5(8ad45e9)、gpui 为 `gpui-pre` =0.3.8 与 `gpui-pre-platform` =0.3.8(crates.io,2026-10-07 起,随组件库 K01)、组件库 f834f55(2026-10-09 起)。
- 2026-10-02:组件库 7380afb 起:对话框与面板的 Enter 提交改走字段的 key-down 动作(macOS IME 天然守卫,不再提前提交);面板族文案经组件 `i18n::text`(内建 en/zh-CN,应用目录可覆盖);核心控件带 role/aria;对话框带角色、名称与说明,Cancel/Close 与禁用说明本地化。同仓库 `~/Documents/GitHub/Ely-GPUI-Components` 修复并经 SSH 推送(本机 HTTPS keychain 令牌失效)。2026-10-04 组件库 b6cb94c:`NumberInput::on_commit` 只在 Enter、失焦、步进、拖动时触发,键入不触发;改文档的数值框用它,否则每个键入数字都成一条内核命令与一步撤销。同批带入上游的 Checkbox、Switch、Select 等 role/aria 与 RenderSurface。c0f17ee:`ToggleButton::disabled`(不可按、无 Tab 停靠、说明不可用,提示可写原因)。967d1e4:`ContextMenu::manual`(按属主编号的请求在某点打开,右键不开),供行在按下之后才到的菜单,如内核给的拼写建议。
- 2026-10-06:审查者为探针复制的源码树须用自己的 `CARGO_TARGET_DIR`(`scripts/review.sh` 写明);写完结论前删掉这些副本与其构建目录(曾积到 /tmp 近 900 GB,磁盘写满、链接失败):曾编进本仓库 `target/`,ely-lok 里烘焙的源码路径指向已删的副本,单测找不到 worker。
- 2026-10-03:每条任务由 `scripts/review.sh` 在 herdr 分栏里交给 codex(gpt-6.1-sol,effort high;本机 `codex` 别名已带 `--yolo`,重复传参会报错)审查,最多三轮。此前各轮用 gpt-6-astra xhigh。
+1 -1
View File
@@ -136,7 +136,7 @@ impl Viewer {
}
fn failed_write(&self, name: &str, error: &io::Error, cx: &mut App) {
if error.kind() == io::ErrorKind::PermissionDenied {
if ely_pdf_engine::refused(error).is_some() {
return self.failed(error, cx);
}
let text = Self::text(cx, "office.error.save", &[("name", name), ("error", &error.to_string())]);
+3 -3
View File
@@ -46,9 +46,9 @@ fn masked(window: &mut Window, cx: &mut Context<Viewer>) -> Entity<TextInput> {
impl Viewer {
/// A refusal names what is missing.
pub(crate) fn failed(&self, error: &io::Error, cx: &mut App) {
match error.kind() {
io::ErrorKind::PermissionDenied => self.toast(crate::view::restricted(&error.to_string(), cx), Severity::Warning, cx),
_ => self.panel_failed(&error.to_string(), cx),
match ely_pdf_engine::refused(error) {
Some(needed) => self.toast(crate::view::restricted(needed, cx), Severity::Warning, cx),
None => self.panel_failed(&error.to_string(), cx),
}
}
+33
View File
@@ -33,6 +33,39 @@ fn pages_export_as_images_and_text() {
assert!(text.contains("First") && text.contains("Second"), "{text}");
}
#[cfg(unix)]
#[test]
fn a_write_the_system_refuses_reports_it_without_a_crash() {
use std::os::unix::fs::PermissionsExt as _;
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let path = file(dir.path(), "doc.pdf", &[(612.0, 792.0, &["First"])]);
let (viewer, cx) = windowed(&mut cx, dir.path(), &[path], true);
shown(cx, &viewer);
let locked = dir.path().join("locked");
std::fs::create_dir(&locked).expect("a folder");
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o500)).expect("read-only");
let id = cx.update(|_, app| viewer.read(app).core.tabs[0].id);
let txt = locked.join("doc.txt");
let how = Optimizing { compress: true, from: 300, to: 0 };
// The window's toasts live on its root.
let root = cx.update(|window, app| {
let root = ely_ui::document_root(ely_platform::AppId::Pdf, viewer.clone().into(), &ely_ui::commands::FILE, Vec::new(), window, app);
viewer.update(app, |viewer, _| viewer.core.root = root.downgrade());
root
});
cx.update(|_, app| viewer.update(app, |viewer, cx| viewer.write_export(id, Kind::Text, how, txt.clone(), cx)));
let failed = |app: &gpui::App| {
let toasts = root.read(app).toasts(app);
toasts.iter().any(|(text, severity)| {
*severity == Some(ely_gpui_component::primitives::Severity::Danger) && text.contains("Permission denied")
})
};
settle(cx, failed);
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700)).expect("writable again");
assert!(!txt.exists(), "nothing is written");
}
#[test]
fn a_damaged_file_opens_repaired_and_saves_as_a_new_file() {
let dir = tempfile::tempdir().expect("a temp dir");
+3 -3
View File
@@ -227,10 +227,10 @@ impl PdfView {
pub fn fail(&self, what: &str, error: &io::Error, cx: &mut Context<Self>) {
// Engine text may quote the document: the user sees it.
log::error!("pdf: {what} failed ({:?})", error.kind());
let text = match error.kind() {
let text = match ely_pdf_engine::refused(error) {
_ if ely_pdf_engine::is_stale(error) => ely_ui::i18n::text(cx, "pdf.object.changed", &[]),
io::ErrorKind::PermissionDenied => restricted(&error.to_string(), cx),
_ => ely_ui::i18n::text(cx, "pdf.failed", &[("what", what), ("error", &error.to_string())]),
Some(needed) => restricted(needed, cx),
None => ely_ui::i18n::text(cx, "pdf.failed", &[("what", what), ("error", &error.to_string())]),
};
cx.emit(Failed(text.to_string()));
}
+23 -3
View File
@@ -293,10 +293,30 @@ impl Drop for Worker {
}
}
/// A permission refusal is `PermissionDenied`, named by what it needs.
/// A request the document's permissions refused, by what it needs.
#[derive(Debug)]
pub struct Refused(pub &'static str);
impl std::fmt::Display for Refused {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}{}", crate::proto::may::REFUSED, self.0)
}
}
impl std::error::Error for Refused {}
/// What a permission refusal needs; none for other errors.
pub fn refused(error: &io::Error) -> Option<&'static str> {
error.get_ref()?.downcast_ref::<Refused>().map(|refused| refused.0)
}
/// A worker error; a refusal names a permission it knows.
fn refusal(text: String) -> io::Error {
match text.strip_prefix(crate::proto::may::REFUSED) {
Some(needed) => io::Error::new(io::ErrorKind::PermissionDenied, needed.to_string()),
let named = text
.strip_prefix(crate::proto::may::REFUSED)
.and_then(|needed| crate::proto::may::NAMES.iter().find(|(_, name)| *name == needed));
match named {
Some((_, name)) => io::Error::other(Refused(name)),
None => io::Error::other(text),
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ pub mod proto;
#[cfg(any(test, feature = "test-support"))]
pub mod sample;
pub use client::{Doc, Opened, Tile, Worker, is_stale};
pub use client::{Doc, Opened, Refused, Tile, Worker, is_stale, refused};
pub use content::{Drawn, TextBlock, Writing, Wrote};
pub use ocr::{OcrLine, tessdata};
pub use output::Optimizing;
+3 -2
View File
@@ -1,6 +1,6 @@
//! Images, text, optimized copies, printing leave, repaired files (C11.8).
use std::{io::ErrorKind, path::Path};
use std::path::Path;
use ely_pdf_engine::{
Doc, Lock, Optimizing, Worker,
@@ -89,7 +89,8 @@ fn printing_and_copying_need_leave() {
worker.protect(doc, &limited, Some(&Lock { user: String::new(), owner: "owner".into(), allow: may::EDIT })).expect("protects");
let (mut other, read) = opened(&limited);
for refused in [other.print_copy(read, &dir.path().join("p.pdf")), other.export_text(read, &dir.path().join("t.txt"))] {
assert_eq!(refused.expect_err("refused").kind(), ErrorKind::PermissionDenied);
let refused = refused.expect_err("refused");
assert!(ely_pdf_engine::refused(&refused).is_some(), "refused: {refused}");
}
worker.print_copy(doc, &dir.path().join("print.pdf")).expect("an unprotected file prints");
}
+4 -4
View File
@@ -1,6 +1,6 @@
//! Protected copies and what they allow (PDF-11).
use std::{io::ErrorKind, path::Path};
use std::path::Path;
use ely_pdf_engine::{
Doc, Lock, Worker,
@@ -43,7 +43,7 @@ fn a_protected_copy_asks_and_holds_its_limits() {
let allowed = worker.permissions(opened.doc).expect("reads");
assert_eq!(allowed & (may::PRINT | may::COPY | may::EDIT | may::OWNER), may::PRINT);
let refused = worker.rotate_pages(opened.doc, &[0], 90).expect_err("refused");
assert_eq!((refused.kind(), refused.to_string()), (ErrorKind::PermissionDenied, "assemble".to_string()));
assert_eq!(ely_pdf_engine::refused(&refused), Some("assemble"));
let text: String = worker.text(opened.doc, 0).expect("still reads").iter().map(|c| c.c).collect();
assert!(text.contains("Hello"));
}
@@ -72,7 +72,7 @@ fn changing_protection_needs_the_owner() {
worker.unlock(doc, "user").expect("unlocks");
let bare = dir.path().join("bare.pdf");
let refused = worker.protect(doc, &bare, None).expect_err("refused");
assert_eq!(refused.kind(), ErrorKind::PermissionDenied);
assert!(ely_pdf_engine::refused(&refused).is_some(), "refused: {refused}");
assert!(!bare.exists());
worker.owner(doc, "owner").expect("tries");
worker.protect(doc, &bare, None).expect("unprotects");
@@ -103,7 +103,7 @@ fn a_file_with_no_open_password_still_holds_limits() {
let opened = other.open(&out).expect("opens");
assert!(!opened.locked);
let refused = other.annotate(opened.doc, &[]).expect_err("refused");
assert_eq!((refused.kind(), refused.to_string()), (ErrorKind::PermissionDenied, "annotate".to_string()));
assert_eq!(ely_pdf_engine::refused(&refused), Some("annotate"));
}
#[test]
+2 -2
View File
@@ -1,6 +1,6 @@
//! Signing by update and checking what was signed (PDF-12, QA-15).
use std::{io::ErrorKind, path::{Path, PathBuf}};
use std::path::{Path, PathBuf};
use ely_pdf_engine::{
Digest, Doc, Lock, Signing, Worker,
@@ -128,7 +128,7 @@ fn a_new_field_needs_leave_to_change_the_page() {
worker.protect(doc, &limited, Some(&lock)).expect("protects");
let (mut other, read) = opened(&limited);
let refused = other.sign(read, &dir.path().join("signed.pdf"), &signing(None, "S", "ely")).expect_err("refused");
assert_eq!(refused.kind(), ErrorKind::PermissionDenied);
assert!(ely_pdf_engine::refused(&refused).is_some(), "refused: {refused}");
}
#[test]
+5
View File
@@ -154,6 +154,11 @@ impl Root {
});
}
/// The toasts on show: title and severity, oldest first.
pub fn toasts(&self, cx: &gpui::App) -> Vec<(SharedString, Option<Severity>)> {
self.toaster.read(cx).shown()
}
/// A toast whose button opens the output `path` (§16).
pub fn toast_output(
&mut self,
+1 -1
View File
@@ -33,7 +33,7 @@ fetch() { # name url rev [sparse paths...]
echo "fetch $name $rev"
}
fetch Ely-GPUI-Components https://github.com/ZacharyZhang-NY/Ely-GPUI-Components.git b0dbfd429c17819f6c143fd365852244d497514a
fetch Ely-GPUI-Components https://github.com/ZacharyZhang-NY/Ely-GPUI-Components.git f834f55c16b38d86fbc243b2d7719667503f4b01
fetch zed https://github.com/zed-industries/zed.git 1a28cff4b409169bac058bca40dfbfeb7621d19b
fetch libreoffice-core https://github.com/LibreOffice/core.git 8221e31b3ac356a1623c672912a3d2b492f7e3d1 \
include/LibreOfficeKit libreofficekit desktop/source/lib desktop/inc \
+1 -1
View File
@@ -33,7 +33,7 @@
- [ ] S-08 刚编辑过幻灯片就进备注栏键入,备注可能丢
- [ ] S-09 改尺寸失败后 `size_asked` 一直留着
- [ ] S-10 开始放映的两处拒绝无提示(规则 2)
- [ ] P-01 文件系统的 `PermissionDenied` 被拼成不存在的文案键,应用崩溃
- [x] P-01 文件系统的 `PermissionDenied` 被拼成不存在的文案键,应用崩溃 — worker 的拒绝改为自有错误类型 Refused;R1 测试不跨平台、不看提示;组件库 f834f55 加 Toaster::shown;R2 PASS
- [ ] P-02 每次编辑落地清掉全部图块与缩略图,页面空白、刚画的墨迹消失
- [ ] P-03 “编辑文字和图片”先命中图片对象再命中文字;一点抖动就移动了背景图
- [ ] P-04 `TEXT_BLOCKS` 对每个文字块重跑一遍整页内容过滤,编辑工具长时间无反应
+5
View File
@@ -0,0 +1,5 @@
FAIL
apps/ely-pdf/src/tests/output.rs:38:新增测试直接使用 Unix 专属的 `std::os::unix::fs::PermissionsExt` 和 `Permissions::from_mode`,测试函数与上层测试模块均缺少平台条件。Windows 编译 ElyPDF 测试目标时会报 E0433、E0599,阻断该平台测试。已将这两处用法原样交给已安装的 `x86_64-pc-windows-msvc` 目标执行 `rustc --emit metadata`,复现两项错误,证据见 `/tmp/ely-p01-r1-windows.log`。修复方向:用 `#[cfg(unix)]` 限定该 Unix 权限测试,保证 Windows 测试目标能够编译。
apps/ely-pdf/src/tests/output.rs:55:新增权限错误回归测试只断言目标文件不存在,固定推进帧后结束,缺少失败处理完成与用户可见错误的断言,静默吞错仍能通过,漏检规则 2 与 PRD UX-05、UX-06 要求的错误反馈。在独立源码副本中将 `Viewer::failed` 改为仅记录探针日志后返回,实际收到 `PermissionDenied`,此测试仍为 `1 passed`;证据见 `/tmp/ely-p01-r1-mutation.log` 与 `/tmp/ely-p01-r1-observed-error.log`。修复方向:接入真实错误提示接收者,等待失败处理完成并断言权限错误提示,使吞错变异触发测试失败。
+1
View File
@@ -0,0 +1 @@
PASS