From 943411fb1adf934d78a7497bbc8145553206dfc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 9 Oct 2026 10:58:47 -0400 Subject: [PATCH] ElyPDF: an OS permission error shows as an error, never a missing text key (P-01); component library f834f55 --- Cargo.lock | 2 +- Cargo.toml | 2 +- agents.md | 4 ++-- apps/ely-pdf/src/ask.rs | 2 +- apps/ely-pdf/src/protect.rs | 6 ++--- apps/ely-pdf/src/tests/output.rs | 33 +++++++++++++++++++++++++++ apps/ely-pdf/src/view.rs | 6 ++--- crates/ely-pdf-engine/src/client.rs | 26 ++++++++++++++++++--- crates/ely-pdf-engine/src/lib.rs | 2 +- crates/ely-pdf-engine/tests/output.rs | 5 ++-- crates/ely-pdf-engine/tests/secure.rs | 8 +++---- crates/ely-pdf-engine/tests/sign.rs | 4 ++-- crates/ely-ui/src/shell/root.rs | 5 ++++ scripts/fetch-references.sh | 2 +- tasks/BUGS.md | 2 +- tasks/reviews/P-01-r1.md | 5 ++++ tasks/reviews/P-01-r2.md | 1 + 17 files changed, 90 insertions(+), 25 deletions(-) create mode 100644 tasks/reviews/P-01-r1.md create mode 100644 tasks/reviews/P-01-r2.md diff --git a/Cargo.lock b/Cargo.lock index a56110a..9816afa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 23486dd..c25913f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/agents.md b/agents.md index af45433..010a43a 100644 --- a/agents.md +++ b/agents.md @@ -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。 diff --git a/apps/ely-pdf/src/ask.rs b/apps/ely-pdf/src/ask.rs index 30e35b6..4ad572e 100644 --- a/apps/ely-pdf/src/ask.rs +++ b/apps/ely-pdf/src/ask.rs @@ -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())]); diff --git a/apps/ely-pdf/src/protect.rs b/apps/ely-pdf/src/protect.rs index 22d6223..d2e01f8 100644 --- a/apps/ely-pdf/src/protect.rs +++ b/apps/ely-pdf/src/protect.rs @@ -46,9 +46,9 @@ fn masked(window: &mut Window, cx: &mut Context) -> Entity { 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), } } diff --git a/apps/ely-pdf/src/tests/output.rs b/apps/ely-pdf/src/tests/output.rs index c3d67ef..805b0fc 100644 --- a/apps/ely-pdf/src/tests/output.rs +++ b/apps/ely-pdf/src/tests/output.rs @@ -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"); diff --git a/apps/ely-pdf/src/view.rs b/apps/ely-pdf/src/view.rs index 00a3b90..b45eb9c 100644 --- a/apps/ely-pdf/src/view.rs +++ b/apps/ely-pdf/src/view.rs @@ -227,10 +227,10 @@ impl PdfView { pub fn fail(&self, what: &str, error: &io::Error, cx: &mut Context) { // 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())); } diff --git a/crates/ely-pdf-engine/src/client.rs b/crates/ely-pdf-engine/src/client.rs index a224c5c..499e52c 100644 --- a/crates/ely-pdf-engine/src/client.rs +++ b/crates/ely-pdf-engine/src/client.rs @@ -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::().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), } } diff --git a/crates/ely-pdf-engine/src/lib.rs b/crates/ely-pdf-engine/src/lib.rs index 1b63f9e..7b3eea6 100644 --- a/crates/ely-pdf-engine/src/lib.rs +++ b/crates/ely-pdf-engine/src/lib.rs @@ -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; diff --git a/crates/ely-pdf-engine/tests/output.rs b/crates/ely-pdf-engine/tests/output.rs index a8e60a4..a4f1882 100644 --- a/crates/ely-pdf-engine/tests/output.rs +++ b/crates/ely-pdf-engine/tests/output.rs @@ -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"); } diff --git a/crates/ely-pdf-engine/tests/secure.rs b/crates/ely-pdf-engine/tests/secure.rs index 7aa44e2..f08aac1 100644 --- a/crates/ely-pdf-engine/tests/secure.rs +++ b/crates/ely-pdf-engine/tests/secure.rs @@ -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] diff --git a/crates/ely-pdf-engine/tests/sign.rs b/crates/ely-pdf-engine/tests/sign.rs index 72c00fe..29f57a6 100644 --- a/crates/ely-pdf-engine/tests/sign.rs +++ b/crates/ely-pdf-engine/tests/sign.rs @@ -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] diff --git a/crates/ely-ui/src/shell/root.rs b/crates/ely-ui/src/shell/root.rs index 53b72c4..a344de8 100644 --- a/crates/ely-ui/src/shell/root.rs +++ b/crates/ely-ui/src/shell/root.rs @@ -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)> { + self.toaster.read(cx).shown() + } + /// A toast whose button opens the output `path` (§16). pub fn toast_output( &mut self, diff --git a/scripts/fetch-references.sh b/scripts/fetch-references.sh index 7a39485..68480a7 100755 --- a/scripts/fetch-references.sh +++ b/scripts/fetch-references.sh @@ -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 \ diff --git a/tasks/BUGS.md b/tasks/BUGS.md index ed45409..965e693 100644 --- a/tasks/BUGS.md +++ b/tasks/BUGS.md @@ -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` 对每个文字块重跑一遍整页内容过滤,编辑工具长时间无反应 diff --git a/tasks/reviews/P-01-r1.md b/tasks/reviews/P-01-r1.md new file mode 100644 index 0000000..085daa4 --- /dev/null +++ b/tasks/reviews/P-01-r1.md @@ -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`。修复方向:接入真实错误提示接收者,等待失败处理完成并断言权限错误提示,使吞错变异触发测试失败。 diff --git a/tasks/reviews/P-01-r2.md b/tasks/reviews/P-01-r2.md new file mode 100644 index 0000000..7ef22e9 --- /dev/null +++ b/tasks/reviews/P-01-r2.md @@ -0,0 +1 @@ +PASS