Remove CEF: the web content crate, ElyMark's page, their packaging, licences and tests (C14.5)

PRD and agents.md name the native route; ElyMail's body will not use
CEF or Electron. ElyMark's package carries the KaTeX fonts' OFL.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-12 03:15:30 -04:00
1 parent 2e8379b0b1
commit 8a942e6d80
77 files changed
+105 -10718

No files matched your search

-4
View File
@@ -1,6 +1,2 @@
[target.'cfg(all(windows, target_env = "msvc"))']
rustflags = ["-C", "target-feature=+crt-static"]
[env]
# cef-dll-sys builds against the CEF that scripts/fetch-cef.sh fetched.
CEF_PATH = { value = "engines/cef", relative = true }
Generated
+5 -243
View File
@@ -374,7 +374,7 @@ version = "0.38.0+1.3.281"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f"
dependencies = [
"libloading 0.8.9",
"libloading",
]
[[package]]
@@ -966,29 +966,6 @@ dependencies = [
"shlex 2.0.1",
]
[[package]]
name = "cef"
version = "154.3.0+154.0.32"
source = "git+https://github.com/tauri-apps/cef-rs?rev=a712ad9bacd234c050631a95b9745b27d346b7cc#a712ad9bacd234c050631a95b9745b27d346b7cc"
dependencies = [
"cef-dll-sys",
"libloading 0.9.0",
"objc2 0.6.4",
"windows-sys 0.61.2",
]
[[package]]
name = "cef-dll-sys"
version = "154.3.0+154.0.32"
source = "git+https://github.com/tauri-apps/cef-rs?rev=a712ad9bacd234c050631a95b9745b27d346b7cc#a712ad9bacd234c050631a95b9745b27d346b7cc"
dependencies = [
"anyhow",
"cc",
"cmake",
"download-cef",
"serde_json",
]
[[package]]
name = "cesu8"
version = "1.1.0"
@@ -1069,49 +1046,9 @@ checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a"
dependencies = [
"glob",
"libc",
"libloading 0.8.9",
"libloading",
]
[[package]]
name = "clap"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "clap_lex"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
[[package]]
name = "clipboard-win"
version = "5.4.1"
@@ -1121,15 +1058,6 @@ dependencies = [
"error-code",
]
[[package]]
name = "cmake"
version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
dependencies = [
"cc",
]
[[package]]
name = "cmov"
version = "0.5.4"
@@ -1256,18 +1184,6 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "console"
version = "0.16.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e96a4956774c13c126a8b5af4daa79384f4d826534c95a02d76afb39e2ab64e3"
dependencies = [
"encode_unicode",
"libc",
"unicode-width",
"windows-sys 0.61.2",
]
[[package]]
name = "console_error_panic_hook"
version = "0.1.7"
@@ -1328,29 +1244,10 @@ version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87"
dependencies = [
"percent-encoding",
"time",
"version_check",
]
[[package]]
name = "cookie_store"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
dependencies = [
"cookie",
"document-features",
"idna",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"time",
"url",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -1892,7 +1789,7 @@ version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
dependencies = [
"libloading 0.8.9",
"libloading",
]
[[package]]
@@ -1925,25 +1822,6 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "download-cef"
version = "3.0.0"
source = "git+https://github.com/tauri-apps/cef-rs?rev=a712ad9bacd234c050631a95b9745b27d346b7cc#a712ad9bacd234c050631a95b9745b27d346b7cc"
dependencies = [
"bzip2",
"clap",
"fs-err",
"indicatif",
"regex",
"semver",
"serde",
"serde_json",
"sha1_smol",
"tar",
"thiserror 2.0.21",
"ureq",
]
[[package]]
name = "dpi"
version = "0.1.2"
@@ -2306,26 +2184,6 @@ dependencies = [
"tempfile",
]
[[package]]
name = "ely-web-content"
version = "0.1.3"
dependencies = [
"anyhow",
"cef",
"ely-gpui-component",
"ely-platform",
"ely-ui",
"futures",
"gpui-pre",
"image",
"libc",
"log",
"objc2 0.6.4",
"serde_json",
"tempfile",
"windows-sys 0.60.2",
]
[[package]]
name = "embed-resource"
version = "3.0.11"
@@ -2349,12 +2207,6 @@ dependencies = [
"phf 0.13.1",
]
[[package]]
name = "encode_unicode"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]]
name = "encoding_rs"
version = "0.8.42"
@@ -2585,16 +2437,6 @@ dependencies = [
"winapi",
]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.13"
@@ -2754,15 +2596,6 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "fs-err"
version = "3.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5c95b673b8f6f7235229ae11c5642d81b04c2e64c1e2fb417bc0cf73ca45f29"
dependencies = [
"autocfg",
]
[[package]]
name = "fsevent-sys"
version = "4.1.0"
@@ -3898,19 +3731,6 @@ dependencies = [
"serde_core",
]
[[package]]
name = "indicatif"
version = "0.18.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c"
dependencies = [
"console",
"portable-atomic",
"unicode-width",
"unit-prefix",
"web-time",
]
[[package]]
name = "inotify"
version = "0.11.5"
@@ -4225,7 +4045,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76"
dependencies = [
"libc",
"libloading 0.8.9",
"libloading",
"pkg-config",
]
@@ -4335,16 +4155,6 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "libloading"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60"
dependencies = [
"cfg-if",
"windows-link 0.2.1",
]
[[package]]
name = "libm"
version = "0.2.16"
@@ -7145,17 +6955,6 @@ dependencies = [
"serde_core",
]
[[package]]
name = "socks"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b"
dependencies = [
"byteorder",
"libc",
"winapi",
]
[[package]]
name = "spin"
version = "0.9.9"
@@ -7262,12 +7061,6 @@ dependencies = [
"quote",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.27.2"
@@ -7524,17 +7317,6 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "tauri-winrt-notification"
version = "0.8.1"
@@ -8002,12 +7784,6 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "unit-prefix"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3"
[[package]]
name = "universal-hash"
version = "0.6.1"
@@ -8031,15 +7807,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc"
dependencies = [
"base64 0.23.1",
"cookie_store",
"flate2",
"log",
"percent-encoding",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"socks",
"ureq-proto",
"utf8-zero",
"webpki-roots",
@@ -8629,7 +8401,7 @@ dependencies = [
"js-sys",
"khronos-egl",
"libc",
"libloading 0.8.9",
"libloading",
"log",
"naga",
"ndk-sys",
@@ -9422,16 +9194,6 @@ version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]]
name = "xcb"
version = "1.7.1"
-2
View File
@@ -49,7 +49,6 @@ objc2-core-media = { version = "0.3", default-features = false, features = ["std
objc2-core-video = { version = "0.3", default-features = false, features = ["std", "CVBuffer", "CVImageBuffer", "CVPixelBuffer"] }
objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFString", "CFCGTypes"] }
core-video = "0.5"
cef = { git = "https://github.com/tauri-apps/cef-rs", rev = "a712ad9bacd234c050631a95b9745b27d346b7cc", default-features = false }
core-foundation = "0.10"
objc2 = "0.6"
security-framework = "3.7"
@@ -61,7 +60,6 @@ ely-lok = { path = "crates/ely-lok" }
ely-office = { path = "crates/ely-office" }
ely-pdf-engine = { path = "crates/ely-pdf-engine" }
ely-platform = { path = "crates/ely-platform" }
ely-web-content = { path = "crates/ely-web-content" }
ely-ui = { path = "crates/ely-ui" }
[workspace.lints.clippy]
+21 -30
View File
@@ -24,10 +24,10 @@ Ely Office 提供八个桌面应用。用户可以写文档、计算表格、制
| ElyPDF | PDF 阅读、编辑、批注、表单和页面处理 | MuPDF;OCR 复用 Tesseract | PDF |
| ElyMail | 邮件收发、本地存储和搜索 | MailCore 2,加本地同步与存储 | 邮件、附件、草稿 |
| ElyTree | Git 图形化版本管理 | Git CLI | 本地 Git 仓库 |
| ElyMark | 本地 Markdown 编辑 | Milkdown 与 CodeMirror 6;参考 MarkText、ColaMD 的编辑与文件处理方式 | Markdown 与本地附件 |
| ElyMark | 本地 Markdown 编辑 | Ely-GPUI-Components 的 Markdown 文档组件,markdown-rs 解析;参考 MarkText、ColaMD、Markion | Markdown 与本地附件 |
| ElyAgent | 独立的 Agent 配置与会话管理 | 嵌入的 Kigi CLI Rust 代码 | 配置、会话记录、运行状态 |
LOK、MuPDF 和 GPUI 是已确定的方向。MailCore 2、Markdown 编辑框架、本地 HTML 宿主和下文的发行默认值,是本 PRD 采用的工程基线,仍须通过三端接入验收。不得把未测试的接口写成已支持功能。
LOK、MuPDF 和 GPUI 是已确定的方向。MailCore 2、Markdown 文档组件、邮件正文渲染和下文的发行默认值,是本 PRD 采用的工程基线,仍须通过三端接入验收。不得把未测试的接口写成已支持功能。
ElyAgent 参考 Magpie 的独立工具定位,但不要求复制其全部功能。它不是其他七个应用的聊天侧栏,也不是整个套件的控制中枢。[S19]
@@ -35,7 +35,7 @@ ElyAgent 参考 Magpie 的独立工具定位,但不要求复制其全部功能
1. 首个正式版交付全部八个应用及本文定义的功能,不发布只有入口和占位界面的套件。
2. Doc、Table、Show 共用 LOK 适配实现。PDF 使用 MuPDF。不开设替代内核路线。
3. GPUI 负责应用界面。专业内容区域可以使用内核渲染结果或本地 Web 内容区域,但外观和行为必须遵守 Ely 规范。
3. GPUI 负责应用界面。专业内容区域可以使用内核渲染结果,但外观和行为必须遵守 Ely 规范;不用 CEF 与 Electron。
4. 应用可以独立启动、更新和卸载。安装 ElyMark、ElyTree 或 ElyAgent 不得强制安装 LOK。
5. 用户文件采用通用格式。应用数据库只保存索引、配置和运行记录,不取代用户文件。
6. 不建设云端文档平台、微服务系统、统一业务数据库或跨应用 Agent 编排系统。
@@ -377,7 +377,7 @@ XFA 动态表单、任意嵌入脚本执行、专业印前分色及 PDF 到 Offi
提供多账号邮件客户端,不建设邮件托管服务。MailCore 2 负责 IMAP、POP、SMTP 和邮件解析等能力。Ely 负责账号流程、本地存储、搜索、同步队列和界面。MailCore 2 官方列出 C++ API 与三端支持,但发行前仍须验证实际构建和依赖。[S10]
默认布局为账号与文件夹、邮件列表、阅读区。写信可在独立窗口打开。HTML 正文和富文本撰写区使用本地内容宿主,不能把正文 HTML 解析当成完整页面渲染。
默认布局为账号与文件夹、邮件列表、阅读区。写信可在独立窗口打开。HTML 正文和富文本撰写区不用 CEF 与 Electron,渲染方式随 ElyMail 任务另定;不能把正文 HTML 解析当成完整页面渲染。
### 12.2 功能要求
@@ -474,9 +474,9 @@ Git 托管、PR 审核平台、Issue 系统、CI 平台和远程开发环境不
提供以本地 Markdown 文件为中心的编辑器。支持可视化编辑、源码编辑和只读预览。文件可由其他编辑器、Git 或 Agent 修改,不要求导入专有知识库。
默认使用 Milkdown 承担可视化编辑,CodeMirror 6 承担源码编辑。两者接入同一个文件会话,只允许当前模式提交修改,不同时维护两份独立可写文档。Milkdown 的 Markdown 与编辑模型转换需要单独处理源码保留,不能把序列化输出默认视为无损原文。[S14][S15][S16]
界面用 Ely-GPUI-Components 的 Markdown 文档组件,全部原生 GPUI:全文只在一个源码编辑器里,可视模式按块渲染,按下的块以原文编辑;源码与预览共用同一份文本。解析用 markdown-rs,公式用 RaTeX,Mermaid 用 mermaid-rs-renderer。文本即文件原文,不经编辑模型序列化。[S14][S15][S16]
参考 MarkText 的 Markdown 编辑行为、ColaMD 的本地文件同步和内容保留规则,不复制其整个应用窗口,不把 Electron 作为 Ely 的桌面外壳。当前 ColaMD 仍包含 Milkdown 与 CodeMirror 相关依赖,但具体实现必须按选定提交核对,不凭包名推断编辑行为。[S17][S18]
参考 MarkText 的 Markdown 编辑行为、ColaMD 的本地文件同步和内容保留规则、Markion 的 GPUI 原生 Markdown 编辑,不复制其整个应用窗口。不用 Electron,不用 CEF 或其他网页内容宿主。[S17][S18]
### 14.2 功能要求
@@ -605,7 +605,6 @@ ely-office/
ely-lok/ # LOK 接入;仅 Doc/Table/Show 使用
ely-pdf-engine/ # MuPDF 与 OCR 接入
ely-mail-engine/ # 邮件协议、同步和本地存储
ely-web-content/ # 本地 HTML 与 Markdown 内容宿主
assets/
tests/
packaging/
@@ -625,23 +624,19 @@ C/C++ 异常、原生错误与资源释放在 FFI 边界处理,不能跨 Rust
邮件同步、Git 命令、OCR、缩略图与索引构建在后台任务中执行,不阻塞 UI 事件循环。后台指应用运行期间的本地任务,不表示默认安装常驻系统服务。
### 17.3 本地 Web 内容宿主
### 17.3 内容渲染
Mail 的 HTML 正文和富文本撰写、Mark 的编辑与预览需要成熟 Web 内容渲染。本 PRD 的默认实现采用 CEF 离屏内容区域,只在这两个应用引入。CEF 提供离屏画面和输入事件接口,适合接入自绘界面,但 GPUI 的实际接入仍须三端验证。[S22]
不用 CEF,不用 Electron(用户 2026-10-11 定)。Mark 的编辑与预览全部由 GPUI 绘制(§14.1)。Mail 的 HTML 正文和富文本撰写的渲染方式随 ElyMail 任务另定,定前不写成已有。
CEF 只处理内容区域,不绘制应用标题栏、菜单、文件树和设置页面。不引入 Electron 应用外壳,不运行面向浏览器的本地文档服务器。CEF 必需的辅助进程随应用发行。
Ely 主题 token 用于内容区。键盘、焦点、选择、缩放、右键菜单和输入法进入统一适配规则。对话框和应用菜单由 GPUI 展示。
Ely 主题 token 同步到内容区样式。键盘、焦点、选择、缩放、右键菜单和输入法进入统一适配规则。对话框和应用菜单由 GPUI 展示,不出现框架默认主题。
邮件阅读环境禁用脚本及文件桥接。Markdown 编辑环境只运行随包代码,通过受限桥接访问当前用户打开的文件与资源。外部文档内容不能调用任意本地命令。
不同时维护 CEF、系统 WebView 和全原生 Markdown 三种路线。若 CEF 原型不能满足三端要求,先修订该接入决策并更新 PRD,不能在应用内部临时混用多套未验收宿主。
邮件阅读不运行正文脚本。Markdown 文档里的 HTML 不运行;数学与图只渲染保存的源码。外部文档内容不能调用任意本地命令。
### 17.4 不建设的技术设施
不引入 Kubernetes、Docker 运行依赖、远程数据库、消息中间件、事件溯源系统、通用插件市场、统一知识图谱、分布式搜索或独立账号中心。
应用间不做 RPC 服务发现。Agent 不以独立后台服务器为前提。Git 不同时接入多个 Git 内核。Markdown 不叠加第三个编辑内核。新抽象必须服务于已有的重复需求。
应用间不做 RPC 服务发现。Agent 不以独立后台服务器为前提。Git 不同时接入多个 Git 内核。Markdown 只有一个解析内核(markdown-rs)。新抽象必须服务于已有的重复需求。
## 18. 数据、配置与隐私
@@ -735,7 +730,7 @@ Doc、Table、Show 的 PDF 输出由 LOK 完成,不要求安装 ElyPDF。ElyMa
| PERF-08 | 资源释放 | 连续打开关闭 20 次后,活动文档对象与句柄回到基线,内存无持续单向增长 |
| PERF-09 | 多会话 | 4 个 Agent 会话同时流式输出时,其他会话选择和日志滚动仍满足交互目标 |
不提前宣传固定内存占用或比其他 Office 更快。发布报告列出单应用、内核子进程及内容宿主的总内存,不能只测 GPUI 主进程。
不提前宣传固定内存占用或比其他 Office 更快。发布报告列出单应用与内核子进程的总内存,不能只测 GPUI 主进程。
### 20.2 稳定性
@@ -769,7 +764,7 @@ Doc、Table、Show 的 PDF 输出由 LOK 完成,不要求安装 ElyPDF。ElyMa
| LibreOffice/LOK | 保留 MPL 2.0 及附带依赖的相应声明;发布对受约束文件的修改与所需资料 [S5] |
| MailCore 2 | 保留其许可、版权和免责条款,并检查其传递依赖 [S24] |
| Git | 作为独立程序分发并遵守其 GPLv2 等实际适用条款;提供所发行版本的源码取得方式 [S23] |
| Markdown 与内容宿主 | 保留 Milkdown、CodeMirror、CEF 及其依赖的通知;逐项检查实际发行包 [S14][S16][S22] |
| Markdown | 保留 markdown-rs、RaTeX(含随带字体)、mermaid-rs-renderer 及其依赖的许可;逐项检查实际发行包 [S14][S15][S16] |
| OCR、字体、词典和媒体组件 | 记录每个资源的来源、版本和再分发条件,不从系统目录随意拷贝受限资源 |
MuPDF 的 AGPL 路线允许符合条件的开源集成;“项目开源”本身不足以证明整个发行包已经符合条件。是否收费与源码义务分开处理。[S8][S9]
@@ -843,13 +838,13 @@ UI 自动测试、内核测试和手工交互测试互相补充。三端的输
| LOK 高级功能 | 母版、放映、图表、修订、输入法、打印和无障碍的接口映射及用例 | 补齐桥接或维护必要上游补丁,不跳回上游原生 UI |
| MuPDF 编辑 | 字体、对象修改、表单、删除及保存的样本结果 | 修复对象编辑与输出;不得改为视觉覆盖冒充编辑 |
| MailCore 2 | 三端可重复构建、TLS、OAuth、同步和 MIME 测试 | 处理依赖及适配问题;替换后端必须显式修订选型 |
| 本地 Web 内容 | CEF 与 GPUI 的输入、焦点、缩放、弹层和辅助技术测试 | 接入未通过不能以独立 Electron 窗口代替 |
| Markdown 原生编辑 | 可视、源码、预览的输入、焦点、缩放、输入法和辅助技术测试 | 未通过不能以 CEF、WebView 或 Electron 代替 |
| Markdown 原文保留 | 无编辑保存、模式切换、局部修改和外部冲突的字节级差异 | 修复映射与保存;未知语法回到原文模式,不删除 |
| 邮箱服务注册 | 正式 OAuth 配置、适用审核状态与用户流程 | 不将未获许可的内置登录描述为可用 |
| Agent 后端差异 | 每个预设后端的版本、启动、控制、日志和恢复能力表 | 按真实能力显示,禁止模拟未提供的接口 |
| 打包与许可 | 三端依赖清单、源码对应关系、资源再分发检查 | 发行前解决,不以“整个项目开源”代替检查 |
选择版本时记录源码提交、编译选项和补丁。升级 LOK、MuPDF、GPUI、CEF 或编辑框架后运行相关回归测试。不要跟随默认分支自动升级正式构建。
选择版本时记录源码提交、编译选项和补丁。升级 LOK、MuPDF、GPUI 或 Markdown 解析与渲染库后运行相关回归测试。不要跟随默认分支自动升级正式构建。
## 24. 交付物与完成定义
@@ -908,14 +903,14 @@ UI 自动测试、内核测试和手工交互测试互相补充。三端的输
[S13] Git 官方文档,git-status 与 porcelain 输出格式。
`https://git-scm.com/docs/git-status`
[S14] Milkdown 官方仓库,框架结构与许可证。
`https://github.com/Milkdown/milkdown`
[S14] markdown-rs 官方仓库,CommonMark、GFM 与扩展语法解析,许可证。
`https://github.com/wooorm/markdown-rs`
[S15] Milkdown 官方 Transformer 文档,编辑模型与 Markdown AST 转换。
`https://milkdown.dev/docs/api/transformer`
[S15] RaTeX 官方仓库,LaTeX 公式排版与许可证。
`https://github.com/erweixin/RaTeX`
[S16] CodeMirror 官方开发入口与仓库迁移说明;应用依赖按实际发布包锁定。
`https://github.com/codemirror/dev`
[S16] mermaid-rs-renderer 官方仓库,Mermaid 图的 Rust 渲染与许可证。
`https://github.com/1jehuang/mermaid-rs-renderer`
[S17] MarkText 官方仓库,编辑模式、格式能力与许可证。
`https://github.com/marktext/marktext`
@@ -933,10 +928,6 @@ UI 自动测试、内核测试和手工交互测试互相补充。三端的输
[S21] Tesseract 官方 API 示例与本地 OCR 接入。
`https://tesseract-ocr.github.io/tessdoc/APIExample.html`
[S22] Chromium Embedded Framework 官方说明与离屏渲染接入。
`https://github.com/chromiumembedded/cef`
`https://chromiumembedded.github.io/cef/general_usage.html`
[S23] Git 官方 COPYING 文件。
`https://raw.githubusercontent.com/git/git/master/COPYING`
+2 -3
View File
@@ -9,7 +9,7 @@ Offline-first desktop apps in Rust and GPUI. Files stay in your folders; no acco
| ElyShow | Presentations (PPTX, ODP) | LibreOfficeKit, system media |
| ElyPDF | PDF reading and editing, OCR, signatures | MuPDF, Tesseract |
| ElyTree | Git client | Git |
| ElyMark | Markdown | Milkdown, CodeMirror in CEF |
| ElyMark | Markdown | markdown-rs, RaTeX, mermaid-rs-renderer |
| ElyMail, ElyAgent | Second release | — |
## Build (macOS)
@@ -20,8 +20,7 @@ scripts/fetch-references.sh # upstream sources at pinned commits
scripts/build-libreoffice.sh # patched LibreOffice 26.2.6.3 (about an hour)
scripts/build-mupdf.sh # patched MuPDF 1.28.5
scripts/build-git.sh && scripts/fetch-git-lfs.sh
scripts/fetch-tessdata.sh && scripts/fetch-cef.sh
scripts/build-mark-content.sh # ElyMark's page
scripts/fetch-tessdata.sh
cargo build -p ely-lok --bins -p ely-pdf-engine --bins
cargo run -p ely-doc # or ely-table, ely-show, ely-pdf, ely-tree, ely-mark
```
+14 -33
View File
@@ -14,7 +14,7 @@
| Doc / Table / Show | LibreOfficeKit,独立工作进程 |
| Show 媒体 | 各平台原生:macOS AVFoundation,Windows Media Foundation,Linux GStreamer(LOK 不放媒体) |
| PDF | MuPDF(C 接口),Tesseract OCR,独立工作进程 |
| Mail | MailCore 2 + SQLite;正文用 CEF 离屏内容区 |
| Mail | MailCore 2 + SQLite;正文渲染随其任务另定(不用 CEF 与 Electron) |
| Tree | Git CLI,参数数组启动 |
| Mark | 组件库 `documents::MarkdownDocument`(GPUI,原文即文本,无序列化);markdown-rs 解析,RaTeX 公式,mermaid-rs-renderer 图 |
| Agent | Kigi CLI 的 Rust 代码直接嵌入(不用 ACP),沿用其提供商登录与模型配置 |
@@ -22,10 +22,10 @@
## 目录
```text
apps/ 八个应用,各自一个二进制;ely-mark/web 是其网页源码(锁定版本,esbuild 构建)
crates/ ely-ui ely-platform ely-ipc(应用与引擎 worker 之间的帧);ely-files ely-lok ely-office(文档应用共用的外壳:标签、会话、保存、关闭询问、冲突、密码;引擎为 LOK 或 MuPDF)ely-pdf-engine(MuPDF worker 与客户端)ely-web-content(CEF 离屏页面,ElyMark 与 ElyMail 共用);ely-mail-engine 随其任务建
apps/ 八个应用,各自一个二进制
crates/ ely-ui ely-platform ely-ipc(应用与引擎 worker 之间的帧);ely-files ely-lok ely-office(文档应用共用的外壳:标签、会话、保存、关闭询问、冲突、密码;引擎为 LOK 或 MuPDF)ely-pdf-engine(MuPDF worker 与客户端);ely-mail-engine 随其任务建
reference/ 上游源码,只读,不属于本项目,不审查
engines/ 下载的二进制与引擎构建产物,不入库,不审查;mupdf-src 是 reference/mupdf 加 patches/mupdf 的构建副本;content/<应用> 是网页内容的构建产物
engines/ 下载的二进制与引擎构建产物,不入库,不审查;mupdf-src 是 reference/mupdf 加 patches/mupdf 的构建副本
patches/ 对上游的补丁,随源码发行(libreoffice、mupdf)
vendor/ crates.io 上游 crate 的副本加本项目的修复,经工作区 `[patch.crates-io]` 换入(gpui-pre、gpui-pre-windows);首个提交为原样,其后是修复
packaging/ 三端打包脚本,apps.tsv 是八个应用的二进制名、名字、桌面分类与发布批次;icons/ 由图标母版渲染各端格式;libreoffice/ely.xcd 是内核的默认配置层
@@ -71,12 +71,8 @@ site/ 官网(Vite 8、pnpm、TypeScript,无框架;等轴 SVG 积木
| gpui 无障碍只有 `aria_value` 等,无文本选区与插入点:LOK 文档的光标与选区到不了辅助技术 | `LokView` 只桥接当前段落文本(`Role::MultilineTextInput`)。上游待办:gpui 补文本选区 AX |
| 选中对象的框与手柄:LOK 不画进图块 | `LokView` 按图形选区画框与 8 个手柄;按住手柄拖动经 `setGraphicSelection` 交内核(缩放;裁剪模式下裁剪) |
| 虚拟化 PDF 页面栈(`DocumentViewer` 一次铺全部页,不合 PERF-05) | `crates/ely-pdf-engine`、`apps/ely-pdf` |
| CEF 离屏内容视图 | `crates/ely-web-content` |
| CEF 页面的无障碍:离屏渲染的页面不进系统 AX 树(CEF 另给 AX 事件,GPUI 无从挂接) | 未桥接;页面文字在辅助技术里不可见。上游待办:gpui 可挂外部 AX 子树 |
| 组件库的大纲项(`lists::Outline`)与提示(`feedback::Alert`)无无障碍角色:屏幕阅读器读不到 | 上游待办;ElyMark 的搜索失败改为结果列表里的行。`feedback::Banner` 已修(fac20ab:危险与警告为 alert,其余为 status,名为其文字) |
| `documents::ZenMode` 把正文夹进 Sm 宽的列,与 ElyMark 的正文宽度设置冲突 | ElyMark 专注模式自己收起标签条、左栏与状态栏,正文宽度由页面设置 |
| CEF 页面的弹出层(`select` 下拉、日期框)在离屏模式另画一层 | 不画;Ely 的页面不用原生 `select`,用自绘菜单 |
| macOS 的 CEF 离屏输入不带 AppKit 编辑命令:Blink 只凭它们处理 Cmd+方向键与 Cmd+Backspace | `ely-web-content` 的 `edit.rs`:页面没处理这些键时照 AppKit 标准键位执行 |
| `documents::ZenMode` 把正文夹进 Sm 宽的列,与 ElyMark 的正文宽度设置冲突 | ElyMark 不用它,专注模式原生重做(C14.12) |
| 表格公式栏与名称框(网格是 LOK 图块,`tables::Spreadsheet` 不用) | `apps/ely-table` |
| Doc/Table/Show 工具栏(`documents::FixedFormatToolbar` 绑 `TextInput`,不接 LOK) | 各 app,用 `shell::Toolbar` 组合 |
| CSV 导入的编码、分隔符、引号、列类型(`misc::CsvImporter` 只读逗号 CSV、映射到属主字段,文案写死英文) | `apps/ely-table` 的 `text.rs`,内核按选项导入 |
@@ -125,7 +121,6 @@ site/ 官网(Vite 8、pnpm、TypeScript,无框架;等轴 SVG 积木
scripts/doctor.sh 工具链版本
scripts/fetch-references.sh 取上游源码到 reference/
scripts/fetch-tessdata.sh 取 OCR 语言数据到 engines/tessdata(钉提交,核对散列)
scripts/fetch-cef.sh 取 CEF minimal 包到 engines/cef(随 cef-rs 钉的版本,核对 SHA-1)
scripts/build-git.sh 构建钉住的 Git 到 engines/git(ElyTree 随包)
scripts/fetch-git-lfs.sh 取 git-lfs 放进 engines/git(核对散列)
scripts/review.sh ID 轮次 路径 审查一条任务
@@ -152,7 +147,6 @@ packaging/icons/masters.py 写出 assets/icons 的全部 SVG 母版(图标
packaging/icons/build.sh 由 assets/icons 的母版渲染 icns、png、ico(只在 macOS)
tests/scripts/icons.sh 八个图标齐全、未过期,macOS 为 squircle,其余无底形
tests/scripts/release-macos.sh dist 里首批六个 .app:以访达的方式打开文件(启动与运行中),引擎都取自包内,带齐许可,不含系统会登记的内嵌包,每个二进制要求的系统不高于应用所写;源码包齐全
tests/scripts/hardened-macos.sh ELY_SIGN_IDENTITY 签名(hardened runtime)的 ElyMark 照样画出页面
tests/engines/*.sh 引擎探测(LOK、MuPDF、libetpan);LOK 探测本构建的内核,不挂载官方镜像
scripts/inert-lo.sh 路径.app 构建出的 LibreOffice.app 去掉文档类型、URL 类型、快速查看与聚焦插件并注销(build-libreoffice.sh 调用)
scripts/extract-official-lo.sh 用 7zz 把官方镜像的 Frameworks 与 Resources 解到 engines/lo-official(不挂载,无包,“未打补丁即拒绝”的单测用)
@@ -164,7 +158,6 @@ tests/scripts/doc-review.sh 真窗口 ElyDoc:无日期的批注逐条写明
tests/scripts/theme.sh 真窗口 ElyTable 以深、浅两种外观启动,截窗口量网格亮度(需屏幕录制权限)
tests/scripts/lag.sh ely-doc 真窗口键入,从调试日志算输入落地、图块、按键到新图块的分位数(需辅助功能权限);ELY_LAG_LOG=路径 留下日志
tests/scripts/pdf-idle.sh 真窗口 ElyPDF 无文件启动时无 worker 子进程
scripts/build-mark-content.sh 按锁文件构建 ElyMark 页面到 engines/content/ely-mark(不跑安装脚本)
tests/scripts/comments-rules.sh comments.py 对长注释与跨行注释报错
tests/scripts/bad-settings.sh 通用设置读不出时 ElyDoc 照样启动,文件不被覆盖
tests/scripts/one-instance.sh 第二份 ElyDoc 被拒,第一份的工作目录不动
@@ -172,7 +165,7 @@ tests/scripts/update.sh 装好的 ElyTree 从本机服务的新版下载
tests/scripts/settings.sh 真窗口:ElyPDF 设置里选深色,ElyTree 即刻变深;作者名写入、不丢外观;ElyTree 拒绝非 Git 程序;设置文件读不出时提示且不覆盖;主窗口重开回到原处(需辅助功能与屏幕录制权限)
tests/scripts/privacy.sh 真窗口 ElyDoc:键入的文字不进日志;worker 的日志进应用日志;设置里的诊断先列出所含内容,导出的文件去掉家目录与姓名、不含正文(需辅助功能权限)
tests/scripts/panels.sh 真窗口 ElyDoc:左侧栏默认不宽于 230、拖动内侧边缘变宽、宽度存入设置、重启后照旧(需辅助功能权限)
tests/scripts/storage.sh 真窗口 ElyMark:设置里的存储列出各类大小,日志当场清空,缓存下次启动前清掉,恢复点数写入设置(需辅助功能权限)
tests/scripts/storage.sh 真窗口 ElyMark:设置里的存储列出各类大小,日志当场清空,恢复副本下次启动前清掉,恢复点数写入设置(需辅助功能权限)
tests/scripts/recovery.sh 真窗口 ElyDoc:恢复点、崩溃后副本、连续两次崩溃、只杀引擎后重新打开(需辅助功能权限)
tests/scripts/pdf-recovery.sh 真窗口 ElyPDF:加锁文件的封存恢复点、空闲引擎退出后重开时再问密码(需辅助功能权限)
tests/scripts/autosave.sh 真窗口 ElyDoc:自动保存开关、停手即写、外部改动不覆盖(需辅助功能权限)
@@ -181,7 +174,6 @@ tests/scripts/show-fullscreen.sh 真窗口 ElyShow:F5 放映的窗口盖住
tests/scripts/types.sh packaging/types.tsv 的文件类型在 Linux 桌面项、Windows 安装程序与 macOS 文档类型里一致,从不设为默认
tests/scripts/layout.sh 真窗口:六个应用在 640、800、1100 宽下无元素越出窗口左右,留截图(需辅助功能与屏幕录制权限)
tests/scripts/ooxml.py <用例> <文件> 不经 LibreOffice 读 DOCX、XLSX、PPTX 并按自算的预期核对(往返用例调用)
tests/scripts/webview.sh 真窗口 CEF 页面:输入法组字与提交、键入、焦点、主题变量、缩放、不可离开所在文件夹
cd site && pnpm deploy 官网构建并经 wrangler 发到 Cloudflare Workers(只有静态资源),地址 https://ely-office.zhangyanghaha0407.workers.dev
cd site && pnpm test && pnpm e2e 官网单测(几何、道路连通)与 Playwright 端到端(悬停、聚焦、按压、命中归属)
cd site/video/scenes && npm run verify 发布视频:群像与离场两个渲染器在切场处屏幕角点一致,离场全程各屏播自己的录屏帧(真页面渲染)
@@ -215,7 +207,7 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
- 2026-10-05(用户定):ElyAgent 的后端与内置模型的登录配置以 Kigi CLI(https://git.zacharyzhang.com/ZacharyZhang-NY/Kigi-CLI,Rust)为基础,以 Rust 依赖直接嵌入,不用 ACP;沿用其提供商登录(订阅账号或 API 密钥)与模型配置。
- 2026-10-05(用户定):八个应用都不许卡顿:键入、选择、滚动、文档渲染都要即时(GPUI 的 120 FPS 手感),树莓派 3 上也要流畅。输入延迟与帧时间是验收项,要实测;界面线程不等内核、不持 worker 锁做同步请求,空闲不做事。
- 2026-10-05:LOK 的输入与事件路径(实测后定)。原样:视图的事件监听长轮询 worker(持 worker 锁最多 50 毫秒,worker 读线程也随之阻塞最多 250 毫秒),按键排在锁后,键入锁等待中位 290 毫秒、最长 9 秒;输入队列在界面执行器上逐条跳回界面线程,每键约一帧,连续键入积压到 1 至 2 秒。现在:worker 有新事件即主动推送(`tag::EVENTS`,推送线程在输出锁下取批,批次即发送时状态),客户端读线程把回复与事件分开,事件存入 `client::Feed`,视图以异步方式等它(`Feed::next`,有新批次时唤醒,不占线程、不碰 worker 锁;原先每个打开的文档阻塞一个后台线程最多一秒,文档多于核数时会饿死输入与图块;监听任务归视图所有,视图释放即停);每个视图一条后台通道(`view/lane.rs`),输入优先、图块其次,连续执行,界面线程只收落地结果;文档尺寸与页框只在内核报尺寸变化时在后台读;批次弄脏的已显示图块即刻重绘,不等下一帧;视图的 `Reported` 事件只在内核回报或输入落地时发出,滚动与图块到达不触发应用的观察钩子;编辑与光标移动后应用要读的内核列表(ElyDoc 导航、字数、审阅,ElyTable 工作表与几何、单元格规则、格式面板)经 `ely_lok::read_off` 在后台读,一次一个,结果按发起者核对。实测(macOS,`tests/scripts/lag.sh`):按打字速度键入时输入落地 0 至 1 毫秒,图块 1 至 3 毫秒,按键到新图块 ElyDoc 中位 19 毫秒、ElyTable 3 毫秒;连续快速键入(脚本一次送出近百字)时,各次运行在 3 至 225 毫秒的中位之间摆动:新文档首次键入时内核有一次 150 至 230 毫秒的冷启动(首块图块与拼写等初始化),同一批字排在它之后;原为中位 1 秒、p95 1.9 秒。树莓派 3 未实测(本机无设备)。界面线程不碰 worker:应用对内核的读取、保存与另存为的导出、导出 PDF、比较、邮件合并都经 `Tabs::read_in`(或 `ely_lok::read_off`)在后台执行,回到窗口时按发起的标签与读取时的状态核对;保存导出期间标签状态为“正在保存”,同一标签的后续保存排队到它结束;另存为在写入前按文件身份再查一次目标是否已被别的标签持有;导出期间又有编辑时,“保存后关闭”不关闭,回到关闭询问;关闭标签时的撤销历史也在后台读;邮件合并预览与比较在发起时就保留自己的标签号与工作目录;读取失败不立即重试,等下一次状态变化;载入时一并在后台读版面与撤销历史(`ely_lok::layout`,`LokView::open` 接收版面)。窗口关闭回调先不放行,在后台逐个读标签的撤销历史并关闭(有改动的照常询问),最后一个标签关闭后移除窗口;内核文档的关闭也在后台(2026-10-06 起,原先同步读历史、在界面线程持 worker 锁关闭)。
- 2026-10-04(用户定):八个应用的内容区一律随系统深浅色:Doc 页面、Table 网格、Show 幻灯片、PDF 页面、邮件正文、Mark 编辑区、Agent 输出。自动颜色随之翻转,文件里写明的颜色照原样;打印与导出 PDF 仍为浅色。各引擎(LOK、MuPDF、CEF)接入应用主题,不留引擎默认色。LOK:`ely.xcd` 定义 `COLOR_SCHEME_ELY_DARK` 与 `COLOR_SCHEME_ELY_LIGHT`(文档底色、自动文字色、网格、链接等取 Ely 默认色板;内核自带的深色方案颜色留空、按应用外观取默认值,传方案名不改外观,所以不用它),`LokView` 在主题深浅变化时经有序队列发 `.uno:ChangeTheme`(不算编辑),页面底色深色取 `surface`、浅色取 `paper`;Omarchy 色板与这两套固定色可能略有差别。导出 PDF 实测仍为白底。图块作废时旧图仍显示,新图到达后替换,编辑与主题切换都不闪。
- 2026-10-04(用户定):八个应用的内容区一律随系统深浅色:Doc 页面、Table 网格、Show 幻灯片、PDF 页面、邮件正文、Mark 编辑区、Agent 输出。自动颜色随之翻转,文件里写明的颜色照原样;打印与导出 PDF 仍为浅色。各引擎(LOK、MuPDF)接入应用主题,不留引擎默认色。LOK:`ely.xcd` 定义 `COLOR_SCHEME_ELY_DARK` 与 `COLOR_SCHEME_ELY_LIGHT`(文档底色、自动文字色、网格、链接等取 Ely 默认色板;内核自带的深色方案颜色留空、按应用外观取默认值,传方案名不改外观,所以不用它),`LokView` 在主题深浅变化时经有序队列发 `.uno:ChangeTheme`(不算编辑),页面底色深色取 `surface`、浅色取 `paper`;Omarchy 色板与这两套固定色可能略有差别。导出 PDF 实测仍为白底。图块作废时旧图仍显示,新图到达后替换,编辑与主题切换都不闪。
- 2026-10-01:主题。`Appearance` 为系统、浅色、深色。系统档在有 Omarchy 时跟随 Omarchy(`ThemeSettings::omarchy_sync`)。读 Omarchy 的 `$HOME/.local/state/omarchy/current/theme/colors.toml`(Omarchy 写死此路径,不随 `XDG_STATE_HOME`),目录由 `ELY_OMARCHY_DIR` 可改。读法与回退链照 `reference/omarchy/bin/omarchy-theme-color`:松散引号、`colorN`、`purple`、`selection_background`、`theme_type`、`light.mode`、亮度和 382 比。缺的色相用 Ely 自己的色并警告。色板照组件库 `A11yChecker` 的规则收口:正文、状态色、代码色在各自的表面上 4.5:1,焦点与图表色在页面上 3:1;先拉近前景再拉到纯黑白,最后逐项核对,核对不过的主题整套忽略。切换时 Omarchy 先暂存 `next-theme` 再整体替换 `theme`,用文件监视跟随,不用它的钩子。新主题无 `colors.toml` 时退回 Ely 色板。`ELY_APPEARANCE`、`ELY_OMARCHY_SYNC`、`ELY_REDUCE_MOTION` 是覆盖开关,设置页接入前先用环境变量。
- 2026-10-05:组件库 8658999(组件库重写历史后的 main,加本项目三个提交):属主不经 `Close` 移除的浮层(一起移除的嵌套浮层同样,回到最外层打开前之处)(对话框动作只清自身状态、异步校验通过后才关)照样把焦点交回打开前之处;原先焦点落在根上,键入无处可去,须先点一下网格。同批带入上游的 ColumnShell、输入法组字时光标重新闪烁、`Icon::from_path`、MIT 许可。旧 pin 2f02c82 已不在 main 上,只能按完整哈希取得。`Select::label` 与 `TextInput::label` 给控件无障碍名称而不绘制;对话框里描述列表旁的控件以此命名,开关用自身的 `label`。7186179 加 `NumberInput::label`(经内部文本框的 `TextInput::set_label`),同批带入上游的 rustfmt 修正。80c46b1:`TemplatePicker` 的列表是一个 Tab 停靠点,上下、Home、End 移动,回车使用,行带列表项语义,搜索提示、全部、使用、无匹配的文案进内建中英目录。7df3045:`SelectableList` 在 AX 树里是列表(`label` 命名),`ListItem` 是列表项(标题为名,带说明、选中、在可选列表中的序号与总数);原先行无角色,不在 AX 树里。af54877:`NumberInput::blank` 显示占位文字而非数字(混合选区),空着回车或失焦不提交,键入后照常。408f2b0:`CommentSidebar` 与 `RelativeTime` 的文案进内建中英目录;属主不处理“解决”时(无 `on_resolve`)不分未解决与已解决,一列显示全部。bcf0378:`CommentSidebar` 的批注列是列表,每条是 Tab 停靠点(列表项语义,名为作者与正文,带选中、序号与总数),回车或空格选中。71bfc93:`init_for_tests`(`test-support` 下)装主题与键位、不要资源,应用单测可渲染 Ely 组件。617a8d6:`FindWidget` 在查询框里按 Esc 即关闭(调 `on_close`)。2ce3368:`ContextMenu` 可定宿主大小。3d08744:`DescriptionList` 的值占满所在列,其中的文本框随之全宽;同批带入上游的图表、分页改动。a6b1bb0:`SignaturePad` 的“在此签名”与“清除”进内建中英目录;同批带入上游 P01 至 P15(各组件的越界与失效状态记日志而不 panic)。490b022:`ChangesList` 分列冲突、已暂存、改动、未跟踪、已忽略(各有其动作:冲突与已忽略只可打开),标题与提示进内建中英目录;同批带入上游 P16 至 P20。7bb53f4(ElyTree 用):`ChangesList` 行数可缺、按区报按下、当前行;`DiffViewer` 外算的段、块按钮、可选的变动行、逐行读给辅助技术;`CommitList`、`FileHistory`、`BlameView`、`ThreeWayMerge` 只画可见行,`CommitList` 近尾加载;`DescriptionList::text`;`SegmentedControl` 各段为单选;`BranchList`、`TagList` 可选中、只画属主处理的行内动作;`marked`、`conflicts`、`resolve` 按标记宽度(`MARKER` 为默认 7),a284c71 起只按 LF 断行;c5799cc:`StashList` 文案进内建中英目录,行可选(`selected`、`on_pick`),只画属主处理的行内动作;7fab072 至 3741d5d:分支、标签、储藏的行分两行(上行名称与行内动作,下行说明与行尾文字),窄时名称与说明先让;行内动作一律显示(原先只在悬停时显示,键盘停在其上时看不见),改动列表同;各 git 行带透明的 1 像素边框,焦点框才画得出(原先 `focus_ring` 无边框宽度,键盘焦点不可见);同批带入上游 P21 至 P29。4f8f3bd:`Changed::lines` 可缺(二进制文件、文件夹不计行数,不显示);37c5187 缩短其注释。19b0b29:`DiffViewer::from_stretches`(别处算好的差异,如 Git 自己的)、`hunk_action`(每块标题上一个按钮)、`selected` 与 `on_line`(改动行可按,带 Shift),`diff::mark_words` 给外来的行标出改动的词,“行内”“并排”“N 行未改动”进内建中英目录;30fea9e:有块按钮时各行同高,底色相接。edd2c12:`ChangesList` 每次按下带分区(`ChangeSection`),行是 Tab 停靠点(列表项语义),`selected` 标出当前文件。2e080b8:`SegmentedControl` 每段是单选按钮(名为其文字,带选中状态),原先不在 AX 树里。e226d79:差异查看器的行模型与配色移入 `git/rows.rs`(文件不超过 500 行);7b807a9 缩短其注释。51d2aea:`CommitList`、`FileHistory`、`BlameView` 只画视口内的行(`uniform_list`,填满所在的框;`CommitList` 收 `Rc` 的提交与 `lanes` 算好的图,`on_end` 在接近末尾时要下一页),各行是 Tab 停靠点(列表项语义,选中状态;Blame 每段首行可按,名为作者、时间与摘要),`FileHistory` 的行数可缺;`CommitInput` 的“提交到”“修改最近提交”进内建中英目录。e6220ad 与 3fc5a46:提交行窄时作者、提交号与时Line truncated
- 2026-10-05:ElyTable 透视表与图表(C09.4,TABLE-10、11)。补丁 `0019-lok-calc-pivot-tables.patch` 与 `0020-lok-calc-charts.patch`:`.uno:DataPilotExec` 收 `Pivot`(JSON:源区域、行、列、筛选、值字段与函数 sum/count/average/max/min),光标在透视表上即原地修改,否则从源区域在新工作表创建;行、列、筛选里的空名表示内核的数据布局字段(多个值字段横排或竖排的位置),读出与写回都保留,已在表中的行、列、筛选字段保留各自的小计函数,读出的布局原样写回不改表;照内核布局对话框的步骤(`ConvertOrientation`,保留各字段的成员、排序与版式),新表的筛选按钮、钻取、展开收起都关(同对话框初值);放到新工作表时插表与透视表是两步撤销(同桌面版)。刷新 `.uno:RecalcPivotTable` 在内核里本就不进撤销(它改缓存)。`.uno:InsertObjectChart` 收 `Chart`(JSON:类型 column/bar/line/scatter/pie、标题、图例、X/Y 轴标题;`other` 保持原类型),LOK 下不开图表向导、插入为一步撤销、插入后图表选中;给出的区域每段都要解析得了、所指工作表都要存在,否则插入与修改都拒绝、文档不动(内核原会改用示例数据,修改时 `ChartModel::setArguments` 抛出未捕获的异常使 worker 退出);`.uno:ChangeChartData` 也收 `Chart`,区域与外观合成一步撤销(撤销保存整个图表的图示与主标题的副本,堆积等外观以外的设置一并恢复)(列表动作带当前视图号,否则 LOK 拒绝撤销别的视图的动作)。读取:`.uno:Chart`(选中图表的对象名、区域、表头、外观)、`.uno:PivotTable`(光标处透视表的名称、位置、源区域、字段与布局),`.uno:SelectedRange` 加 `data`(选区内数据的绝对区域)。界面在“插入”菜单:数据透视表、刷新数据透视表、图表;对话框在光标处有透视表、或有选中图表时为修改,否则以选中的数据(单格扩到数据区)新建,源与图表区域取 `data`,不含空行。为对话框发起的读取(`Tabs::ask_in`)回来时,发起的工作簿已不是当前标签即丢弃,不为隐藏的工作簿开对话框(ElyTable 全部对话框、合并询问)。没有数据时恢复原光标(`select.data` 会把单格标成区域,回车随之只在格内打转)。公式语法是 Excel A1,区域写 `Sheet1!$A$1:$C$5`。
@@ -229,13 +221,12 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
- 2026-10-06:ElyShow 输出与演示检查(C10.6,SHOW-13、14,QA-12)。补丁 `0037-pdf-impress-handouts.patch`:PDF 导出的 `ExportHandouts`(每页张数 1、2、3、4、6、9)照打印的讲义版式(`SdPage::CalculateHandoutAreas`,3 张带笔记线)把 `PageRange` 内未隐藏的幻灯片排到讲义页上;其他张数不出页,导出失败。讲义版式会改文档的讲义页,所以讲义与图片都在另存的 ODP 副本上做(`output::on_copy`,做完关闭并删除)。`Pdf::handouts` 经 worker 的 `EXPORT_PDF` 传入。导出对话框:内容 PDF、图片、讲义,范围全部、当前、从–到,讲义每页张数(默认 3);打印对话框:幻灯片或讲义,生成同一 PDF 后交系统打印面板。图片每张未隐藏的幻灯片一个 PNG,宽 1920,副本设浅色方案,文件名 `名称-序号.png`,所选文件夹里任一目标已存在即整体不写。PPTX/ODP 由保存与另存为负责。补丁 `0038-lok-impress-slide-check.patch`:读 `.uno:SlideCheck`(每张幻灯片与母版:文字所用字体族、链接图片与 OLE 链接及是否缺失、内核不能显示的对象:读不出的图片、无数据的 OLE;缺失的链接图片只作为链接报一次)。演示检查以内核报的缺失字体(`FONTS_MISSING`)为准,按所用字体定位到幻灯片或母版;媒体缺失与媒体链接取自 `.uno:SlideShow`;点一行转到所在第一张幻灯片。系统打印面板只经 `ely_office::print_later` 打开:它在前台任务里、GPUI 放开应用之后运行(面板的模态循环会回调 GPUI,在实体更新里打开即借用冲突崩溃),三个应用共用。图片输出先以 `create_new` 占下全部目标(不跟随符号链接),任一冲突或失败都删去本次占下的文件。输出与检查的对话框由后台读取打开,回来时须仍是最近一次请求(`Deck::ask_dialog`、`dialog_due`)且没有别的对话框;对话框种类或当前标签一有变化,此前的请求即作废(`watch_dialogs`,随文档通知)。图片的张数与隐藏取自写出的副本本身;PNG 写完显式 flush。读不出的图片按载入后的状态判定(`NONE` 或解码失败的 `Default` 占位);OLE 链接检查源文件是否在。
- 2026-10-06(用户定):ElyPDF 签名与权限。验证签名时证书信任取操作系统的信任库(macOS 钥匙串的信任设置;Windows、Linux 随其构建)。可视签名三种来源:手写、键入姓名、图片文件;可视签名不是数字签名。只凭用户密码打开、带所有者限制的文件照限制办(打印、复制、编辑等被拦并说明原因),输入所有者密码可解除;改保护须所有者权限。
- 2026-10-06(用户定):OCR 随包语言数据用 tessdata_fast,英文、简体中文、繁体中文(eng、chi_sim、chi_tra)。
- 2026-10-06(用户定):ElyMail 在八个应用里最后做:任务表中 C12 移到 C15 之后、C16 之前;CEF 内容宿主随 ElyMark 先建(C14.0),邮件编号依次前移。
- 2026-10-06(用户定):ElyMail 在八个应用里最后做:任务表中 C12 移到 C15 之后、C16 之前;邮件编号依次前移。
- 2026-10-06(用户定):`ely-office` 的外壳不再只服务 LOK:`Host` 带关联类型 `Engine`(`ely_office::Engine`:后台载入、视图、撤销历史的标记、写出、关闭,视图的落定、编辑计数、待落地、焦点与事件),`Tab` 与 `Core` 按引擎取文档、视图与 worker 类型。LOK 的实现在 `ely_office::lok`(`Lok`、`start`、只对 LOK 的 `Commands` 即 `run_active`/`run_in`;查找替换、面板、图表仍只限 LOK);MuPDF 的在 `apps/ely-pdf/src/engine.rs`(`Pdf`;worker 首次载入才起,`Mupdf::get`)。`Kind::macros` 为 `None` 表示该格式无宏,不扫文件。ElyPDF 每个标签一个 `PdfView` 实体(页面、图块、文字、选择、找到的词都在里面,自己绘制),`Viewer` 只管标签条、书签栏、查找框与状态栏;打开、密码、关闭询问与保存走外壳。外壳的密码框:对话框开着时后到的加锁文件排队(`Core::locked`),对话框关闭(含取消、提交)后下一个再问;每次询问一个身份(按输入框实体),连续询问的 Esc 各归各的;提交时对话框先关、再载入。ElyDoc 的加锁测试原先清的是应用自己的对话框字段,改为清外壳的。
- 2026-10-07:本地 Web 内容宿主(C14.0,§17.3)。`crates/ely-web-content`:CEF 154.0.32(Chromium 154.0.8037.58,cef-rs a712ad9,`default-features = false`),`scripts/fetch-cef.sh` 取 minimal 包到 `engines/cef`,`.cargo/config.toml` 的 `CEF_PATH` 让构建用它。进程:应用的 `main` 先调 `ely_web_content::subprocess()`(参数里有 `--type=` 即为 CEF 子进程,载入框架后 `execute_process`,不碰 GPUI);开发构建子进程即应用本身的二进制;macOS 包里是五个辅助应用(`<二进制名> Helper`、`(GPU)`、`(Renderer)`、`(Plugin)`、`(Alerts)`,`Contents/Frameworks`,`LSUIElement`,可执行文件为 `ely-web-helper`),框架在 `Contents/Frameworks`,`packaging/macos/bundle.sh` 对依赖 ely-web-content 的应用自动放入并先签内层;查找次序 `ELY_CEF`、包内、辅助应用所在、构建时的 `engines/cef`。启动:`start` 在 GPUI 里只做准备(载入框架、给 NSApp 的类补上 Chromium 要的 `isHandlingSendEvent`、`setHandlingSendEvent:` 与包住 `sendEvent:`,并加 `CrAppProtocol`、`CrAppControlProtocol`),`initialize` 与建浏览器都在消息泵任务里、GPUI 的更新之外进行(实测:在更新里同步调 CEF,CEF 转动 AppKit 的循环,GPUI 报 RefCell 已借用、整窗不画);浏览器在泵里同步建(`browser_host_create_browser_sync`;原为异步,建好之前视图已释放时浏览器无人关闭),视图要么在建之前已走(不建),要么有浏览器可关。凡在 GPUI 里对 CEF 的调用(输入、焦点、尺寸、脚本、缩放、重新载入、关闭)都排进主线程的队列,由泵在下一轮、更新之外依次执行(实测:在实体释放时同步关浏览器,CEF 转动 AppKit 的循环去校验菜单,GPUI 的菜单回调遇已借用的应用状态而中止,约三成复现)。泵:外部消息泵,按 `on_schedule_message_pump_work` 的时间,另照 cefclient 每轮后以 1/30 秒兜底(实测:只按请求时第一轮后 CEF 再不请求,页面不载入);空闲时 CPU 约 1.5%。离屏渲染须用 Alloy 风格(`runtime_style`;Chrome 风格不出画面)。同一缓存目录只允许一个实例,第二个启动时报出占用它的进程号。画面:`on_paint` 的 BGRA 直接成 GPUI 图像,上一帧在下一次绘制时从图集释放(`drop_image`);光标随页面。输入:鼠标点击、移动、滚轮(行按 40 点),键照 cefclient 的 macOS 做法:键盘按下带字符与系统键码发 `KEYDOWN` 再发 `CHAR`(退格、回车、方向等用 AppKit 的字符),可打出字的键等 GPUI 的输入法回调后以所打的字发同样两个事件,多字或组字经 `ime_commit_text`/`ime_set_composition`(无替换范围时用 CEF 的“无范围”,两端为最大值;传空指针 CEF 不收);松开带同一字符(不带时 Chromium 当作又按一次,实测退格删两个字);拷贝、剪切、粘贴、全选走框架命令。焦点随 GPUI 的焦点给页面。页面与应用之间:CEF 的消息路由,页面调 `window.elyQuery({request, onSuccess, onFailure})`(`onSuccess` 与 `onFailure` 都必须有,缺一路由即不发;页面载入时路由未就绪,早到的查询丢失),应用收到 `Event::Query(请求, 回复)`;应用以 `run_js` 调页面。主题:每次载入与主题变化时把调色板、代码色、字体与字号比例、深浅写成 `:root` 的 `--ely-*` 变量与 `color-scheme`。缩放 `set_zoom(倍数)`。页面只许载入所在内容文件夹里的文件:按真实路径判定(`..`、百分号转义、指向外面的符号链接都算外面,文件须存在),导航与每个资源请求(脚本、图片、样式、请求等)都过这道门,资源另许 `data:` 与本页的 `blob:`,其余一律取消;另在每个 CEF 进程以 `--proxy-server=127.0.0.1:9` 与 `--proxy-bypass-list=<-loopback>` 把一切网络(含本机回环与 WebSocket,后者不经资源回调)送往不通的代理;WebRTC 走 UDP 不经代理,故另以 `--force-webrtc-ip-handling-policy=disable_non_proxied_udp` 只许它经代理,并在每个页面上下文建立时、页面脚本之前删去 `RTCPeerConnection` 等构造(渲染进程的 `on_context_created`);页面的地址由真实路径逐字节转义生成(文件名含 `#`、`?` 照样载入);用户点出的链接以 `Event::Leave` 交给应用;`WebView::open_inert` 开的页面不运行脚本(`BrowserSettings.javascript` 关),ElyMark 的打印与导出 PDF 暂存页用它,文档里的 HTML 脚本不运行(M-15);页面的警告与错误只记行号(警告文字与来源都可由文档伪造,不进日志);页面自己的诊断经 `elyQuery` 交应用记录。载入失败(主框架,停止不算)、渲染进程退出、CEF 起不来都报 `Event::Failed`,页面区改显示组件库的错误视图与“重新载入”(Line truncated
- 2026-10-07:ElyTree 恢复与清理、外部工具、操作规则(C13.6,TREE-13、14,§13.3,QA-19、20)。仓库页第二行:在终端中打开(macOS `open -a Terminal`,其他平台报未构建)、在访达中显示(`reveal_path`)、清理未跟踪的文件…;页底“引用日志”列出 HEAD 最近 200 次移动(`reflog show -z`,位置、Git 写的原因、时间),选中即中间显示该提交(同提交详情,`Whose::Reflog`),上方“在此新建分支…”(找回删掉的分支)与“把当前分支移到此…”:有已暂存的改动即拒绝(`reset --keep` 会丢掉它们,请先提交或取消暂存);否则在后台算出离开该分支的提交,对话框写明分支、从哪到哪、离开的提交(引用日志仍可找回)与“工作区改动保留”,确认后 `reset --keep`(Git 在改动冲突时拒绝;没有任何按钮做硬重置),执行前在写队列里核对 HEAD 仍在预览时的分支(完整引用名,或仍游离)与提交、仍无暂存,任一变了即什么也不做。引用日志的时间是该次移动的时间(`--date=unix` 下的 `%gd`),不是提交时间;选中按事件本身(提交、原因、时间与它在全部引用日志中自最早起的序号,后者新移动不改,同一秒同原因的两次也分得开),同一提交的多条不会一起选中;之后有新的移动时该事件的位置后移,选中与详情跟着它,事件已不在引用日志里即不显示。清理:对话框先由 `git clean -n -d`(勾“包括被忽略的文件”另加 `-x`)得出 Git 会删除的路径(`core.quotePath=false`,其余 C 引号照 Git 规则还原),其中的文件夹当场展开为其中的文件(`ls-files --others`,同一忽略规则,另一仓库不入),对话框逐个列出文件与文件夹;确认后先逐段查每个所列路径:仍在仓库里、途中没有符号链接、文件仍是文件、文件夹仍是文件夹(任一变了即整体拒绝,什么也不删),再以 `git clean -f [-x] -- 所列文件` 删去(只给文件,Git 不递归;此后被跟踪了的文件 Git 留下,也不穿过链接),最后自深而浅删去所列文件夹中已空的(预览后在其中新出现的文件不动,文件夹随之留下),每次都问。文件标题栏多出“在编辑器中打开”(用户在“仓库›选择编辑器…”选的程序,存在数据目录 `tools.json`,字段类型不对与读不出同样报错且不覆盖;macOS 的 .app 经 `open -a`,在后台等它结束,失败即显示其原话;未选时先问)与 Markdown 的“在 ElyMark 中打开”(`ely_platform::apps::open`:按安装布局找同级的应用,macOS 的 `名称.app/Contents/MacOS`,Windows 的 `Ely Office\名称`,开发构建的同一目录;找不到即报错)。锁(§13.3):任何写操作因 Git 的锁文件失败(`….lock': File exists`,或配置文件的 `could not lock config file …: File exists`)时,对话框说明另一个 Git 正在工作、Ely 从不删除锁文件,“重试”把同一写操作重新排队。QA-19:测试都以独立的 Git 命令核对索引、提交、引用与工作区。QA-20:冲突、钩子失败、凭据被拒(本机 401 服务器,显示 Git 的 “terminal prompts disabled” 原话)、锁、变基中断后中止、取消(停止取回)都有窗口测试,结束时仓库状态以 Git 读回核对。切换到系统 Git 目前经 `ELY_GIT`(设置页随 C18.1)。真窗口(macOS):仓库页工具行、清理预览对话框、文件标题栏的编辑器与 ElyMark 按钮,按下后同级的 ely-mark 以该文件启动。未验证:Windows 与 Linux 的终端;ElyMark 打开文件的内容(C14)。
- 2026-10-07:ElyTree 储藏、远程、工作树、子模块、LFS(C13.5,TREE-10 至 12,§13.3)。左栏第四页“仓库”:上方取回(`fetch --progress --all`)、拉取(`pull --progress`,照用户的 pull 设置)、推送、强制推送…;储藏(`stash list -z`,新的在前;“储藏改动…”问说明与是否包括未跟踪的文件,`stash push`),远程(名称与地址,`remote` 加 `config --get-regexp` 读出取回与推送地址),工作树(`worktree list --porcelain -z`,行名为文件夹名,有失效的可“清理”即 `worktree prune`),子模块(索引里的 gitlink 与文件夹自身 `.git` 的 HEAD:未检出、在记录的提交上、在别的提交上、冲突),LFS 文件(`git lfs ls-files --long --json`:工作树里是内容、对象在本机而工作树是指针、只有指针而对象不在本机);没有子模块或 LFS 文件时不列。各列表分别读出,一个读不出只在该处显示 Git 原话;切到此页时读,此后每次状态重读时重读,只取最新一次。选中一项,中间显示:储藏为提交详情的同一套(`hist::Picked`,历史与储藏共用读取与面板):改动的文件(对第一个父提交)与储藏的未跟踪文件(第三个父提交,各自对空树),选文件看差异,上方应用、弹出、删除…;远程为地址与推送地址,取回、修改…(改名 `remote rename`、改地址 `remote set-url`)、移除…(先问);工作树为路径与分支或提交、锁定与失效原因,打开(作为标签)、移除…(先问,主工作树与当前的不给);子模块为记录的与检出的提交,“检出记录的提交”(`submodule update --init`,经远程运行)与打开。用户输入的名称、地址、路径前一律加 `--`(推送的远程名同样)。表单与选择对话框(新建分支、标签、改名、身份、储藏、远程、工作树、选远程与上游)记下打开时的仓库,提交时写进那个仓库,不随当前标签变;储藏列表按每条三个字段读(说明可为空)。储藏的应用、弹出、删除按 `stash@{n}` 执行,执行前在写队列里核对它仍是所见的那个储藏(另有储藏插入即拒绝,什么也不做);弹出遇冲突时 Git 保留储藏,对话框显示 Git 原话。删除储藏、移除远程与工作树每次都问。分支页的本地分支多出“设置上游…”(列出远程分支,`branch --set-upstream-to=refs/remotes/名`)与“取消上游”。推送:有上游即 `push --progress`;没有上游时后台读出远程,问推到哪个远程(`push --set-upstream -- 远程 refs/heads/分支:refs/heads/分支`),没有远程或 HEAD 游离即说明。强制推送(§13.3):后台读出当前分支、上游的远程与远端分支、上次取回时远端的提交(租约)、此刻 HEAD 的提交(源)与远端有而源没有的提交,对话框写明远程、分支与将被覆盖的提交(或写明没有),确认后推送的就是所示的源提交(`push --force-with-lease=远端分支:租约提交 -- 远程 源提交:远端分支`),确认前本地分支再动也不改变推送内容;远端自上次取回后变了即被拒绝;没有上游或游离即说明不能。远程命令(取回、拉取、推送、子模块更新)是写队列里的一种(`Write::Remote`),经 `running::Running` 运行:Git 在自己的会话里(`setsid`,SSH 找不到终端,不会挂在终端提示上;凭据照用 credential helper 与 ssh-agent),进度逐行显示在中间上方的提示条,可停止(向整个进程组发 SIGTERM,Git 自行清理;已结束的不再发);用户停止时提示“已停止”、清掉其后的排队写,不当作失败;失败时对话框显示 Git 的 stdout 与 stderr(以回车结尾的进度行被下一行改写,只留最后一次;以换行结尾的行全部保留,钩子的每一行都在);拉取遇冲突照合并类命令判定为停下。关窗与退出时停下所有远程命令与克隆。克隆改用同一运行器。真窗口(macOS):仓库页五类列表、储藏详情与文件、工作树详情、分支行。未验证:Windows 与 Linux(停止远程命令在非 Unix 上报未构建);真实网络与 HTTPS、SSH 凭据;LFS 对象的下载(只显示状态)。
- 2026-10-07(用户定):跟随组件库最新的 main。组件库 K01 把 gpui 从 zed 仓库的提交换成 crates.io 的 `gpui-pre` =0.3.8(`gpui-pre-platform` 同版本),K02 让 Tab 绑在 FocusScope 的 ElyFocus 上下文下、更深的上下文先得;本项目工作区的 `gpui`、`gpui_platform` 随之改为这两个包(以 `package =` 保留原名,源码不变),组件库 pin 到 dabc1eb(其上加本项目一个提交:树与树行的无障碍角色)。升级后全工作区编译无改动。
- 2026-10-08:首版打包(R1.1)。图标(用户定:logo-design 技能出方案,选 C“实心几何”;macOS 标准 squircle,Linux 与 Windows 无底形,八个同一套设计):母版在 `assets/icons`(我按圆角矩形、圆与 30°/45° 斜线在 256 网格上画;每个颜色对白与 `#1E1E1E` 都不低于 3:1,ElyMark 原定的石板灰在深色上 1.75:1,改为琥珀 `#B87A00`;ElyTable 在 48 像素以下用简化的一版),`packaging/icons/build.sh` 以 AppKit 渲染出 icns(macOS)、512 像素 png(Linux 的 AppImage 与桌面文件)、16 至 256 像素 ico(Windows 安装程序、开始菜单快捷方式与卸载项);生成物入库,Linux 与 Windows 构建不需要 macOS。系统交给应用的文件:`ely_ui::run` 收下启动参数与 `on_open_urls`(访达的打开请求,启动前到达的也在内),应用先取 `launch_files`(有则不开空白文档;ElyMark 有则不恢复上次的标签),再以 `on_open` 处理运行中的请求;六个应用都走这一条(ElyDoc、ElyTable、ElyShow 原先不收启动参数)。macOS 包:LOK 应用只带内核,按 LibreOffice 自己的布局并入本应用:内核的库进 `Contents/Frameworks`(合并库构建留下的“invalid - merged lib”占位文件不拷,其余非代码文件即报错),内核的资料进 `Contents/Resources`,另有 `MacOS/ely-lok-worker`;`install_dir` 先找包内(`Frameworks/libmergedlo.dylib`)。不带 `LibreOffice.app` 本身:原先整份嵌入时,其快速查看与聚焦插件、`soffice` 等启动程序与文档类型被系统登记,系统设置里出现“LibreOffice 扩展”(用户发现;已注销本机七处登记);测试也不再运行 LibreOffice 应用(样本改为 `tests/fixtures/office` 的固定文件,运行开发构建的 `soffice` 会让系统重新登记它)。ElyPDF 带 `MacOS/ely-pdf-worker` 与 `Resources/tessdata`(三种语言),OCR 数据先找应用旁再找包内;ElyTree 带 `Resources/git`,其中每个 Mach-O 先签(Git 以 `INSTALL_SYMLINKS` 构建,内建命令是指向同一程序的相对链接;原先是硬链接,拷进包后变成约 150 份副本、689 MB;以 `MACOSX_DEPLOYMENT_TARGET=14.0` 构建,原先随本机 SDK 要求 macOS 27);ElyMark 用同级 ElyTree 包里的 Git。文档类型(`packaging/macos/documents.sh`)一律 `Alternate`,不抢系统默认:Doc 为 docx、odt、doc、rtf;Table 为 xlsx、ods、xls、csv、tsv;Show 为 pptx、odp、ppt;PDF;Markdown;Tree 为文件夹(查看者)。worker、内核、OCR 数据、CEF 与页面的所在路径都写日志,`tests/scripts/release-macos.sh` 据此核对都在包内。许可(§21.2、§21.3):每个包的 `Resources/Licenses` 有 Ely 的 AGPL 全文(仓库根 `LICENSE`)、`rust-crates.txt`(`packaging/crates.py`:本机目标下链进各二进制的全部 crate 与其许可文件,无许可文件的取 `packaging/licenses` 里该表达式的标准文本并写明作者,都没有即失败)与所带引擎的许可:LibreOffice 的 LICENSE、NOTICE、CREDITS;MuPDF 的 COPYING 与其编进去的每个第三方及每组内嵌字体(按构建实际嵌入的)的许可、OpenSSL、tessdata(Apache-2.0,按提交与散列取)、Great Vibes 的 OFL;Git 的 COPYING 与 LGPL-2.1、git-lfs 的 LICENSE.md(发布包里没有,按标签与散列取);CEF 的 LICENSE 与 Chromium 的 CREDITS、页面的第三方通知;缺任何一份即打包失败(`packaging/licenses.sh`)。`release.sh` 另出 `ElyOffice-<版本>-source.tar.gz`:自有源码、锁文件、上游补丁与构建脚本(上游各自钉在提交,取得方式即其中的 fetch 脚本)。正式签名(hardened runtime)时 CEF 的渲染与 GPU 辅助程序带 `allow-jit`,插件辅助程序带 `allow-unsigned-executable-memory` 与 `disable-library-validation`(`packaging/macos/entitlements`;原先不带,V8 保留不到代码区,页面载入失败);`tests/scripts/hardened-macos.sh` 以 `ELY_SIGN_IDENTITY` 签 ElyMark 并核对页面画出。ElyTable 同时收到多个文本文件(访达一次交来多份 CSV)时,读完的导入排队,没有对话框开着才逐个询问(原先后到的一份替换前一份,前一份静默丢失)。`packaging/macos/release.sh` 按 `apps.tsv` 的批次出各应用的 dmg;本机只有 Apple Development 证书,无 Developer ID,未公证。未验证:Intel 包(只建了 arm64)、公证、Linux 与 Windows 的包(本机不能建)。
- 2026-10-08:首版打包(R1.1)。图标(用户定:logo-design 技能出方案,选 C“实心几何”;macOS 标准 squircle,Linux 与 Windows 无底形,八个同一套设计):母版在 `assets/icons`(我按圆角矩形、圆与 30°/45° 斜线在 256 网格上画;每个颜色对白与 `#1E1E1E` 都不低于 3:1,ElyMark 原定的石板灰在深色上 1.75:1,改为琥珀 `#B87A00`;ElyTable 在 48 像素以下用简化的一版),`packaging/icons/build.sh` 以 AppKit 渲染出 icns(macOS)、512 像素 png(Linux 的 AppImage 与桌面文件)、16 至 256 像素 ico(Windows 安装程序、开始菜单快捷方式与卸载项);生成物入库,Linux 与 Windows 构建不需要 macOS。系统交给应用的文件:`ely_ui::run` 收下启动参数与 `on_open_urls`(访达的打开请求,启动前到达的也在内),应用先取 `launch_files`(有则不开空白文档;ElyMark 有则不恢复上次的标签),再以 `on_open` 处理运行中的请求;六个应用都走这一条(ElyDoc、ElyTable、ElyShow 原先不收启动参数)。macOS 包:LOK 应用只带内核,按 LibreOffice 自己的布局并入本应用:内核的库进 `Contents/Frameworks`(合并库构建留下的“invalid - merged lib”占位文件不拷,其余非代码文件即报错),内核的资料进 `Contents/Resources`,另有 `MacOS/ely-lok-worker`;`install_dir` 先找包内(`Frameworks/libmergedlo.dylib`)。不带 `LibreOffice.app` 本身:原先整份嵌入时,其快速查看与聚焦插件、`soffice` 等启动程序与文档类型被系统登记,系统设置里出现“LibreOffice 扩展”(用户发现;已注销本机七处登记);测试也不再运行 LibreOffice 应用(样本改为 `tests/fixtures/office` 的固定文件,运行开发构建的 `soffice` 会让系统重新登记它)。ElyPDF 带 `MacOS/ely-pdf-worker` 与 `Resources/tessdata`(三种语言),OCR 数据先找应用旁再找包内;ElyTree 带 `Resources/git`,其中每个 Mach-O 先签(Git 以 `INSTALL_SYMLINKS` 构建,内建命令是指向同一程序的相对链接;原先是硬链接,拷进包后变成约 150 份副本、689 MB;以 `MACOSX_DEPLOYMENT_TARGET=14.0` 构建,原先随本机 SDK 要求 macOS 27);ElyMark 用同级 ElyTree 包里的 Git。文档类型(`packaging/macos/documents.sh`)一律 `Alternate`,不抢系统默认:Doc 为 docx、odt、doc、rtf;Table 为 xlsx、ods、xls、csv、tsv;Show 为 pptx、odp、ppt;PDF;Markdown;Tree 为文件夹(查看者)。worker、内核、OCR 数据的所在路径都写日志,`tests/scripts/release-macos.sh` 据此核对都在包内。许可(§21.2、§21.3):每个包的 `Resources/Licenses` 有 Ely 的 AGPL 全文(仓库根 `LICENSE`)、`rust-crates.txt`(`packaging/crates.py`:本机目标下链进各二进制的全部 crate 与其许可文件,无许可文件的取 `packaging/licenses` 里该表达式的标准文本并写明作者,都没有即失败)与所带引擎的许可:LibreOffice 的 LICENSE、NOTICE、CREDITS;MuPDF 的 COPYING 与其编进去的每个第三方及每组内嵌字体(按构建实际嵌入的)的许可、OpenSSL、tessdata(Apache-2.0,按提交与散列取)、Great Vibes 的 OFL;Git 的 COPYING 与 LGPL-2.1、git-lfs 的 LICENSE.md(发布包里没有,按标签与散列取);ElyMark 带 RaTeX 嵌入的 KaTeX 字体的 OFL 与说明;缺任何一份即打包失败(`packaging/licenses.sh`)。`release.sh` 另出 `ElyOffice-<版本>-source.tar.gz`:自有源码、锁文件、上游补丁与构建脚本(上游各自钉在提交,取得方式即其中的 fetch 脚本)。ElyTable 同时收到多个文本文件(访达一次交来多份 CSV)时,读完的导入排队,没有对话框开着才逐个询问(原先后到的一份替换前一份,前一份静默丢失)。`packaging/macos/release.sh` 按 `apps.tsv` 的批次出各应用的 dmg;本机只有 Apple Development 证书,无 Developer ID,未公证。未验证:Intel 包(只建了 arm64)、公证、Linux 与 Windows 的包(本机不能建)。
- 2026-10-08(用户定):深色主题不用纯黑或近黑,底色取柔和的灰 `#252525`。组件库 fd0efc7:深色色板 bg `#252525`、surface `#2c2c2c`、sunken `#202020`、overlay `#323232`、hover `#383838`、active 与 border `#3f3f3f`、border_strong `#555555`;为让说明文字在较亮的 hover 与 active 上仍有 4.5:1,muted 提到 `#c4c2c0`、subtle 与代码注释提到 `#acaaa8`;色盲友好图表的紫与橙改为 `#9d8dec`、`#ffd93d`(3:1 且各色两两分得开)。`ely.xcd` 的深色方案随之:页面 `#2c2c2c`、页外 `#252525`、边界与网格 `#555555`、域底纹 `#3f3f3f`。跨应用同步外观、设置里的浅色/深色/跟随系统、Omarchy 下随 Omarchy 主题,本来已有(共用 `settings.json` 加文件监视;`settings.sh` 核对 ElyPDF 选深色后 ElyTree 即刻变深);Omarchy 的读法与 OmaPhoto 相同(`~/.local/state/omarchy/current/theme/colors.toml`、`mode` 与具名颜色)。
- 2026-10-08:图像的显存与内存(C20.2 长时测试查出)。GPUI 把每张画过的 `RenderImage` 留在窗口的图集里,直到调 `window.drop_image`;LOK 的图块、PDF 的图块与缩略图、演示的缩略图、动画预览与放映图层原先都不释放,ElyDoc 编辑保存 60 轮后应用进程涨到约 2 GB(内核不涨)。组件库 `primitives::Retired`:不再画的图先交给它,下一次绘制时释放(`release` 只放别处已不持有的,`release_all` 给自知不再画的属主,`append` 接过关闭者的);LokView、PdfView 在替换、挤出、清空图块时交出旧图,绘制前释放;ElyShow 每个标签一份,关闭的标签与结束的预览交给 Deck;放映按窗口各一份(观众屏、演讲者视图),离场中的幻灯片还在画时其图层等它结束。修后同样 100 轮在 340 至 440 MB 之间起落。
- 2026-10-08:性能基准(C20.1,§20.1)。`tests/perf/bench.py` 在发行构建上测 PERF-01 至 08(每项 30 次,P50、P95,含全部子进程的内存),样本由 `tests/perf/samples.py` 按固定种子生成到 `engines/bench`,记录写到 `docs/perf/<日期>-<机型>.md`(格式见其 README);`ELY_STALLS=1` 开主线程忙碌记录(只供测量,空闲的应用照样不做事)。实测改动:Doc、Table、Show 原先要等内核启动完(约 2.5 至 3.9 秒)才开窗口,ElyDoc 日常启动 P95 3.9 秒不达标;现 `Worker::launch` 只起进程,内核的版本与图块核对在第一个请求前做(`Worker::ready`,任何请求都先过它,未核对的 worker 收不到请求),`ely_office::start` 在后台核对并设作者名,失败即 `startup::fail`;窗口约 0.15 秒可操作,空白文档在内核首次载入后出现(中位约 2.7 秒,其中约 2.2 秒是内核首次载入文档)。启动参数改为按当前目录取绝对路径(原相对路径成了 `file://相对` 而打不开)。补丁 `0039-skia-macos-no-unused-font-manager.patch`:macOS Skia 首次画字时以全部系统字体再建一个无人使用的 `SkFontMgr`(约 370 个字体枚举加排序,主线程约 175 毫秒),新文档的第一个字与打开文档的首块带字图块因此迟到;去掉后 PERF-03 的 10 次对比 P95 由 2.6 秒降到 1.5 秒。PERF-02 的计时原以输入落地时的代际为基准,内核的作废事件先于落地回报到达时,该次编辑的图块不计,钟一直走到下一次按键(记下的是按键间隔约 250 毫秒);现按编辑的修订认定(`view/clock.rs`:修订不低于该编辑的批次所升的代际,其图块即为所求),实测中位 2 毫秒、P95 5 毫秒。PERF-08:关闭的标签原把其图块留在 GPUI 的图集里(每个文档约 20 MB 显存,40 次新建关闭后应用涨约 250 MB);`Engine::retire` 交出关闭视图的全部图像(LOK 图块、PDF 图块与缩略图、ElyMark 页面的画面),外壳在窗口的下一帧释放,修后 40 次在 454 至 462 MB 之间。历史首屏改为从按下“历史”计(原从进程启动计,含打开仓库)。R1 后基准更严:PERF-03、04 到视图收下的第一块图块(`lok: tile (x, y) shown`),其间有图块画失败即整次作废;DOCX 样本原以单独一段的分页符分页,内核只排出 60 余页(`Heading1` 段上的 `pageBreakBefore` 同样丢页),改为分页符放在标题段内,PERF-03 之前先核对恰为 100 页(视图记录页数);PERF-02 每键须有自己的计时,缺即失败;PERF-08 循环前记基线,记内核中的文档数(worker 每次载入关闭写 `N documents open`),文档数须回到基线,文件数自第一次之后不变(第一次打开模板对话框时进程一次性载入两个系统字体与两个 AppKit 本地化表)。worker 的启动核对失败后保持失败,此后一切请求被拒(原核对失败的状态与成功相同,第二次即放行)。空白文档就绪由约 2.5 秒降到约 1.0 秒,发生在本次重建内核之后,与 0039 无关(对比实测),原因未查明。
@@ -243,23 +234,15 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
- 2026-10-08:兼容清单与往返验证(C19.1,§19.1、§19.2)。`docs/compatibility.md` 是随版本发布的兼容清单(版本、内核与补丁、验证机,各应用的编辑保存、导入受限与输出,已验证与限制),版本或格式行为变了即同步改它。往返用例在 `crates/ely-lok/src/client/tests/independent.rs`:Ely 编辑、保存、关闭、再打开、再编辑、再保存,再由 `tests/scripts/ooxml.py` 检查;它不用 LibreOffice,自己解 OOXML,预期值自己算(整数、日期序号、错误值、文本严格相等,浮点按用例声明的容差),DOCX 另交 macOS 的 `textutil`;没有 python3 时报 SKIP。与 Microsoft Office 的交叉检查未做(本机没有 Office)。
- 2026-10-08:恢复副本、引擎停止与自动保存(C06.5,§6.2、§6.3)。Doc、Table、Show、PDF 共用 `ely_office::Recovering`:每 10 秒为有改动、已落定、不在导出的标签经 `Engine::snapshot`(写副本,文档不动)写一个恢复点(`ely_files::Recovery`,限额取存储设置;加密文档封存),索引在数据目录 `recovery/index.json`;崩溃标记照 ElyMark 的 `Crash`,下次启动把崩溃留下的最新点开成未命名副本并标为未保存,提示写明只恢复了内容,光标与撤销历史没有。引擎进程退出(`Engine::ended`,客户端 `try_wait`)时 `ely_office::Stopping` 在上方显示提示条(原因、最近一次恢复点的时间)与“重新打开文档”:`Engine::restart` 换上新 worker(LOK 照原配置与作者名重起,PDF 下次打开再起),有改动的标签从最新点开成副本,无改动的从文件重开,有改动却没有恢复点的列出;被放下的标签迟到的失败不再提示,指向它们的关闭询问一并撤下。重开的副本接过原恢复点的文件夹(封存随之),旧点留到新标签写下新点为止;载入或解封失败的条目留在索引里,下次崩溃后的启动再给。需要密码的副本(加密 PDF 的恢复点照原加密写出)照样询问,答后仍是未命名副本(`Source::Locked`)。引擎是否退出在出错时与每次恢复计时时查,空闲的引擎退出也会显示。恢复索引经 `ely_files::save_atomic` 暂存后替换,写不进时上一份照旧可读;写不进即提示一次、每次计时重试。干净结束后的下次启动只删已接过或正常的条目,没能重开的留着再试。自动保存(设置“文档”页,每个应用一个开关,存在应用的 `settings.json` 的 `autosave`,默认关):只对本应用自有格式(`Kind::save_as`)、无风险(宏、签名、修复)、状态为“已编辑”的标签,编辑计数停 2 秒不变即走普通保存;保存失败、外部改动后状态不再是“已编辑”,不再自动写,等用户处理。Cmd+W 关闭标签是共用命令(`ely_ui::commands::CloseTab`,原来只有 ElyPDF 绑了;文档应用里 Cmd+W 落进 LOK,内核按自己的 Ctrl+W 关掉框架,worker 下一次绘制或读取即在已释放的文档上段错误);worker 另拒绝内核关闭框架与退出的快捷键(Mod1 加 W、F4、Q)。真窗口(macOS):`recovery.sh`(崩溃后副本回来、关闭询问;只杀引擎后提示条、重新打开、文字回来、关闭询问),`autosave.sh`(关时不写,开关存下,开后停手即写,外部改动后不覆盖)。未验证:Table、Show、PDF 的真窗口恢复(同一外壳);Windows 与 Linux。
- 2026-10-08(用户定):首版发布六个应用(ElyDoc、ElyTable、ElyShow、ElyPDF、ElyTree、ElyMark);ElyMail 与 ElyAgent 第二批发布。ElyMark(C14)做完即开始首版的完整测试与打包,C12、C15 留到第二批。
- 2026-10-08:数据管理(C18.3,§18.3)。设置的“存储”页(每个应用):本应用数据目录里的日志(`ely.log`)、网页内容缓存(`web`,只 ElyMark 显示)、恢复副本(`recovery`)各自的大小与清除(`ely_platform::storage`);日志当场清空(日志器以追加打开,截短不坏句柄),缓存与恢复副本在用着,写下记号(`.clear-web`、`.clear-recovery`),下次启动在设置与窗口之前删去(删不掉即启动失败、写明原因,记号留着下次再试,不打开没删掉的数据);每次清除先问,写明删什么、何时删;每个文档保留的恢复点(10、20、50)与空间(32、64、256 MB)存在应用设置的 `recovery`(严格读,不合即报错),ElyMark 写恢复点时照用(C06.5 的文档应用同)。日志超过 8 MB 时,启动时只留后 4 MB、从整行起。数据库的升级前备份与失败只读只属 ElyMail(第二批),首版的六个应用没有数据库;邮件与 Agent 历史导出同属第二批。组件库 ea4ed4c:`SettingsRow` 是以标题命名、以说明描述的组(原先行标题不进 AX 树,两个同名按钮分不出);待清除的状态以禁用的按钮写明。
- 2026-10-08:数据管理(C18.3,§18.3)。设置的“存储”页(每个应用):本应用数据目录里的日志(`ely.log`)、恢复副本(`recovery`)各自的大小与清除(`ely_platform::storage`);日志当场清空(日志器以追加打开,截短不坏句柄),恢复副本在用着,写下记号(`.clear-recovery`),下次启动在设置与窗口之前删去(删不掉即启动失败、写明原因,记号留着下次再试,不打开没删掉的数据);每次清除先问,写明删什么、何时删;每个文档保留的恢复点(10、20、50)与空间(32、64、256 MB)存在应用设置的 `recovery`(严格读,不合即报错),ElyMark 写恢复点时照用(C06.5 的文档应用同)。日志超过 8 MB 时,启动时只留后 4 MB、从整行起。数据库的升级前备份与失败只读只属 ElyMail(第二批),首版的六个应用没有数据库;邮件与 Agent 历史导出同属第二批。组件库 ea4ed4c:`SettingsRow` 是以标题命名、以说明描述的组(原先行标题不进 AX 树,两个同名按钮分不出);待清除的状态以禁用的按钮写明。
- 2026-10-08(用户定:图标照 Papirus 重画):每个应用是 Papirus 192 网格上的一个大的扁平物件(页、讲台、信封、终端),主体一种饱和色,折角为其浅色,内容为白色、至多一个点缀色,主体与内容带 Papirus 的柔和投影(下移 4 或 2、模糊、20% 黑)。macOS 为浅色 squircle 上的同一物件(占主体 86%),Linux 与 Windows 只有物件。48 像素以下用无投影、无小细节的简化版。母版由 `packaging/icons/masters.py` 写出,配色见 `assets/icons/README.md`。Papirus 图标包(`assets/papirus_icons-master.zip`,用户放入)只作参照,不随包。
- 2026-10-08(用户要求核对各系统的文件关联):文件类型只写在 `packaging/types.tsv`(应用、扩展名、MIME;`/` 为文件夹)。Linux 的桌面项由它写 `MimeType=`(原先没有,桌面环境从不提供 Ely 打开文件);Windows 安装程序由 `packaging/windows/types.sh` 生成的 NSIS 宏注册(原先没有):HKCU 下每个扩展名的 `OpenWithProgids` 与 `Applications\<程序>\SupportedTypes`,ProgID `ElyOffice.<名称>` 带图标与打开命令,ElyTree 在文件夹右键菜单加“Open in ElyTree”;只进“打开方式”,从不改默认(同 macOS 的 Alternate),卸载时删去。makensis 在本机编过 ElyDoc 与 ElyTree 的安装程序;Windows 与 Linux 上实际打开文件未验证。
- 2026-10-08(用户定:窗口缩窄时控件挤成一团、越出边框,不可接受):窄窗口的版面。外壳一次排两侧栏(`shell/panel.rs` 的 `room`,有单测):内容区至少留 360,放不下时两侧先缩到各自最窄,再先藏右栏、后藏左栏,窗口变宽即回来,用户拖的宽度不变。组件库 6d95b24 至 69265b4:`ScrollArea::vertical` 只纵向滚动、内容不得宽于区域(侧栏一律用它,原先横向不受限,整栏按自然宽度排后被裁);`PropertyGrid` 每行可折行,放不下时控件到标签下方,标签列 104;全部图标的 `SegmentedControl` 与 `Tabs` 放不下全部文字时只显示图标,文字作提示与无障碍名(宽度取上一帧量得的条宽与文字排版宽度);`StatusBar` 两侧与各项可缩,文字以省略号结尾;`Banner` 文字折行;`Button` 不再拒绝收缩,只在无法折行时缩并以省略号结尾;未聚焦的单行文本框显示文字开头(原插入点在末尾,看到的是尾部);`Switch::name` 只给无障碍名(行已有标签时不再重复画字);`CommitInput` 的勾选与按钮一行可折。各应用固定宽度的控件框(`w_32` 等)改为占满、以原宽为上限。`tests/scripts/layout.sh`:六个应用各开样本,640、800、1100 宽下读 AX 树,任何元素越出窗口左右即失败,并留截图供目视(栏内裁切 AX 看不出)。缩放(用户问为何 Doc、Table、Show 不能缩放;视图早有 `set_zoom`,界面从未接上):`ely_office::Zooming`,Cmd+= / Cmd+- / Cmd+0,“视图”菜单三项,状态栏右侧 `ZoomControl` 的属主模式(组件 69265b4:显示属主给的百分比、按下交属主;原只缩放整个界面);Cmd+滚轮与触控板捏合以指针处为中心缩放(`view/zoom.rs`,档位 25% 至 400%);ElyShow 的 Cmd+0 回到适合幻灯片,手动缩放即停止适合。缩放只在内存里随标签,不跨重启。
- 2026-10-08(用户定:侧栏太占地方,要可调):侧栏宽度(C05.6)。外壳的左右侧栏(`shell/panel.rs`,八个应用共用)默认左 220、右 320(原取 `sidebar_width` 240 与 `sheet_size` 380;右 320 是 ElyDoc 属性栏不截断控件的最窄宽度,实测 280 截断数值框);内侧边缘是组件库的 `layout::resize_edge`,拖动改宽,左至少 160、右至少 280(原 220,属性栏放不下)、至多窗口的 45%;拖动停 0.5 秒后存入应用设置的 `panels`(`settings::panels`),退出时还没存的当场写入(窗口位置同),启动时严格读回(不是正数即在设置窗口写明并用默认),写入失败也在设置窗口写明;开合动画期间不显示拖动边。
- 2026-10-08:凭据与私密内容(C18.2,§18.2)。恢复密钥(加密文档的恢复副本用)存在系统凭据库(keyring:钥匙串、Windows 凭据、Secret Service);凭据库不可用或拒绝时改用本次运行的随机密钥,只在内存(`RecoveryKey { key, lasting }`,`lasting` 为假即提示恢复不跨重启),从不写成普通文件;用旧密钥封存的恢复点读不开即报“无法恢复”,不导出明文。PDF 打开密码与证书密码只在 worker 内存里、文档关闭即弃。日志:worker 的 stderr 经 `ely_ipc::relay` 按其级别并入应用日志(原先从访达启动时 worker 的日志丢失),两个 worker 与应用一样读 `ELY_LOG`、默认 info(LOK worker 原读 `RUST_LOG`,平时只出错误);日志调用不打印正文、密码、密钥;引擎(worker、内核、MuPDF)给的错误串可能引用文档,日志只记请求号与错误类别,全文只经回复交给界面;worker 的 stderr 只放行其结构化日志记录与崩溃栈帧,引擎自己打印的文字不进日志(只计数)。worker 启动失败也写成结构化记录,最后一条错误由 `Relay::last_error` 带进客户端的报错。诊断导出时姓名来源(账户名、设置)读不出即不导出并写明原因;日志尾部从整行开始。诊断(设置里的“诊断”,每个应用):先列出要导出的内容(本应用日志的最新 4 MB、macOS 上 7 天内本应用与其 worker 的崩溃报告,列表在 AX 树里),开关“去掉路径与个人姓名”默认开(凡路径与 URL 整段换成 `<path>`:有引号到引号为止,否则到行末;分段标题只写文件名;账户全名、登录名与作者名按从长到短换成 `<user>`),导出为一个文本文件(`ely_platform::diagnostics`),不上传。Git 服务的凭据照用用户自己的 credential helper 与 ssh-agent;打开文件不发任何请求。未验证:Windows 与 Linux 的凭据库与崩溃报告位置(那两端诊断只含日志)。
- 2026-10-08:配置(C18.1,§18.1)。通用偏好在 `<本机数据目录>/ElyOffice/settings.json`,各应用的在其数据目录的 `settings.json`;都经 `ely_platform::settings::Store`:带 `version`(本构建为 1;更新的版本或读不通即报错,不覆盖),写入在同目录的 `.settings.lock`(`ely_platform::lock::FileLock`,flock 或 LockFileEx,由 ely-files 移来共用)下先重读、只改所写的键、未知字段原样留下,临时文件 fsync 后原子替换;多个应用同时写不丢字段(单测八路并发)。通用字段(`settings::Shared`,类型不对即报错):外观(跟随系统、浅色、深色)、语言(跟随系统、English、简体中文;下次启动生效,设置里写明)、动效(跟随系统、减少、完整)、跟随 Omarchy(只在有 Omarchy 时显示)、作者名(空即账户名;`user::name` 先取它,ElyPDF 的批注、LOK 的批注与修订都用它)。环境变量 `ELY_APPEARANCE`、`ELY_OMARCHY_SYNC`、`ELY_REDUCE_MOTION`、`ELY_LOCALE` 仍优先。`ely_ui::settings` 启动时读入(读不出即用默认值、记下原因),以 `notify` 监视文件夹,别的应用写入后即刻重读:外观、Omarchy、动效当场换,作者名经 `observe_global` 在后台交给 LOK worker(`set_author`)。设置窗口(应用菜单“设置…”,Cmd+,,每个应用一个)用组件库 `SettingsLayout`、`SettingsSection`、`SettingsRow`、`ThemeSelector`(组件 489ef31 起三项文字进内建中英目录)、`SegmentedControl`、`Switch`、`forms::Input`:通用;Doc、Table、Show、PDF 另有“文档”(作者名,失焦或回车写入);ElyTree 另有“Git”(Git 程序,空即随带的;写入前在后台核对 2.40 或更新,不是即在框下以 Banner 说明、不写;`ely_platform::git::locate` 次序为 `ELY_GIT`、此设置、随带的,版本核对移入 `ely_platform::git::system_version`,下次启动 ElyTree 生效)。文件读不出时窗口上方以 Banner 写明原因,改动不保存。作者名与 Git 程序每次提交都编号,写入经 `Store::set_if` 在文件锁下核对仍是最新的提交才落盘(等锁期间改了主意,旧写入作废);写入失败另以 Banner 写明,键入的值留着可重试;作者框在未编辑时跟随别的应用写入的名字。版本核对先看 `--version` 的退出状态(`git::version_of`)。各应用主窗口的位置、大小与最大化、全屏存在应用的设置里(`window`,停止移动 0.5 秒后写),启动时所记之处的中心仍在某块显示器上才用。另:ElyMark(CEF)直接启动的应用继承了 Chromium 忽略或屏蔽的 SIGTERM,测试的 pkill 杀不掉、留下进程(实测数个钟头的残留);`apps::open` 在 exec 前把 SIGTERM、SIGINT、SIGHUP、SIGQUIT 复原并清空信号屏蔽(单测:启动者忽略并屏蔽 SIGTERM 时子进程照样被终止)。未验证:Windows 与 Linux 的锁与窗口位置。
- 2026-10-08:进程与状态核对(C17.1,§17.2、§17.4)。帧(`ely-ipc`)由长度、标签、请求号与内容组成:客户端每个请求一个递增的请求号,worker 的回复带回同一个,不符即报错(`check_reply`;原先只靠一问一答的次序配对,丢一个回复即整串错位而不自知);LOK 主动推送的事件请求号为 0(`UNASKED`);PDF worker 的版本戳包含 `ely-ipc` 的源码。帧超过上限(64 MB)发送前即报错,什么也不写;worker 的结果超限时回一个错误,worker 不退出。LOK 的输出读不下去(坏帧、提前结束)时,原因经回复通道交给等待的请求,客户端停下并回收 worker 再报出(原先请求一直等进程结束)。会话:LOK 每次载入一个会话号(递增,原先用内核文档指针,释放后的地址被下一份文档复用,旧句柄读到新文档、事件被当作已关的丢掉);PDF 的 `Doc` 带打开它的 worker 会话,客户端发送前核对,别的 worker 的旧句柄被拒;worker 的文档号带进程前缀(进程号与启动时刻),别的进程的号在此不存在。修订(PDF):`Doc` 记着读到时的历史标记(标记相同即文档相同,撤销回到原标记),指向页、对象、位置的请求带上它,worker 在动内核之前核对,不符即以 `stale:` 拒绝;整份文档的请求(页数、保存、撤销、元数据、附件列表与添加、保护等)带“任意”;按键读取或移除附件带标记。ElyPDF 的编辑在排队时记下当时的句柄,落地后按新标记换句柄,过期的读取照旧按版本丢弃(画不出的块不因过期记为失败),过期的编辑提示“文档已改动,未执行”:排在别的编辑后面的编辑因此被拒(它对着的是前一个编辑落地之前的页面),再按一次即可。LOK 的修订(R2):worker 给每份文档记一个修订,会改文档的请求(键、鼠标按下与松开、拖动、文字、命令、粘贴、剪切、函数补全;不按键的鼠标移动不算)在执行前加一;按编号指向工作表、幻灯片、对象、参考线、制表位、行列的命令(参数里有 `Index`、`nTabNumber`、`TabNo`、`Part`、`Row`、`Col`、`Column` 或区域的四角,值不是 -1;-1 是新建)先发 `EXPECT` 带读到时的修订,不符即以 `stale:` 拒绝,内核不动;同一次读取得出的一组命令(对象列表的多选)只核对一次,在一次 worker 调用里依次执行(`Worker::run_all`、`LokView::run_all_at`、`Commands::run_all_in`)。`LokView::run` 拒绝带编号的命令,须经 `run_at`;外壳的 `run_in` 对带编号的命令用 `Host::read_at`(ElyTable、ElyShow 为其读取时同一次 worker 调用里取的 `Worker::now`,ElyDoc 为视图所见:推送的事件批次带修订,每个输入落地后视图另取一次修订);`Tabs::read_in` 也给读取闭包这个句柄(ElyPDF 为视图当前的句柄)。ElyTable、ElyShow 在视图所见修订新于其读取时重读,所以键入之后按编号的命令不会一直过期;记着编号的对话框(删除与重命名工作表、删除重复项、幻灯片备注栏键入期间)在读取时记下修订,确认按它执行(`Commands::run_on`),其间文档结构变了即拒绝;ElyDoc 属性栏按视图所见的修订发(删除所示的制表位)。输入失败时视图照样取回修订(请求在执行前已计数),之后的编号命令不会一直过期。过期的拒绝提示“文档在此间已改动,请重试”(`office.error.changed`)。MuPDF 打开中途(加锁检查、日志、修复)失败即释放已打开的文档与修复副本(原先每次失败留下整个文档;MuPDF 的存储另有 256 MB 上限的缓存,满后不再增长)。FFI:MuPDF 每个调用在垫片的 `fz_try`/`fz_catch` 里,返回码加错误信息;LibreOffice 只经 LOK 的 C 接口;worker 崩溃时客户端报出退出状态。工作进程:LOK 随应用窗口启动(启动即开空白文档),MuPDF 在打开第一个 PDF 时启动;不建进程池。后台:Git、OCR、缩略图、文件夹搜索、导出与系统打开都在后台任务里。不建设 §17.4 所列设施(无 RPC 服务发现、无常驻服务、Git 只有一个内核、Markdown 只有 Milkdown 与 CodeMirror)。
- 2026-10-08:配置(C18.1,§18.1)。通用偏好在 `<本机数据目录>/ElyOffice/settings.json`,各应用的在其数据目录的 `settings.json`;都经 `ely_platform::settings::Store`:带 `version`(本构建为 1;更新的版本或读不通即报错,不覆盖),写入在同目录的 `.settings.lock`(`ely_platform::lock::FileLock`,flock 或 LockFileEx,由 ely-files 移来共用)下先重读、只改所写的键、未知字段原样留下,临时文件 fsync 后原子替换;多个应用同时写不丢字段(单测八路并发)。通用字段(`settings::Shared`,类型不对即报错):外观(跟随系统、浅色、深色)、语言(跟随系统、English、简体中文;下次启动生效,设置里写明)、动效(跟随系统、减少、完整)、跟随 Omarchy(只在有 Omarchy 时显示)、作者名(空即账户名;`user::name` 先取它,ElyPDF 的批注、LOK 的批注与修订都用它)。环境变量 `ELY_APPEARANCE`、`ELY_OMARCHY_SYNC`、`ELY_REDUCE_MOTION`、`ELY_LOCALE` 仍优先。`ely_ui::settings` 启动时读入(读不出即用默认值、记下原因),以 `notify` 监视文件夹,别的应用写入后即刻重读:外观、Omarchy、动效当场换,作者名经 `observe_global` 在后台交给 LOK worker(`set_author`)。设置窗口(应用菜单“设置…”,Cmd+,,每个应用一个)用组件库 `SettingsLayout`、`SettingsSection`、`SettingsRow`、`ThemeSelector`(组件 489ef31 起三项文字进内建中英目录)、`SegmentedControl`、`Switch`、`forms::Input`:通用;Doc、Table、Show、PDF 另有“文档”(作者名,失焦或回车写入);ElyTree 另有“Git”(Git 程序,空即随带的;写入前在后台核对 2.40 或更新,不是即在框下以 Banner 说明、不写;`ely_platform::git::locate` 次序为 `ELY_GIT`、此设置、随带的,版本核对移入 `ely_platform::git::system_version`,下次启动 ElyTree 生效)。文件读不出时窗口上方以 Banner 写明原因,改动不保存。作者名与 Git 程序每次提交都编号,写入经 `Store::set_if` 在文件锁下核对仍是最新的提交才落盘(等锁期间改了主意,旧写入作废);写入失败另以 Banner 写明,键入的值留着可重试;作者框在未编辑时跟随别的应用写入的名字。版本核对先看 `--version` 的退出状态(`git::version_of`)。各应用主窗口的位置、大小与最大化、全屏存在应用的设置里(`window`,停止移动 0.5 秒后写),启动时所记之处的中心仍在某块显示器上才用。未验证:Windows 与 Linux 的锁与窗口位置。
- 2026-10-08:进程与状态核对(C17.1,§17.2、§17.4)。帧(`ely-ipc`)由长度、标签、请求号与内容组成:客户端每个请求一个递增的请求号,worker 的回复带回同一个,不符即报错(`check_reply`;原先只靠一问一答的次序配对,丢一个回复即整串错位而不自知);LOK 主动推送的事件请求号为 0(`UNASKED`);PDF worker 的版本戳包含 `ely-ipc` 的源码。帧超过上限(64 MB)发送前即报错,什么也不写;worker 的结果超限时回一个错误,worker 不退出。LOK 的输出读不下去(坏帧、提前结束)时,原因经回复通道交给等待的请求,客户端停下并回收 worker 再报出(原先请求一直等进程结束)。会话:LOK 每次载入一个会话号(递增,原先用内核文档指针,释放后的地址被下一份文档复用,旧句柄读到新文档、事件被当作已关的丢掉);PDF 的 `Doc` 带打开它的 worker 会话,客户端发送前核对,别的 worker 的旧句柄被拒;worker 的文档号带进程前缀(进程号与启动时刻),别的进程的号在此不存在。修订(PDF):`Doc` 记着读到时的历史标记(标记相同即文档相同,撤销回到原标记),指向页、对象、位置的请求带上它,worker 在动内核之前核对,不符即以 `stale:` 拒绝;整份文档的请求(页数、保存、撤销、元数据、附件列表与添加、保护等)带“任意”;按键读取或移除附件带标记。ElyPDF 的编辑在排队时记下当时的句柄,落地后按新标记换句柄,过期的读取照旧按版本丢弃(画不出的块不因过期记为失败),过期的编辑提示“文档已改动,未执行”:排在别的编辑后面的编辑因此被拒(它对着的是前一个编辑落地之前的页面),再按一次即可。LOK 的修订(R2):worker 给每份文档记一个修订,会改文档的请求(键、鼠标按下与松开、拖动、文字、命令、粘贴、剪切、函数补全;不按键的鼠标移动不算)在执行前加一;按编号指向工作表、幻灯片、对象、参考线、制表位、行列的命令(参数里有 `Index`、`nTabNumber`、`TabNo`、`Part`、`Row`、`Col`、`Column` 或区域的四角,值不是 -1;-1 是新建)先发 `EXPECT` 带读到时的修订,不符即以 `stale:` 拒绝,内核不动;同一次读取得出的一组命令(对象列表的多选)只核对一次,在一次 worker 调用里依次执行(`Worker::run_all`、`LokView::run_all_at`、`Commands::run_all_in`)。`LokView::run` 拒绝带编号的命令,须经 `run_at`;外壳的 `run_in` 对带编号的命令用 `Host::read_at`(ElyTable、ElyShow 为其读取时同一次 worker 调用里取的 `Worker::now`,ElyDoc 为视图所见:推送的事件批次带修订,每个输入落地后视图另取一次修订);`Tabs::read_in` 也给读取闭包这个句柄(ElyPDF 为视图当前的句柄)。ElyTable、ElyShow 在视图所见修订新于其读取时重读,所以键入之后按编号的命令不会一直过期;记着编号的对话框(删除与重命名工作表、删除重复项、幻灯片备注栏键入期间)在读取时记下修订,确认按它执行(`Commands::run_on`),其间文档结构变了即拒绝;ElyDoc 属性栏按视图所见的修订发(删除所示的制表位)。输入失败时视图照样取回修订(请求在执行前已计数),之后的编号命令不会一直过期。过期的拒绝提示“文档在此间已改动,请重试”(`office.error.changed`)。MuPDF 打开中途(加锁检查、日志、修复)失败即释放已打开的文档与修复副本(原先每次失败留下整个文档;MuPDF 的存储另有 256 MB 上限的缓存,满后不再增长)。FFI:MuPDF 每个调用在垫片的 `fz_try`/`fz_catch` 里,返回码加错误信息;LibreOffice 只经 LOK 的 C 接口;worker 崩溃时客户端报出退出状态。工作进程:LOK 随应用窗口启动(启动即开空白文档),MuPDF 在打开第一个 PDF 时启动;不建进程池。后台:Git、OCR、缩略图、文件夹搜索、导出与系统打开都在后台任务里。不建设 §17.4 所列设施(无 RPC 服务发现、无常驻服务、Git 只有一个内核、Markdown 只有 markdown-rs)。
- 2026-10-08:应用之间的连接(C16.1,§16;首版只做六个应用之间的三项,邮件与 Agent 的三项随第二批)。启动别的 Ely 应用(`ely_platform::apps`):先找与本应用同一安装或构建的同级(`.app` 旁、Windows 的 `Ely Office\名称`、开发构建同一目录),再找 macOS 登记过的(按包标识 `AppId::bundle_id`,`NSWorkspace`);`.app` 一律经 `open -a` 交给 Launch Services,正在运行的应用经其打开请求收下文件(不另起一个),其余直接启动。应用切换:每个应用的应用菜单有“打开 Ely 应用”子菜单,只列已安装的其他 Ely 应用(`commands::switch_to`,八个无参数动作),启动失败即提示原因。导出后查看:导出、提取、拆分、删除标记内容、清理、保护、签名与附件另存成功后,提示带“打开”:PDF 在已安装的 ElyPDF 打开(作为新标签,不当作原编辑会话),否则与其他文件、文件夹一样交系统默认程序(gpui `open_with_system`);ElyPDF 打开失败即提示原因,不改交系统。ElyMark 导出缺图、图或公式时的警告同样带“打开”。Markdown 查看版本:ElyMark 状态栏的 Git 项把文件本身交给 ElyTree,ElyTree 收到文件即打开其所在仓库并显示该文件的历史(`Tree::open_path` 按文件所在文件夹开仓库,再以仓库内路径 `file_log`);不自动提交,不建仓库;未安装 ElyTree 时提示从发行包安装。真窗口(macOS):`mark-output.sh` 核对 ElyTree 打开该文件的历史、导出警告的“打开”在 ElyPDF 打开 PDF;`release-macos.sh` 从 ElyDoc 的应用菜单启动 ElyMark。AppKit 自己的窗口恢复关掉(`startup::no_window_restore`,`ApplePersistenceIgnoreState`):Ely 自己恢复会话;原先应用被强退后再启动,AppKit 先弹模态“是否重新打开窗口”,访达交来的文件被挡在后面(打包测试实测)。窗口测试的 `seen` 不看系统菜单(苹果菜单的“最近使用的项目”会列出别处打开过的同名文件)。未验证:Windows 与 Linux 的同级查找与系统打开;系统默认程序打开(会在本机弹出别的应用,未自动测)。
- 2026-10-08:ElyMark 的单行 `$$…$$`(用户以 Pandoc 示例发现):remark-math 把它读成行内公式,序列化只写一个 `$`,与原文不同,整段退成原文块。现按原文记下每个行内公式的 `$` 数(`math.js`),两个及以上以显示模式排出(同 GitHub),写回照原数;导出同样以显示模式。Pandoc 专有的表格、定义列表、行块不是 CommonMark/GFM,照 §14.3 以原文块保留(`---- ----` 在 CommonMark 里是分隔线)。
- 2026-10-08:ElyMark 书写视图、输出、Git 配合、大文件(C14.7,MARK-09、11、13、14,PERF-07)。书写视图:“视图”菜单的专注模式(⇧⌘F,收起标签条、左栏与状态栏;可视模式里光标所在的顶层块之外淡到 0.35,源码里当前行之外同),打字机滚动(插入点所在行保持在视口中部;上下各留半屏,首尾行也能居中),正文宽度窄、中、宽(36、48、64rem),字体无衬线、衬线、等宽,文字大小(⌘= ⌘- ⌘0,70% 至 200%,每档 10%)。全部在页面里以 CSS 实现(`look.js`:根元素的 data 属性与 `--ely-text-scale`),Markdown 不变;设置存在数据目录的 `look.json`(读不出即报错并用默认值),菜单随之打勾,启动时恢复;淡入淡出遵守系统的减少动效。组件库的 `ZenMode` 不用(见缺口)。输出(“文件›导出…”⌘E 一个对话框选 HTML、HTML 与资源文件夹、PDF、纯文本;“打印…”⌘P):页面以 unified(remark-parse、remark-gfm、remark-math、remark-rehype、rehype-katex、rehype-stringify,版本写死)把编辑器此刻的全文(与懒挂载、大文件无关,不截断)渲染成 HTML,Mermaid 用浅色主题画成 SVG(画不出的照原代码块并在导出后提示);front matter 不进输出。HTML 内嵌导出样式表(构建时单独产出 `export.css`:KaTeX 样式与 woff2 字体内嵌、浅色排版)与图片(文档相对路径、百分号转义、file URL 读成 data URL;网址照原样;读不到的图片照原链接并提示);HTML 与资源文件夹写 `名称.html` 与 `名称_files/`(样式表与图片副本,同名图片按序号区分;文件夹已在即拒绝,写坏即删去);纯文本以 hast-util-to-text 按块换行,公式写 TeX;PDF 先把同一份内嵌 HTML 暂存到工作目录,在不显示的 CEF 视图里由 Chromium 打印(`WebView::print_pdf`,结束经 `Event::Printed` 报回),再交到所选位置;打印同一份 PDF 交系统打印面板(`ely_office::print_later`)。导出都经 `ely_files::export`(不覆盖打开着的文件)。富文本复制用 Chromium 自己的复制(剪贴板带 HTML)。Git(MARK-13,不做第二套客户端):Git 的查找与运行环境移入 `ely_platform::git`(`locate`:`ELY_GIT`,应用旁,ElyTree 包内的 Git,本构建的 `engines/git`;`command`:只读不抢锁等同一套环境变量),ElyTree 照用;ElyMark 在后台对当前文件问 `rev-parse --show-toplevel` 与 `status --porcelain=v2 --ignored`,状态栏显示未改动、有改动、未跟踪、已忽略、有冲突(问不出即“未知”,提示里是原因;不在仓库里不显示),按一下以仓库根打开 ElyTree(`ely_platform::apps::open`);切换标签、每次保存(指纹变化)、外部改动与窗口回到前台时重问。大文件(MARK-14):超过 2 MiB 的文本以源码打开,上方提示条写明大小与“可视与预览已关闭,保存保留全部内容”,切到可视或预览即提示不切;保存与导出都是全文。PERF-07 实测(macOS,release):1 MB 文档自启动到可视可编辑 1.4 秒(首次冷启动 2.0 秒);4 MB 在可视下约 5 秒,故按源码打开。真窗口(macOS,`tests/scripts/mark-output.sh`):Git 状态未改动→键入自动保存后有改动→按下打开 ElyTree 于该仓库;专注模式收起标签、存入 `look.json`、重启仍在,文件不变;四种导出的内容(KaTeX、SVG、内嵌图片、全文;资源文件夹的样式与图片;PDF 以 pdftotext 读出文字;纯文本含 TeX);打印生成 PDF;3.3 MB 文件以源码打开、⌘1 不切、末尾编辑后全文保存、纯文本导出含最后一行;`ELY_CLIPBOARD_TEST=1` 时可视全选复制带 HTML。`mark-content.sh` 的 look 步核对淡出、窄列、衬线与插入点居中。去掉专注收起、图片内嵌、大文件阈值、Git 状态或截断文本导出时各自失败。窗口测试须在英文输入法下运行(中文输入法会把模拟键入收进组字)。导出时每张图定为一个 `src`(去掉 `srcset`、`sizes` 与 `picture` 的 `source`,无 `src` 时取 `srcset` 的首个地址);公式的 KaTeX 错误随导出提示(行号与 KaTeX 的说明),不当作完整导出。引用正文的提示走外壳的 `toast_unlogged`,日志只记计数与行号;组件库 28a6bba 起 `Toaster` 只记提示的编号与级别,不记标题。未验证:Windows 与 Linux;Windows 的 Git 路径在 ElyTree 包内的位置。
- 2026-10-07:ElyMark 外部变更、自动保存与会话恢复(C14.6,MARK-08、12,§6.2、§6.3、§14.4,QA-22)。监视:一个 `notify` 监视器盖住各标签文件所在的文件夹(不递归),200 毫秒内的一串改动合成一次查看;按会话指纹(长度与修改时间)比对,自己的保存不触发。文件改了:读到稳定的字节(读前读后指纹相同,最多五次),无未保存内容(含页面还没送回的编辑)即原地换成磁盘的文字(`ely.refresh`:插入点所在的文件行与滚动尽量保持,时间线从这份文字重新开始),会话重载指纹并以新文件身份登记;有未保存内容即不动磁盘与编辑器,状态为“外部已改”,自动保存停止,上方提示条“比较…”。比较:组件库 `git::regions` 对“上次载入或保存时”的文字、你的与磁盘上的三方合并,`ThreeWayMerge` 逐个冲突选边(编辑器的文字先落定;比较期间磁盘再改即重读重比,选定前核对所读内容的指纹,不符即提示再比较一次、不写入),可“用磁盘上的”“保留你的”(立即覆盖写入)或冲突都选完后“用合并的”(磁盘的版本成为新的基底,合并结果作为一步可撤销的编辑,随后自动保存)。路径不见了:等 300 毫秒(原子替换的空档)仍不在,即在原文件夹里按文件身份(设备与 inode,不认符号链接)找改名后的文件(`Sessions::renamed_in`),找到即标签跟过去(与 F2 改名同一条路);找不到即为已删:内容留着,提示条“另存为…”,不再自动保存、不重建文件。读不出(权限等)或监视器出错:提示条写明原因与“再查一次”;“视图›检查磁盘上的文件”随时手动查看(网络目录等不保证有事件)。自动保存(§6.2):有路径的文件停手 1 秒后保存(`ely_files::PolicyEngine`),外部改动或删除后停止,选定处理后重新开始;失败照常留为未保存。会话(MARK-12):数据目录的 `session.json` 记各标签的路径(未命名为空)、名称、模式、插入点所在的文件行与滚动;每 10 秒有未保存内容且有变化的标签写一个恢复点(`ely_files::Recovery`,每标签一个文件夹,20 点 7 天);退出(Cmd+Q 或关窗)开始时先记下当时的标签;`session.lock` 写进程号,正常退出删去它与全部恢复点;下次启动锁还在且进程已不在即为崩溃(`ely_files::alive`)。启动时打开记下的文件,标签显示后换成记下的模式与位置;崩溃后的未保存内容从最新恢复点放回,一律开成未命名副本(有路径的文件照磁盘打开,从不先覆盖原件,§6.3;原件已不在也照样开副本并提示哪个文件没能打开)、标为未保存,并提示“恢复了某时未保存的内容;撤销历史未恢复”(§6.3 区分内容与历史)。显式“不保存”后正常退出,下次不当作崩溃,未命名的内容不回来。真窗口(macOS,`tests/scripts/mark-outside.sh`):原子替换后干净标签换成新文字、键入后自动保存;未保存时外部写入不被覆盖,比较三栏、保留你的写回;互不冲突的两处改动合并后都在;外部改名后标签名跟随、保存写到新名、旧名不重现;改成无权限读即提示、恢复权限后“再查一次”重载;删除后不再自动保存、不重建;退出后重开回到源码模式与原来的行;未命名文档写过恢复点后 `kill -9`,重开内容回来且关闭时问是否保存,选不保存后再开不回来。去掉干净重载、改名跟随、删除时停自动保存、读错提示、崩溃内容、退出时记下标签或恢复的位置时各自失败。启动时先显示可视再换到记下的模式(一次多余的绘制,已知)。启动参数给了文件时只开它们与崩溃恢复的副本,不连带上次的标签。“保留你的”以所比较的指纹为基线(`Session::rebase`)走普通保存,之后的外部改动照样冲突;自动保存撞上外部改动时交给比较(`Host::conflicted`),不弹外壳的冲突框;外壳在询问取消、保存面板取消、保存失败、冲突询问取消时都调 `Host::close_cancelled`,关闭标签时调 `Host::tab_closed`(ElyMark 即刻删其恢复点并更新清单);每个标签只在首次显示时恢复一次;缺列的位置读作 0。组件库 f756043:脏标签与非当前标签的关闭按钮透明而非隐藏,辅助技术可按。外壳冲突询问的“保留我的”以此刻磁盘的指纹为基线走普通保存(导出期间再改照样冲突;文件已不在时直接写),“从磁盘重新载入”先结束挂起的关闭;退出途中首次保存的标签以新路径记入清单。取消退出(未保存询问点取消)后清单照常跟随(`Host::clLine truncated
- 2026-10-07:ElyMark 编辑工具与附件(C14.5,MARK-06、07)。查找替换(Cmd+F):组件库 `FindWidget` 盖在页面右上,查询与替换是两个 `TextInput`,带区分大小写、全词、正则三个开关;可视用 `prosemirror-search`,源码用 `@codemirror/search`;总数与当前序号由页面按不重叠的命中报回;正则写错时框内显示 JS 的原话(按两个编辑器所用的 `gmu` 标志编译);空的或无效的查询不执行查找命令(CodeMirror 对它会打开自带的面板);零宽命中按码点前进(落在代理对中间时 Unicode 正则会往回答,原先反复收集同一处直至页面崩溃);预览不替换;换模式或换标签后按同一查询重查。查找框整窗一个,各标签的页面各记其查询:下一处、替换每次都带上框里此刻的查询与替换文字,标签一换即按框里的重查。查询变了从当前命中的起点往后找,键入替换文字不移动命中。替换框回车替换一处。缩进:源码 Tab 与 Shift-Tab 为 `indentWithTab`;可视在列表里 Tab 下沉、Shift-Tab 上提,不在列表时吞掉;代码块同源码。WebView 在自己的键位上下文绑定 Tab 与 Shift-Tab 交给页面(组件库 K02 起更深的上下文先得),焦点不离开编辑器。列表续写:可视为 Milkdown 自带,源码为 lang-markdown 的回车续写。“表格”菜单:插入表格(3×3)、上下加行、左右加列、删行、删列(prosemirror-tables);插入后插入点落在新表第一格(Milkdown 在段末插入时把插入点留在段里,之后的行列操作落空);插入点不在表格里、或在源码与预览里即提示。代码高亮见 C14.3。附件:拖放图片文件或粘贴剪贴板里的 PNG,复制到文档旁的 `assets/`:拖放保留原名、重名加序号,粘贴名为 `pasted-毫秒.png`;都以 `create_new` 占名,从不覆盖已有文件,写坏即删去半成品(`ely_files::import_attachment` 原先先查后拷,其间出现的文件会被覆盖);在插入点写入相对链接,名称按百分号转义,源码里替代文字的方括号与反斜杠加转义;复制期间文档另存到别的文件夹即不写链接,提示图片留在原文件夹的 assets;未命名文档、预览与非图片文件拒绝并说明。粘贴经 `WebView::intercept_paste`:宿主先看剪贴板,图片自己处理,其余照常交给页面。macOS:CEF 离屏输入不带 AppKit 编辑命令,而 Blink 在 macOS 上只凭这些命令处理 Cmd+方向键与 Cmd+Backspace(Option 组合 Blink 自己处理),可视编辑器里这些键原来不动;`ely-web-content` 的键盘处理器(`edit.rs`)在页面没处理这些键时照 AppKit 标准键位以 `Selection.modify` 移动或扩选到行首行尾、文首文末,Cmd+Backspace 与 Cmd+Delete 删到行首、行尾;CodeMirror 自己处理的键不经此。组件库 8f4729c 与 aa0c99d:开关按钮(`ToggleButton`、`ToggleGroup` 的各项)在 AX 树里是按钮,名为其文字或提示,带按下状态。真窗口(macOS,`tests/scripts/mark-tools.sh`):可视全部替换,源码正则替换,Tab 与 Shift-Tab,列表续写,Cmd+Down 后插入表格、加行加列、删列,拖放图片(自带的拖动源)复制到 assets 并写入转义的链接;`ELY_CLIPBOARD_TEST=1` 时粘贴图片同样落地;复制被拖住(FIFO)时经菜单另存到别处,不写链接;两个标签各设查询后回到前一个全部替换按框里的;键入替换文字后替换一处落在原命中;源码拖入 `a]b.png` 写成 `![a\]b](…)`。去掉 AppKit 键、正则、Tab 绑定、插入点修正、文件夹核对、起点查找、标签跟随或转义时各自失败。`tests/scripts/comments.py` 另查 Swift 测试助手与页面的 JS 源码。本机有全局快捷键占用 Cmd+Shift+S 与 Cmd+Shift+Z(空白 AppKit 窗口也收不到),所以测试经菜单另存。未验证:Windows 与 Linux。
- 2026-10-07:ElyMark 导航与搜索(C14.4,MARK-05、MARK-10,MARK-01 的打开文件夹)。左栏三页(分段控件):文件、大纲、搜索。打开(面板可选文件与文件夹,启动参数同)文件夹即在后台按其真实路径列出其下的 Markdown 文件(`folder.rs`:`.md`、`.markdown`,不进点开头的文件与文件夹,不跟随链接,只在所开的文件夹里,不扫整盘),以组件库 `lists::FileTree` 显示(只画可见行,可过滤),选中按回车或双击打开;窗口回到前台、改名之后即重列,列表变了即重搜文件夹。没开文件夹时文件页是最近列表(`ely_files::recent`:数据目录的 `recent.json`,新的在前,留 20 个,读不出即报错且不覆盖;ElyTree 的同一套移入此处共用),打开成功的文件与列出的文件夹记入,按真实路径(同一文件只一条);标签的路径变了(改名、另存为)即记入新路径,旧路径已不存在即移出。大纲:页面在显示后与每次送出全文时报出当前编辑器的标题(可视与预览取文档里的标题节点,源码取 CodeMirror 的语法树,ATX 与 Setext),以 `lists::Outline` 显示,原文块里的标题也在内(以 remark 读其源码,引用、列表中的标题同样列出,跳转落在标题文字上),选一项即把插入点放到该标题并把键盘焦点交给文档(预览滚到该处)。搜索:本文件或文件夹(打开了文件夹才有),按字面、不分大小写逐行找,打开着的文件按其当前文字(含未保存的),其余读盘,读不出或不是 UTF-8 的文件以“某文件未能搜索”的行列在结果最前(带原因),最多 500 条;本文件的结果随当前标签:切换标签即重搜,较早的搜索迟到即弃;结果列出行文字与“文件:行”,选中回车即打开(已开即转到,按真实路径认标签)并把插入点放到该行的那一处:源码定位到该行的命中,可视按写出时每个块在全文里的位置(`preserve.js` 的写出同时给出各块的区间)找到该行所在的块,再按块内此前命中的个数落到块中对应的命中(此前的行先经 Milkdown 解析,按编辑器所见的文字计数,链接地址等不算,与块内查找同按不重叠计;原文块照源码计数;该行所见文字没有查询词时——命中只在链接地址等处——落在该行开头);行号自文件开头算,可视与预览先减去 front matter 的行数,落在 front matter 里即滚到它。页面未显示完就要求的定位等显示后再做。多标签由外壳。真窗口(macOS,`tests/scripts/mark-places.sh`):文件树不列 .txt、点选回车打开,大纲跳到二级标题后键入落在标题里,文件夹搜索找到两个文件的行、不搜 .txt、选中结果打开并在该行键入,最近列表重启后还在、从中打开文件夹。组件库 dabc1eb 起树与树行有无障碍角色(树、树项,名称、层级、选中、展开);大纲项仍没有(测试按位置点选)。未验证:Windows 与 Linux。
- 2026-10-07:ElyMark 编辑模式、文件与语法(C14.3,MARK-01 至 04,§14.3)。模式:可视、源码、预览,“视图”菜单与 Cmd+1/2/3,状态栏右侧分段控件;每个标签各记其模式,页面回落到源码时标签随之。切换在页面里进行(`ely.mode`):先取当前编辑器的全文并立即送出待送的全文,再以同一文字显示新模式;每次切换都新建目标模式的编辑器(不再保留隐藏的编辑器)。本文件只有一条时间线:带着编辑离开一个模式时,把离开时的全文记入时间线;编辑器自己的撤销用尽(各看其撤销深度,Milkdown 的历史键位排在我们的之后)时再按撤销,回到时间线上一条全文、以当前模式重建(另一段的编辑作为一步撤销),重做用尽时向后走;只有真正的编辑会截掉时间线上其后的部分,撤销、重做的跳转不算编辑;离开时编辑器还有可重做的步骤,就把全部重做后的文字记在时间线前方,回来后重做即到那里。模式请求按序号只认最新的:编辑器构建期间再要的模式记下,构建完且仍是最新请求时显示它,此后的新请求清掉它;较旧的显示回报回来时序号不符即退出,不碰排队的请求。预览每次新建、只读(正文与代码块)。切换本身不改文件。文件:新建(空的未命名 `untitled.md`,保存走另存为)、打开、保存、另存为(外壳)、重命名(F2,“文件”菜单):`commands::RENAME` 与 `commands::RENAMING`(含重命名的文件命令表),`document_root`/`set_document_menus` 改收文件命令表;ElyMark 的 `rename.rs` 在原文件夹里改名(只有它用,不放外壳),`ely_files::rename_new` 不覆盖已有文件(macOS `renamex_np` 加 `RENAME_EXCL`,Linux `renameat2` 加 `RENAME_NOREPLACE`,Windows `MoveFileExW` 不带替换;后两者未构建验证),名称须不含斜杠且扩展名为可保存的格式,未命名或正在保存的标签不改名,改名完成时标签须仍开着且仍是原路径,否则只记日志、不动登记;同文件登记记下打开时的文件身份,原路径已不存在而身份相同即为同一文件(改名间隙里打开新名字会转到原标签),改名后以 `Sessions::track` 迁移登记(不关再开);外壳每次保存成功后也以它更新登记的路径与身份(原子保存换了文件);标签名与 `Session::renamed` 的路径随之,内容与指纹不变。文件 URL:Windows 的驱动器路径写成 `file:///C:/…`、UNC 写成 `file://主机/…`,去掉 `\\?\` 前缀,围栏反向换回 Windows 路径再取真实路径(字符串转换有单测,Windows 上未运行)。CEF 的请求处理移入 `fence.rs`(`handlers.rs` 原超过 500 行)。文档旁的图片:页面 `<base>` 设为文件所在文件夹,`WebView::allow_images` 让该文件夹只放行图片请求(资源类型为图片;导航、脚本仍只限内容文件夹),另存为或改名后随文件移动(已显示的相对图片不重载,已知限制);Mermaid 的包按脚本自身位置载入,不受 `<base>` 影响。Milkdown 7.22.2 的图片解析把无标题图片的 `null` 标题交给要求字符串的属性,ProseMirror 拒绝、Milkdown 记错误后丢掉整张图;`image.js` 改为空字符串。实测(macOS,`tests/scripts/mark-modes.sh`):文档旁的图片画出(截窗口量色),三种模式往返不改文件,预览不收键入,可视里键入后经预览回来撤销得回原样,源码里的改动回到可视并保存,改名到已有文件被拒、已有文件不动,改名到新名后文件移动、继续编辑保存到新文件,新建打开空的可视文档(读外壳写下的 `untitled.md` 须为空),可视键入、源码键入、回可视后两次撤销回到两者之前、重做回来(撤销跳转当作编辑、丢掉时间线时失败)(去掉预览只读、去掉保留编辑器、去掉旧编辑器的链接时测试失败);`mark-content.sh` 在同一段脚本里连要两种模式,最后显示后者(去掉记下的模式时失败);可视已显示、其回报未答时再要源码,最后为源码(不核对最新请求时失败)。“直接打开文件夹”随文件树在 C14.4。未验证:Windows 与 Linux。
- 2026-10-07:ElyMark 文件会话与原文保留(C14.2,§14.3,QA-21)。ElyMark 用 `ely-office` 的外壳(标签、打开、保存、另存为、关闭询问、冲突),引擎 `Mark`(`apps/ely-mark/src/engine.rs`):载入读字节,UTF-8 带不带 BOM 都可,UTF-16 与非 UTF-8 即报错并指出字节位置(`text.rs`);编辑器拿到去掉 BOM、CRLF 换成 LF 的文本;写出时文本与载入时相同即原样写回原字节,否则补回 BOM 并按载入时多数的换行(CRLF 多于单独 LF 即 CRLF;混用换行的文件编辑后统一成多数的那种,已知限制)。外壳的 `Engine::open` 改为收窗口、返回 `io::Result`(网页视图要窗口,页面开不了即为打开失败)。每个标签一个页面;页面每次改动立即报 `{"kind":"changed","version"}`,停手 300 毫秒后报该版本的全文 `{"kind":"text"}`;外壳的“已落定”即最新改动的全文已到,保存等它;历史标记为全文的长度与散列,撤销回原样即为未修改;外壳保存时先写出再读标记,两者之间页面可能送来新全文,所以写出时记下所写文字的标记,紧接的读标记交出它,之后的编辑仍算未保存。可视模式保留原文(`preserve.js`):照 Milkdown 自己的 remark 流程(`parse` 加 `runSync`)读出原文各顶层块的位置,与编辑器的顶层块一一对应(数目不同或有块无位置即不能对应;空文件 Milkdown 仍有一个空段落,照空处理)。每个顶层块单独序列化,与原文逐字相同的才是可视块,否则换成原文块(`ely_raw`:等宽、可编辑的纯文字,写出即其文字,不进撤销历史的一次替换);块间 Milkdown 不显示的原文(如链接定义:Milkdown 把引用链接内联、删去定义)也成原文块,所以没有藏起来的原文。序列化的标记照该文档自己多数的写法(`style.js`:无序与有序列表标记、强调、加粗、代码围栏、分隔线、Setext;以 remark-parse 统计后设 `remarkStringifyOptionsCtx`),写法一致的文档几乎全为可视块,与多数写法不同的块成原文块。写出时按块做 Myers 差异(`diff.js`;ProseMirror 未改的块是同一对象,否则按内容比较),未改的块与块间原文逐字照抄,只有改动的块用 Milkdown 序列化。YAML front matter 不进可视编辑器,以原文显示在上方,写出时原样接回。不能对应的文档直接以源码模式打开(可编辑,无损),并报 `fallback`。打开后插入点在文档开头。页面每次载入是一代(应用计数,随 `ely.show` 传入),回报带代数,别的代的回报丢弃;载入时版本计数归零,重载后显示最后收到的全文。故障视图按“重新载入”后焦点回到页面(原先留在消失的按钮上,键入无处可去)。普通 Markdown 读文件的权限错误不交外壳当作加密文件(外壳把 PermissionDenied 当作要密码),照系统原话报出;外壳打开失败另写日志。实测(macOS,`tests/scripts/mark-session.sh`):BOM、CRLF、front matter、Setext 标题、`1)` 列表、HTML 块、`~`删除线、硬换行、链接定义与 `[[...]]` 的文件,不改保存逐字节相同;在首段键入一字保存,除该段多一个字外逐字节相同(整篇序列化、去掉 BOM 时该测试失败);段落里的 `[[...]]`、引用里的 Setext 与 `[[...]]`、只有链接定义的文件都以原文块显示,键入一字只多一字;在链接定义前向前删除不移动、不修剪它;渲染进程被杀、按重新载入后再键入的字照样保存;无读权限的文件报“Permission denied”、不问密码(不用原文块、不按代丢弃回报时测试失败)。未验证:Windows 与 Linux;模式切换与撤销(C14.3)。
- 2026-10-07:ElyMark 内容包(C14.1,§14、§17.3)。网页源码在 `apps/ely-mark/web`:版本全部写死,`package-lock.json` 锁定;`scripts/build-mark-content.sh` 以 `npm ci --ignore-scripts` 安装、esbuild 打成经典脚本(file:// 下模块脚本被 Chromium 拦下)到 `engines/content/ely-mark`,附 `THIRD-PARTY-NOTICES.txt`(按打进包的每个包取其许可文件;没有许可文件的只许 MIT,以清单的作者与 MIT 原文写出,构建时列出)。包:Milkdown 7.22.2(commonmark、gfm 含脚注与任务、history、代码块组件)、CodeMirror 6(代码块与源码模式,语言取 language-data,全部打进包)、KaTeX 0.19.0(字体随包)、Mermaid 12.1.0(单独的 `diagram.js`,首次遇到图时才载入;`securityLevel: strict`,不在页面上留错误图)。数学按 Crepe 的做法(remark-math,块公式为 LaTeX 代码块)。只渲染文档里保存的源码;公式与图出错时显示错误(公式带位置,图带行号)与原文,不清空代码块。页面接口 `ely.show(markdown, mode, texts)`,mode 为 visual、source、preview;每次显示一个新容器与序号,较早的显示若后完成即销毁其编辑器、不报告,页面上始终只有最后一次的编辑器;预览时正文与代码块都只读;显示后经 `elyQuery` 报 `{"kind":"shown"}`。颜色全部取 `--ely-*`(CodeMirror 的高亮、Mermaid 的主题变量),随系统深浅色:Milkdown 把预览净化后拷进页面(不保留我们给的元素),且每次调用给的回调都是新函数,所以 Mermaid 预览先返回带编号的空占位,绘制结果只在该占位仍在页面上时经回调落下(改语言、改文字、卸载都换掉占位,旧结果即弃);绘制在全页一条队列里依次进行,每次绘制带代次,调用 Mermaid 前与落下前都核对属主、占位与代次,失效即跳过(编辑器销毁后排着的绘制不再执行);`:root` 的样式一变,凡占位仍在页面上的图(含正在绘制的)都以新颜色重画,此前的结果作废,首次落下的就是新颜色。记录归各编辑器所有,编辑器销毁即释放,占位已离开页面的记录在下一次绘制或换主题时清掉。Milkdown 把视口外的代码块换成占位(懒挂载),滚入视口时再建。界面文字由应用给(`mark.*`)。打包时 `bundle.sh` 把 `engines/content/<应用>` 放进 `Resources/content`,缺即失败;应用先找包内,再找本构建的 `engines/content`,都没有即报错。ElyMark 启动后显示一个空的可视文档(文件会话在 C14.2)。实测(macOS,`tests/scripts/mark-content.sh`):三种模式下标题、任务、表格、脚注、行内与块公式、代码块(逐块滚入视口,须可见、未被遮挡)、Mermaid 图(不小于 100×80)与两种错误,切换深浅色后图的颜色随之变,连续两次显示只留并只报后一次,截窗口背景色占比低于 0.97,KaTeX 字体已载入,无越界请求;`.app` 包内页面显示。未验证:Windows 与 Linux;树莓派上 2.5 MB 主脚本的载入时间。
- 2026-10-07:ElyTree 分支、标签、合并、变基、冲突(C13.4,TREE-07 至 09,§13.3)。Git 一律以 `GIT_EDITOR=true`、`GIT_SEQUENCE_EDITOR=true` 运行(没有终端可开编辑器,合并与撤销照 Git 自己拟的信息);失败时 stderr 为空即报 stdout(合并的冲突写在 stdout)。左栏第三页“分支”:本地分支(当前在前)、远程分支、标签(新的在前),`for-each-ref` 读出(附注标签取所指提交与标签自身的说明,`origin/HEAD` 不算分支,上游已删即不显示领先落后),上方“新建分支…”“新建标签…”;选中一个,中间列出提交、日期、上游与说明,以及适用的动作:切换(远程分支为 `switch --track refs/remotes/名`,不被同名标签劫走)、合并到当前分支、把当前分支变基到此、在此新建分支、重命名、删除(`branch -d`,Git 拒绝时报原话)、强制删除(`-D`)、删除标签。历史页提交详情多出拣选、撤销提交(合并提交以 `-m 1`)、在此新建分支、在此新建标签。新建分支可选“并切换过去”(`switch -c`);标签有说明即附注标签(`tag -a -m`,签名照用户的 `tag.gpgSign`)。变基、删除、强制删除、删除标签、中止、跳过每次都先问(每个确认只对这一次,§13.3),合并、拣选、撤销、继续不问。进行中的操作按 Git 目录里的 `rebase-merge`/`rebase-apply`、`MERGE_HEAD`、`CHERRY_PICK_HEAD`、`REVERT_HEAD` 判定(每次状态重读时读,只取最新一次),中间上方的提示写明正在把谁并到谁(变基时“当前侧”是新基底、“另一侧”是正在重放的提交),给继续(`<操作> --continue`)、跳过(合并无此项)、中止。命令失败时,只有合并、变基、拣选、撤销,且 Git 的输出有以 `CONFLICT (` 起头的行、失败后索引仍有未合并条目(`ls-files --unmerged`),才提示“因冲突停下”;其余(如钩子拒绝、冲突中 `checkout --ours` 的钩子失败)对话框显示 Git 的原话。失败的报错带上 stdout 与 stderr 两者(拣选的 CONFLICT 行在 stdout,钩子多写 stderr)。分支与标签的名称取自完整引用名(`refs/heads/`、`refs/remotes/`、`refs/tags/` 之后),同名的分支与标签各归各的;合并与变基用完整引用。分支行内的切换与删除接到同样的入口(删除照样先问)。冲突文件:中间上方是三侧与 `ThreeWayMerge` 的块级选择,各块照工作树文件里 Git 写下的标记分组(不另算一套合并;标记宽度取 `check-attr conflict-marker-size`,照 Git 的 `strtol_i` 读,不是正整数即 7;行须恰为该宽度,长短不同的同形行照正文;只按 LF 断行,同 Git,正文里的独立 CR 不算行界),基底取自 diff3 标记,没有时取 Git 自己 `merge-file --zdiff3 --marker-size` 对三个索引段的结果(块数相同时,只填没有基底的块,diff3 里空的基底照空);下方是可编辑的结果(`CodeEditor`,初为工作树里的带标记文件);选一侧即在结果里解决对应的那一块标记(其余手改照留),结果里的标记数与未选的冲突数对不上时(已手改过)不再按块选,提示在结果里改;“保存”只写工作树文件(经同一仓库的写队列,与 Git 的写操作不重叠;落地时按仓库与路径记下;路径已成符号链接即拒绝,不顺着链接写),“标为已解决”只做 `git add`(须先保存;结果里还有标记时先问),两者分开(§13.3);另有“用当前侧的文件”“用传入侧的文件”(`checkout --ours/--theirs`),一侧没有该文件时还有“以删除解决…”(`git rm`,先问)。符号链接、子模块、任一侧含 NUL(Git 视为二进制)、不是 UTF-8 或缺少的冲突整份解决:写明情形,给“用当前侧的文件”“用传入侧的文件”(只给有该文件的一侧)、“标为已解决”与“以删除解决…”。重读时结果有未保存的手改即保留并提示。真窗口(macOS):合并冲突的提示条、三侧与按块取另一侧后结果更新。未验证:Windows 与 Linux;交互式变基(不提供)。
- 2026-10-07:ElyTree 提交、历史、Blame(C13.3,TREE-05、06)。左栏分“改动”与“历史”两页(`navigation::Tabs`)。提交:改动页下方是 `git::CommitInput`(摘要与说明两个框,摘要满 50 变黄、72 变红),上面一行是 Git 设置里的身份(每次状态重读与窗口回到前台时重读,只取最新一次;`user.name <user.email>`,未设即写明,点它开身份对话框:姓名、邮箱(两个输入框以其标签为无障碍名),可勾“用于所有仓库”写 `--global`,否则写本仓库;空着即提示不写)与签名(`commit.gpgsign` 为真时写明以 `gpg.format` 签名,默认 openpgp),签名照用户自己的 Git 与工具,Ely 不另设。提交框按仓库各存一份(换标签时存下当前的、换上另一个的:摘要、说明、修改最近提交与其草稿),提交为 `commit --cleanup=whitespace -F -`(信息经标准输入,`#` 开头的行照留,用户的 `commit.cleanup` 不删它们,不经 shell),“修改最近一次提交”勾上即显示为勾选、读出上次的信息填入(读回前不能提交;先存下已键入的,取消勾选时放回;每次勾选或取消、换仓库都使在途的读取作废,读回时须仍是发起的仓库与那一次),提交成功后只在提交框仍是所提交的内容时清空(提交期间又键入的留着,别的仓库的提交框不动);钩子或签名失败时以对话框显示 Git 的全部原话,不只说失败。提交同其他写操作排队。历史:`log --topo-order --exclude=refs/stash --all`,每页 2000 个,滚到距末尾 20 行内读下一页;每次状态重读后比对 `show-ref --head`,引用变了(含外部提交、切分支、取回)即从头重读;读取期间来的比对记下,读完再比;引用读不出时历史栏显示 Git 的原话,之后读通即重读并清掉;提交图由 `git::lanes` 按已读的全部提交算出;时间按本机时区写到分钟(不用相对时间,长列表不必随时刷新)。搜索:在“提交信息”(`--grep`)、“作者”(`--author`)、“改动的文字”(`-S`)中按字面、不分大小写找,或按提交号(`rev-parse --verify`,前缀可);搜索时不画图。选中一个提交:中间上方是摘要、说明、作者与提交者(不同时才列)、完整提交号、父提交(可点,转到父提交)、签名状态(`%G?` 按字母译出,带签名者)、引用;下方左列是相对第一个父提交改动的文件(`diff --name-status -M` 与 `--numstat`,根提交对空树,空树由 `hash-object -t tree` 算,不写死 SHA-1),右边是所选文件的差异(与改动区同一读法:Git 自己的差异,图片、二进制、非 UTF-8 同样处理,无块的纯重命名显示为无改动)。改动区差异与历史页所选文件的标题栏都有“文件历史”与“逐行追溯”(改动区限已跟踪的文件):文件历史为 `log --follow --numstat -z`,跨重命名,每个提交带当时的路径与行数,选中即显示该提交里这个文件的差异(状态按两侧是否存在判定);逐行追溯为工作树文件的 `blame --porcelain`,未提交的行写“尚未提交”,每段首行可按,转到历史页的该提交;条色按提交时间新旧。读取都在后台,按发起时的仓库、提交与文件核对;文件历史与逐行追溯每次打开有窗口内唯一的序号,迟到的结果即弃。提交详情的作者、提交者、提交号、签名、引用、摘要与说明在 AX 树里有文字。真窗口(macOS)经 AX:历史页、提交图、引用、提交详情、文件列表与差异。未验证:Windows 与 Linux;GPG 与 SSH 实际签名(只读设置,签名由用户的 Git 完成)。
- 2026-10-07:ElyTree 差异与暂存(C13.2,TREE-03、04)。差异一律取 Git 自己的输出(`-c diff.suppressBlankEmpty=false diff --no-color --no-ext-diff --no-textconv -U3`,前缀写明 `a/`、`b/`,覆盖用户的 noprefix 等设置;空白的上下文行照样带前导空格):已暂存为 `--cached -M`(HEAD 对索引,即提交前的实际索引内容),改动为索引对工作树,未跟踪为 `--no-index` 对 `/dev/null`(退出码 1 即有差异);Git 的清理过滤(换行转换等)由它自己做,按块、按行暂存的补丁对得上索引。块之间未改动的行从旧侧的 blob 取(`cat-file blob`,已暂存为 `HEAD:旧路径`,改动为 `:路径`),可展开;行号按块头推算,旧文件比差异短即报错。改动的词按每对删去与加入的行标出(Unicode 词)。空白:精确、忽略空白变化(`-b`)、忽略全部空白(`-w`);行内与并排。按块或按行:只在精确比较、且是普通内容改动时(无新建、删除、重命名、复制、权限变化的头),已暂存区为取消暂存(`apply --cached --reverse`),改动区为暂存(`apply --cached`);未跟踪、重命名等整份暂存,界面写明。补丁由所选行切出:暂存时未选的删去行留作上下文、未选的加入行不写;取消暂存时反之;块头按“范围前的行数”换算,空范围照 Git 的写法;“无换行结尾”按切出后两侧的实际行尾:无换行的行在哪一侧后面还有行,那一侧就带上换行(所选的删去或加入行同样),两侧不同的上下文行拆成一删一加;`apply` 带 `--whitespace=nowarn`,用户的 `apply.whitespace` 不改所选的字节。写入前在后台重读同一条差异,与所选时读到的字节不同即拒绝(“文件在显示后改了,未暂存”),索引不动;同一仓库的写仍排队。所选行按住 Shift 取同一块内的范围;差异一变(任何重读得到不同的输出)所选即清空;每次读取有窗口内唯一的序号,换文件后旧文件迟到的读取即弃。图片(png、jpg、gif、webp、bmp、tiff、ico)以 `BeforeAfter` 左右拖动对比,一侧缺失时只显示另一侧;其他二进制文件,以及两侧任一侧不全是 UTF-8 的文件(差异以外的字节也算),显示两侧大小,整份暂存;文件与符号链接互换时 Git 为同一路径打出两段(删去与新增),两段都显示(不折叠:两段没有共同的旧文),整份暂存;工作树侧照 Git 读:符号链接读其自身的路径,不跟随;未跟踪的文件夹与冲突文件(冲突在 C13.4 处理)只说明。单击文件即在中间显示其差异(已忽略的文件以系统程序打开),标题栏另有“打开文件”。外部改动后状态重读,正显示的差异随之重读;文件不再列在原分区即收起。真窗口(macOS):改动区选两行暂存,索引恰为所选,已暂存区显示实际索引内容,并排视图;AX 树中各行、块按钮与分段可按。
@@ -278,7 +261,6 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
- 2026-10-09(用户报:ElyPDF 改字报“这种字体画不出 V”):文档自带的子集字体常无可用的 Unicode 表(LibreOffice 导出的子集表为占位,0x00 起逐一对应字形号),按 Unicode 找字形一律失败。现以“文档自带字体”写字时直接用页面上的那个字体对象(`shim/ely_pen.c`;`pdf_load_font` 按对象缓存,与文字层的字体同一对象),以它自己的编码写:按文字提取的读法(ToUnicode,缺项或坏值退回 cid_to_ucs,同 pdf-op-run.c)倒出 Unicode 到编码的表,排版取它的宽度(`pdf_lookup_hmtx`),原有的 ToUnicode 与宽度不动,两个字共用一个字形时各写各的编码(原先按字形号写、重建 ToUnicode,共用字形的字被改写,且 CMap 漏写条目数,PDFKit 读不出字)。删去原文字块后页面资源里可能已无该字体,故按对象号以新名字重新引用。只支持编码即 CID 的字体(简单字体与 Identity-H),其余拒绝并说明;页面上没有的字仍报缺字。排版按同一套宽度量出的框,比较时留 0.01 点的余量(原块宽度即这些宽度之和,浮点误差曾使末字折行)。
- 2026-10-09:ElyTable 的自动筛选(R2.9)。内核会重新推算未命名的自动筛选区:每次打开筛选列表时向下扩到紧邻的数据行(`ExtendDataArea`、`ExtendBackColorArea`,为接上后来追加的行),命令从单格找到它时按连续数据区重算(`ScDocShell::GetDBData`:应用条件、关闭筛选及其撤销);选区下方紧邻的合计行因此进了列表、随数据一起被隐藏,存后重开的筛选区同样会扩。补丁 `0040-lok-calc-autofilter-keeps-its-rows.patch`:LOK 下已有的自动筛选区保持设定时的行(同 Excel),不再隐式扩展(光标在区内时;光标在同表另一块数据里即按那一块);单格开筛选时仍取整个数据区。代价:紧贴在筛选区下方追加的行不自动并入,需重新选定后再开筛选。
- 2026-10-09:ElyTree 的差异按文件类型着色(R2.12)。组件库 7f2ac3e 至 b0dbfd4:`DiffViewer`(建立时按路径定下读法) 按路径的扩展名判定散文与纯文本(md、markdown、txt、rst、adoc、org、csv、tsv、log 等,无扩展名同,不着色),代码按语言着色(组件库 b0dbfd4 的 `forms::syntax`:Rust、JavaScript/TypeScript、类 C、Python、Ruby、Shell、YAML、INI、SQL、Lua、Haskell、Lisp、Erlang、TeX 与其他 `#` 语言各有行注释记号、关键字与常量;Shell 与 YAML 的 `#` 只在词首起注释;Makefile、Dockerfile、CMakeLists.txt 先按文件名认),不是完整的语法分析器(字符串插值、块注释、多行字符串照通用词法)(原先一律套 `//` 的通用词法,Markdown 的 `#`、Python 的注释都被当作代码上色)。同批带入上游的块编辑器跨块选择、浮层滚轮、输入法候选框位置、插入点按设备像素对齐与格式修正。
- 2026-10-09:ElyMark 的表格(R2.13)。表格的空格与分隔行写法有许多种(`|a|b|`、`|---|`、对齐空格),逐字重现做不到,原先凡写不回原样的表格都成了原文块。现在一块的序列化与原文在把其中每张表格化成规范形式(各列对齐方式加各格的原文,`tables.js` 的 `canon`)后相同,即为可视块(`preserve.js` 的 `kept`),引用与列表里的表格同样;格内有 Milkdown 会改写的写法(`[[…]]`、引用式链接等)即不同,整块仍为原文块,与段落的规则一致。Milkdown 的列表项必须以段落开头,表格作列表项的第一块时无法放进编辑器,照原文块保留。未改的表格照旧逐字写回,改过的表格按文档多数表格的写法重写(`style.js` 按表格的行节点投票:单元格两侧有无空格、是否按列对齐,经 remark-gfm 的 `tableCellPadding` 与 `tablePipeAlign`;原先投票的解析器不认 GFM,表格从未计票),分隔行随之重写(如 `|---|` 成 `| - |`)。
- 2026-10-09:ElyTable 新插图表的位置(R2.10)。内核按“定位区域”把图表放在其旁、并挤回当前可见区;带参数的请求(Ely 经 `FN_PARAM_5` 给区域)原把定位区域取为光标格,图表压在数据上或旁边的另一块数据上。补丁 `0041-lok-calc-chart-beside-its-data.patch`:LOK 下有请求区域时,图表放进数据区右侧(隔一列)第一块够大、无合并格的空单元格(逐列向右找,跨过旁边的数据块),右侧没有依次找下方、左侧、上方,再按行带找全表第一块够大的空处(尺寸从候选格起累加,表尾也快),不受可见区限制;空处按图表所在的工作表查,数据在别的表时从光标起找;整张表没有空处即插入失败、文档不动;`LokView` 在新选中对象(原先无选中)时把它滚入视口,拖动中的对象不滚动。
- 2026-10-09:ElyPDF 编辑与缩放不闪白(R2.6)。旧图块不再随编辑或比例变化即刻丢弃:编辑落地时全部当前图块、比例变化时离开视图的同页图块,连同当时的页面尺寸进入“底图”(`apps/ely-pdf/src/backdrop.rs`),按其在页面中的比例位置画在该页新图块之下,直到该页在视口里的新图块到齐、或页面尺寸与旋转变了才释放;每页画什么由 `PdfView::pieces` 决定,绘制与测试同用它。只有改内容的编辑保留底图并沿用已测的页面尺寸(前 100 页以外不再被占位尺寸覆盖);增删、移动、旋转、裁剪页面与撤销重做(`PdfView::edit_pages`)清空底图,因同一序号可能已是另一页。关闭标签时底图随图块交给窗口释放。原先比较整份尺寸表(超过 100 页的混合纸张文档尺寸表后段为占位,比较失败即全部清空)且只认同比例的图块(编辑后右栏收起、比例变化即清空),都会闪白。
- 2026-10-09:文档的缩放锚点(R2.7)。窄于视口的文档居中,缩放原按视口坐标求锚点、漏了居中的偏移,锚点随缩放漂移;现锚点先减去缩放前的偏移,缩放后按新的偏移(`LokView::slack`,绘制同用)换回。按键与菜单的缩放(无指针)另保持首页左缘在视口内;指针与捏合的缩放保持指针下的点不动(文档边界以外即止)。
@@ -286,7 +268,7 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
- 2026-10-09:作者名与读不出的设置(缺陷清单 X-02)。签名用的名字取启动时读入的通用设置(`ely_ui::settings::current`;文件读不出时为默认值,设置窗口另以 Banner 写明),`ely_platform::user::named(作者名)` 为空即用账户名;不再在引擎启动时重读文件(原先 `settings.json` 读不出时 Doc、Table、Show 以致命错误退出)。LOK worker 记下设定的作者名,引擎重启照用它。`tests/scripts/bad-settings.sh` 核对读不出的设置下 ElyDoc 照样启动、文件不被覆盖。
- 2026-10-09:每个应用只跑一份(缺陷清单 X-03)。`ely_ui::run` 在写日志之前取应用数据目录的 `instance.lock`(`ely_platform::instance::hold`;Unix 为 fcntl 记录锁,进程结束即放,被拒时以 `F_GETLK` 向系统问出持锁的进程号,不另存进程号文件,同时启动的几份也报得准;Windows 为 LockFileEx,不报进程号);已有一份在跑时第二份不启动,报“已在运行(进程号)”。原先第二份启动即清空第一份的工作目录、把它的恢复点当作干净结束删掉、改写崩溃锁。macOS 经访达打开文件本就交给运行中的那份;`open -n` 与 Linux 每文件一个进程时第二份被拒,文件须在已开的窗口里打开(未做转交)。`tests/scripts/one-instance.sh` 核对。
- 2026-10-10:ElyMark 的链接(M-05,我定,同常见编辑器):可视模式单击链接是编辑,Cmd 单击(Windows、Linux 为 Ctrl)交应用;预览单击即交应用;应用照 ElyPDF:http、https、mailto 交系统打开,其余提示拒绝。
- 2026-10-10:Windows 与 Linux 的构建环境(E-01)。Windows:MSVC(VS 2022,v143);MuPDF 由 `build-mupdf.sh` 以 MSBuild 编上游 `platform/win32` 的 `libmupdf`(静态运行库 /MT,同 Rust 的 `crt-static`;`HAVE_LIBCRYPTO`,`libmupdf.lib` 内含第三方、Tesseract 与 PKCS#7);静态 OpenSSL 取自钉在 2026.07.29 的 vcpkg(`engines/vcpkg`,`openssl:x64-windows-static`);Git 为 Git for Windows 的 MinGit 2.56.0(钉散列,`engines/git/cmd/git.exe`,LFS 在 `ucrt64/libexec/git-core`);CEF 照 cef-dll-sys 放在可执行文件旁(Windows、Linux 都是),不另载入;`%TEMP%` 下的程序被系统的应用控制拦下,构建与探针都不放那里;cmake 与 ninja 须在 PATH;LibreOffice 的 configure 只认 VS 自带的 CMake(组件 `VC.CMake.Project`)。仓库与 `reference/` 一律 LF(`core.autocrlf false`、`core.eol lf`;`fetch-references.sh` 对每个上游照设)。Linux:WSL 的 Ubuntu 26.04;静态 libcrypto 另需其 pkg-config 列出的库(jitterentropy、zstd、z)。LibreOffice 在 Linux 照 Collabora 的 LOKit 配置构建(无图形后端、cairo RGBA、自带各库)。
- 2026-10-10:Windows 与 Linux 的构建环境(E-01)。Windows:MSVC(VS 2022,v143);MuPDF 由 `build-mupdf.sh` 以 MSBuild 编上游 `platform/win32` 的 `libmupdf`(静态运行库 /MT,同 Rust 的 `crt-static`;`HAVE_LIBCRYPTO`,`libmupdf.lib` 内含第三方、Tesseract 与 PKCS#7);静态 OpenSSL 取自钉在 2026.07.29 的 vcpkg(`engines/vcpkg`,`openssl:x64-windows-static`);Git 为 Git for Windows 的 MinGit 2.56.0(钉散列,`engines/git/cmd/git.exe`,LFS 在 `ucrt64/libexec/git-core`);`%TEMP%` 下的程序被系统的应用控制拦下,构建与探针都不放那里;cmake 与 ninja 须在 PATH;LibreOffice 的 configure 只认 VS 自带的 CMake(组件 `VC.CMake.Project`)。仓库与 `reference/` 一律 LF(`core.autocrlf false`、`core.eol lf`;`fetch-references.sh` 对每个上游照设)。Linux:WSL 的 Ubuntu 26.04;静态 libcrypto 另需其 pkg-config 列出的库(jitterentropy、zstd、z)。LibreOffice 在 Linux 照 Collabora 的 LOKit 配置构建(无图形后端、cairo RGBA、自带各库)。
- 2026-10-10:Windows 与 Linux 的内核与 worker(W-01、K-01,用户定:0.1.3 先三端发布)。内核只认合并库(`ffi::KERNEL`:`libmergedlo.dylib`、`libmergedlo.so`、`mergedlo.dll`;`sofficeapp` 在合并构建里是文本占位);Windows 载入时一并搜索该 DLL 所在的 `program/`。查找次序照旧,Windows 与 Linux 的包内位置是应用旁的 `libreoffice/program`,开发构建为 `engines/lo-src/instdir/program`。worker 的主循环:macOS 照旧以 GCD;Linux 以 unipoll,`runLoop` 的轮询回调里每次答一个请求,内核的唤醒回调往同一队列放信号(`worker/unipoll.rs`;回调的数据指针须非空,VCL 见空即不调用);Windows 也以 unipoll:LOK 不另起主线程,worker 的主线程跑 `runLoop`(Windows 的消息循环),请求经只收消息的窗口投到这条线程上执行(`worker/messages.rs`),同 macOS 的 GCD;原先 LOK 自起主线程、worker 在另一线程作答,载入表格即死锁。Windows 的循环不调轮询回调,但须传非空的回调,`isUnipoll` 只看它。补丁 `0043-lok-callbacks-flush-inline-everywhere.patch`:回调队列在各平台都即时冲出(原只 macOS),否则请求里等的命令结果与状态要等下一轮主循环才到。`0042-svx-boost-json-needs-no-library.patch`:svxcore 与 sd 定义 `BOOST_ALL_NO_LIB`,MSVC 不再按 Boost 的自动链接索要 `libboost_json`、`libboost_container`。Linux 的内核以 cairo 画图块(worker 设 `SAL_DISABLESKIA`;构建去掉 `--enable-cairo-rgba`,自带的 cairo 要清掉重建),字节为 BGRA,同 macOS 与 Windows;`getTileMode` 只看 cairo 开关,不可信。worker 启动核对在三端都画一段红字,按 BGRA 找不到红色即拒绝。`scripts/apply-lo-patches.sh` 的临时库设 `core.autocrlf false`(Windows 默认转换使任何前缀都对不上)。Windows 的 LibreOffice 在 Cygwin 下构建(`build-libreoffice.sh` 的 CYGWIN 分支:去掉 Unix 专用开关,加 VS 2022,用 `/opt/lo/bin` 的 make,清掉 Git Bash 带进来的 `MSYSTEM`);构建目录与 Cygwin 排除在 Defender 扫描之外(否则并行 configure 删不掉 `conftest.dir`)。英文词典扩展里的 Lightproof(Python 写的语法检查)在构建后删去:macOS 与 Linux 的内核不带 Python,它本不生效;Windows 必带内置 Python(Cygwin 下 configure 不许去掉),第二次用同一配置启动时初始化它,worker 挂起。worker 以 `SAL_NOLOCK_PROFILE` 运行,`ely.xcd` 关掉 LibreOffice 的文档锁(`Common/Misc/UseLocking`):被强杀的 worker 留下的锁会让下次启动或载入弹出交互而挂起;每个应用只有一个 worker 用自己的配置,冲突由 Ely 自己核对。三端发布(CI-01):`.gitea/workflows/release.yml` 在打标签时于三台运行器上执行 `scripts/ci.sh`:macOS 在 Mac mini 的常驻克隆里借用开发克隆 `~/Documents/GitHub/ElyOfficeSuite` 的引擎(`ELY_SHARE`;2026-10-11 起:从零构建 LibreOffice 超过 act_runner 默认的 3 小时,v0.1.3 的 Mac 任务被杀、无 dmg),Windows 在本机运行器上把 `engines` 与 `reference` 接到开发克隆,Linux 由同一台 Windows 运行器经 WSL 构建(服务器上的 Linux 运行器是一次性容器,建不了内核);各自打包后上传到该标签的发布。Windows 为 NSIS 安装程序,Linux 为 AppImage、deb、rpm 与 Arch 包(引擎构建与打包都经 `packaging/linux/container.sh` 进 Ubuntu 26.04 容器,WSL 里须有 docker;`ELY_SHARE` 的目录一并挂进容器),都由 `packaging/stage.sh` 分装引擎;每个平台都上传本提交的源码包(`packaging/source.sh`);更新清单只含 macOS(Windows 与 Linux 的自动安装未做)。
- 2026-10-11:gpui 的 Windows 窗口关闭(W-03 冒烟查出)。`gpui-pre-windows` 0.3.8 在窗口关闭时报三条错:系统已销毁窗口(ElyTree 的关窗交给系统)后 Drop 仍对旧句柄 `RevokeDragDrop` 与 `DestroyWindow`(拖放处理器因此不释放,连带窗口状态);销毁时的失活回报排在异步任务里,执行时窗口已移除(“window not found”);退出后后台任务向已销毁的平台窗口投递(`Invalid window handle`)。修复在 `vendor/gpui-pre-windows`:`WM_DESTROY` 时记下已销毁、在句柄仍有效时撤销拖放、去掉失活回调,Drop 见已销毁即不再动句柄;平台窗口已不在时投递失败只记 debug(应用已退出),其余投递失败照报。三个应用关窗与退出的日志无错。
- 2026-10-11(用户报:Windows 启动带黑色命令行窗口、“打开方式”里无图标、点 X 不退出、表格全黑无网格线):四处根因。命令行窗口:应用是控制台子系统;发行构建改为 `windows_subsystem = "windows"`(开发构建照旧有控制台看日志),子进程(两个 worker、Git、`cmd start`)一律经 `ely_ipc::windowless` 以 `CREATE_NO_WINDOW` 启动。图标:exe 里只有 gpui 的清单资源;各应用共用构建脚本 `packaging/windows/icon.rs`,把 `assets/icons/<应用>.ico` 编成资源 1(gpui 的窗口图标也取资源 1)。点 X 不退出:窗口其实弹了“保存更改”询问。`ely.xcd` 深色方案的 `CalcNotesBackground` 与浅色不同,Calc 在配色变化时改写批注样式并记一步撤销(`ScDetectiveFunc::UpdateAllComments`),未动的表格切到深色即算已修改;两套方案的批注底色改为同一值(LibreOffice 默认的浅黄),切换主题不再改文档,三端同此(`sheet.rs` 的 `a_theme_switch_is_no_edit`)。全黑:Win32 的虚拟设备没有 `SetSizeUsingBuffer`(基类默认返回假,断言在发行构建里不在),给客户缓冲区的设备停在 1×1 像素,Calc 图块只画了一个像素,放映图层同空;补丁 `0044-lok-win32-paints-then-copies.patch` 让 Windows 走 macOS 的路:按全尺寸画进设备,再拷进客户缓冲区(`doc_paintTile` 与 `SlideshowLayerRenderer`)。Writer 自设设备尺寸,原本不受影响。`tests/scripts/lo-patches.sh` 的临时库也设 `core.autocrlf false`(原先在 Windows 上恒失败)。
@@ -294,7 +276,6 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
- 2026-10-11:Linux 标题栏按钮(核查 Windows 按钮问题时查出)。macOS 的红绿灯由系统画,不受影响;Linux 为客户端装饰,按钮由组件库画在标题栏的拖动区里,拖动区对任何按下都准备移动窗口,按住按钮时指针动一像素即开始移动窗口,合成器接管指针,松开到不了按钮,点击落空。组件库 3eb4d34:`drag_region` 只在按下未被里面的按钮认领(按钮以 `prevent_default` 认领)时才准备移动;`shell/title_tests.rs` 核对晃动的按下点得动 Linux 的关闭、标题栏空处照样移动窗口(去掉修复时前者失败)。未在真 Linux 桌面上点过。
- 2026-10-11(用户报:ElyTree 改动列表里文件名长时盖住行尾的打开按钮):组件库 bd01470:`ChangesList` 行里的文件名原为不缩的盒子,长名越过行宽压在按钮下;现可缩、以省略号结尾,先于按钮让位;`git/tests/narrow.rs` 核对 260 宽时文件名止于按钮之前(原布局时失败)。
- 2026-10-11(用户定:ElyTree 状态栏去掉 Git 的版本与路径那一项):所用 Git 的版本与路径只记日志。同时查出 Windows 上 `std::fs::canonicalize` 给出 `\\?\C:\…` 形式,仓库根、Git 目录、工作树与文件的真实路径一律改用 `dunce::canonicalize`(能写成普通路径即普通路径,同一文件仍是同一真实路径),状态栏的仓库路径随之为普通形式;ElyTree 的单测在 Windows 上由 44 个失败降到 8 个,余下 8 个改前同样失败(如写入 Windows 不许的文件名),未处理。
- 2026-10-11:ElyMark 的 AppImage(0.1.3 的 Linux 发布查出)。`linuxdeploy` 把可执行文件的 RUNPATH 改成 `$ORIGIN/../lib`,并把它找得到的 `libcef.so` 拷进 `usr/lib`:原先 `libcef.so` 与 CEF 的资料在 `usr/bin`,结果是两份 1.45 GB 的内核、程序载入 `usr/lib` 那份、CEF 在它旁边找不到 ICU 数据而退出(从未在 AppImage 里启动过)。现在 `package.sh` 把从 `engines/cef` 分装来的全部条目移进 `usr/lib`(按名字对照 CEF 文件夹,清单仍只在 `stage.sh`),以 `LD_LIBRARY_PATH=AppDir/usr/lib` 运行 `linuxdeploy`,`libcef.so` 只此一份、依赖照样带上,CEF 按所载 `libcef.so` 的文件夹取资料(`ely-web-content` 的 `open`)。ElyMark 的 AppImage 由 715 MB 降到 423 MB;WSL 里启动后 CEF 载入、窗口打开(WSL 里各应用都不出首帧,未在真桌面上验证)。
- 2026-10-09(用户定):代码与发布从 GitHub 搬到自建的 Gitea(git.zacharyzhang.com;GitHub 账号被封,推送与发布都被拒)。公开仓库 `ZacharyZhang-NY/ElyOffice`(原样推上 main、缺陷清单分支与 v0.1.0、v0.1.1 标签),组件库为同站的 `ZacharyZhang-NY/Ely-GPUI-Components`(Cargo 的 git 依赖与 `fetch-references.sh` 随之改址,钉的提交不变)。v0.1.1 的六个 dmg 与两个源码包重新发在 Gitea 的 v0.1.1 发布里。官网的下载与源码链接指向 Gitea(`releases/latest` 会跳到最新发布页)。`.github/workflows` 删去(它们靠 GitHub 的 macOS 机器与 `gh`,发布改在本机以 `packaging/macos/release.sh` 构建、经 Gitea API 上传)。发布视频里写的地址已在源文件改为 Gitea,已发布的成片仍是旧地址,重渲染后才更新。上游项目(LibreOffice、MuPDF、git-lfs、tessdata 等)照旧从它们自己的 GitHub 地址取。
- 2026-10-11(用户报:Omarchy 上六个 AppImage 都打不开):`gpui-pre-platform` 对 `gpui-pre-linux` 关了默认特性,Wayland 与 X11 只经它自己的 `wayland`、`x11` 特性打开;工作区原只开 `font-kit`,Linux 上 gpui 退成无头平台,日志照写“window open”,合成器里从无窗口。工作区的 `gpui_platform` 加开两者;`tests/scripts/linux-backends.sh` 核对特性,在 Hyprland 上核对 Wayland 与 X11 两路都映射出窗口、截图里有均匀的深色页面,tesseract 读出起始页的 “Open a repository”(不交缓冲的窗口 Hyprland 不映射;不画字形时读不出)。
- 2026-10-11:Linux 的 inotify 把打开文件报成事件(notify 的 `Access`),macOS 的 FSEvents 不报;四处文件监视原把任何事件当作改动,重读本身又触发下一次:Omarchy 主题与通用设置在第一次改动后、ElyTree 打开仓库起(`git status` 读仓库),空闲时每 250 毫秒重读。四处都经 `ely_ui::watching::only_read` 略去读取(打开、读、只读关闭),写后关闭照算改动(经内存映射的写入只报 CLOSE_WRITE,不报 MODIFY);`tests/scripts/linux-idle.sh` 以 inotifywait 从外面数空闲时的读取与 git status 次数。
@@ -303,4 +284,4 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
- 2026-10-11:Linux 包的实测(L1.6)。本机(Omarchy,Hyprland 0.56)装上六个 Arch 包与六个 AppImage,`linux-installed.sh` 两种都过;deb、rpm、Arch 包在 Ubuntu 26.04、Fedora 44、Arch 容器里装上、全部 ELF 找得到库;deb 与 rpm 的六个应用在各自容器的 Xvfb 下画出窗口(`linux-distros.sh`)。截图按窗口(`grim -T` 加 Hyprland 的 `stableId`):按区域截时用户切换工作区或别的窗口盖住即截错。ElyMark 的文件监视略去读取后不会自激:重读前先比指纹(长度与修改时间),不打开文件。
- 2026-10-11(用户定:替换 v0.1.3 的 Linux 包):v0.1.3 发布里原有的六个 AppImage(无头,打不开窗口)已删去,换成本机容器里建出的六个应用各四种包(共 24 个),出自 L1.7 的提交(晚于该标签);上传后逐个核对大小,ElyTree 的 AppImage 下载回来散列相同。`package.sh` 把每个 RUNPATH 按所在 ELF 解析(`$ORIGIN` 或 `${ORIGIN}` 开头,规范化后须在 AppDir 内),否则拒绝(如未修正的 cairo):CI 用的是别处建好的引擎,旧构建会在这里报错而不是带进包里。替换的 Linux 包对应的源码随之上传到同一发布(`source.sh`,文件名带构建提交号),发布说明写明 Linux 包出自哪个提交。
- 2026-10-11:`ci.sh` 用 `ELY_SHARE` 时,CI 克隆里的 `engines`、`reference` 须是指向共享克隆的链接(Windows 为目录联接,不查);是本克隆自建的目录或指向别处即报错,不混用。`.gitignore` 的 `/engines`、`/reference` 不带斜杠,链接也被忽略(`source.sh` 要求工作区干净)。
- 2026-10-11(用户定:ElyMark 不用 CEF,不用 Electron):ElyMark 改为原生。界面用组件库的 Markdown 组件(`documents::MarkdownEditor` 一族,缺的在组件库补并推到 Gitea),解析用 markdown-rs(`markdown` 1.0.0),公式用 RaTeX,Mermaid 用 mermaid-rs-renderer,PDF 用 MuPDF Story;CEF 随之删去,ElyMail 的正文也不用 CEF 与 Electron。进度在 `tasks/TASKS.md` 的 C14。组件库 fcad0ff 与 508cc55:`MarkdownRenderer`、`MarkdownEditor`、Markdown 着色、块范围与链接查找改读 markdown-rs 的语法树(GitHub 风格加公式),引用式链接与图片按定义解析,图片把所在段落分开,单行 `$$…$$` 为块公式,软换行(LF、CRLF、CR)读作空格,嵌套在引用与列表里的定义同样解析,强调与链接里的图片仍是图片(链接里的图片可按);`BlockEditor` 的导入导出不属 ElyMark,仍用 pulldown-cmark。markdown-rs 读嵌套图片时只留外层的说明。组件库 d5ecc5a:块公式经 `typography::Latex` 由 RaTeX 排出(字形为轮廓,按窗口缩放画),```` ```mermaid ```` 块经 `documents::Diagram` 由 mermaid-rs-renderer 画出(Mermaid 自己的深浅主题,底色与字色取 Ely 色板),都作为 gpui 资源在后台排版,RaTeX 或 Mermaid 对已解析的输入 panic 时显示为错误;`MarkdownRenderer::base` 给出文档所在文件夹,相对与 file 地址的图片从中读取并按自身大小、不超过栏宽显示,`data:` 地址解码,网络图片不载入(写明)。组件库 ab9a14c:每个公式、图与图片元素经 `primitives::held` 持有其资源,按资源计数持有者(8b9820b;画出的图存在共享的记录里,9036c92 起由最后离开的持有者释放,哪怕它自己还没收到图),最后一个持有者换了内容或离开窗口才从 gpui 资源缓存移除、在本帧之后从图集释放,共用同一内容的元素(含 gpui 缓存的视图)不受影响;公式按字形实际范围取画框,字形不被裁;公式失败显示原因、RaTeX 给的位置与原文,日志不含公式;`file:` 地址去掉查询与片段,`localhost` 为本机,Windows 上别的主机为共享路径,他处拒绝;Mermaid 的节点与饼图文字取 Ely 的前景色。组件库 2b6f551:`documents::MarkdownDocument` 是文件级的 Markdown 文档:全文与撤销在一个 `CodeEditor` 里(`CodeEditor::highlighter` 以 `markdown_highlights` 着色,无行号);可视以 gpui `list` 逐块画出文件的顶层块(链接定义、HTML、front matter 照原文显示,每个字节都在块里或块间),按下的块开成源码(`TextInput`),其键入即刻算作文档的文字,写回为一步撤销,上下键越过块边进入相邻块,Esc 写回;Cmd(他处 Ctrl)按下交给链接;预览只读;源码为编辑器本身;128 KiB 以内当场解析,更长的在后台解析,解析中按下块不打开(日志);新解析保留两端相同的块,滚动不跳。`CodeEditor` 的撤销历史最多留 64 MiB 文字,超出先丢最旧的步;`CodeEditor::reset` 换文字且撤销重新开始;`MarkdownMode` 加 `Preview`。组件库 fc181b4:一个插入点通用于各模式(编辑器的选区,写回的块移动它);`set_caret` 与 `set_top_line` 分别设插入点与视图,换模式保持顶行;按下的块在前一块写回后仍是所按的块(按偏移换算,结构变了即不开并记日志);块是 Tab 停靠点,Enter 或空格打开;预览不收按下、撤销与重做;撤销与重做每次记录、输入法组字、撤销、重做后都限在 64 MiB 内(先丢最旧的撤销,再丢最远的重做)。组件库 433c38f:只有对当下文字的解析才能打开块(解析的文字与编辑器相同),否则拒绝;要求的顶行在其文字解析后才落定:先到所在块,下一帧按块高的比例到块内位置,再有新的要求即接着放下一个;源码与列表之间切换保持顶行,可视与预览共用一个列表,切换不动滚动。组件库 abac8b4:顶行的要求带编号,迟到的帧回调只完成自己的那个;文字一换即按新文字重新放,放时按当下文字的行数夹住;`reveal` 是同样的要求(插入点设在该行,该行放到顶上),新文字解析后才落定。
- 2026-10-11(用户定:ElyMark 不用 CEF,不用 Electron):ElyMark 改为原生。界面用组件库的 Markdown 组件(`documents::MarkdownEditor` 一族,缺的在组件库补并推到 Gitea),解析用 markdown-rs(`markdown` 1.0.0),公式用 RaTeX,Mermaid 用 mermaid-rs-renderer,PDF 用 MuPDF Story;CEF 随之删去,ElyMail 的正文也不用 CEF 与 Electron。进度在 `tasks/TASKS.md` 的 C14。组件库 fcad0ff 与 508cc55:`MarkdownRenderer`、`MarkdownEditor`、Markdown 着色、块范围与链接查找改读 markdown-rs 的语法树(GitHub 风格加公式),引用式链接与图片按定义解析,图片把所在段落分开,单行 `$$…$$` 为块公式,软换行(LF、CRLF、CR)读作空格,嵌套在引用与列表里的定义同样解析,强调与链接里的图片仍是图片(链接里的图片可按);`BlockEditor` 的导入导出不属 ElyMark,仍用 pulldown-cmark。markdown-rs 读嵌套图片时只留外层的说明。组件库 d5ecc5a:块公式经 `typography::Latex` 由 RaTeX 排出(字形为轮廓,按窗口缩放画),```` ```mermaid ```` 块经 `documents::Diagram` 由 mermaid-rs-renderer 画出(Mermaid 自己的深浅主题,底色与字色取 Ely 色板),都作为 gpui 资源在后台排版,RaTeX 或 Mermaid 对已解析的输入 panic 时显示为错误;`MarkdownRenderer::base` 给出文档所在文件夹,相对与 file 地址的图片从中读取并按自身大小、不超过栏宽显示,`data:` 地址解码,网络图片不载入(写明)。组件库 ab9a14c:每个公式、图与图片元素经 `primitives::held` 持有其资源,按资源计数持有者(8b9820b;画出的图存在共享的记录里,9036c92 起由最后离开的持有者释放,哪怕它自己还没收到图),最后一个持有者换了内容或离开窗口才从 gpui 资源缓存移除、在本帧之后从图集释放,共用同一内容的元素(含 gpui 缓存的视图)不受影响;公式按字形实际范围取画框,字形不被裁;公式失败显示原因、RaTeX 给的位置与原文,日志不含公式;`file:` 地址去掉查询与片段,`localhost` 为本机,Windows 上别的主机为共享路径,他处拒绝;Mermaid 的节点与饼图文字取 Ely 的前景色。组件库 2b6f551:`documents::MarkdownDocument` 是文件级的 Markdown 文档:全文与撤销在一个 `CodeEditor` 里(`CodeEditor::highlighter` 以 `markdown_highlights` 着色,无行号);可视以 gpui `list` 逐块画出文件的顶层块(链接定义、HTML、front matter 照原文显示,每个字节都在块里或块间),按下的块开成源码(`TextInput`),其键入即刻算作文档的文字,写回为一步撤销,上下键越过块边进入相邻块,Esc 写回;Cmd(他处 Ctrl)按下交给链接;预览只读;源码为编辑器本身;128 KiB 以内当场解析,更长的在后台解析,解析中按下块不打开(日志);新解析保留两端相同的块,滚动不跳。`CodeEditor` 的撤销历史最多留 64 MiB 文字,超出先丢最旧的步;`CodeEditor::reset` 换文字且撤销重新开始;`MarkdownMode` 加 `Preview`。组件库 fc181b4:一个插入点通用于各模式(编辑器的选区,写回的块移动它);`set_caret` 与 `set_top_line` 分别设插入点与视图,换模式保持顶行;按下的块在前一块写回后仍是所按的块(按偏移换算,结构变了即不开并记日志);块是 Tab 停靠点,Enter 或空格打开;预览不收按下、撤销与重做;撤销与重做每次记录、输入法组字、撤销、重做后都限在 64 MiB 内(先丢最旧的撤销,再丢最远的重做)。组件库 433c38f:只有对当下文字的解析才能打开块(解析的文字与编辑器相同),否则拒绝;要求的顶行在其文字解析后才落定:先到所在块,下一帧按块高的比例到块内位置,再有新的要求即接着放下一个;源码与列表之间切换保持顶行,可视与预览共用一个列表,切换不动滚动。组件库 abac8b4:顶行的要求带编号,迟到的帧回调只完成自己的那个;文字一换即按新文字重新放,放时按当下文字的行数夹住;`reveal` 是同样的要求(插入点设在该行,该行放到顶上),新文字解析后才落定。组件库 f59c9f7:文字超过读取上限(2 MiB)即丢掉旧的读取,大纲与块为空。ElyMark(C14.4):视图即一个 `MarkdownDocument`,文本即文件原文(BOM 与 CRLF 照 `text.rs` 存回),新建、打开、保存、另存、重命名经外壳照旧;视图在建成后的下一轮更新向外壳报一次载入(文档在 `new` 里当场解析,外壳此时还未订阅);文字超过 2 MiB 即只用源码并转去源码(随当前文字,不只看打开时);网页实现的查找替换、表格、附件、书写视图的应用、导出与打印Line truncated
-89
View File
@@ -1,89 +0,0 @@
// Builds the page into the folder given; no network.
import { build } from 'esbuild'
import { copyFile, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
const out = process.argv[2]
if (!out) throw new Error('usage: node build.mjs <output folder>')
await rm(out, { recursive: true, force: true })
await mkdir(out, { recursive: true })
const built = await build({
entryPoints: ['src/main.js', 'src/diagram.js', 'src/main.css'],
outdir: out,
bundle: true,
minify: true,
format: 'iife',
target: 'chrome140',
loader: { '.woff2': 'file', '.woff': 'file', '.ttf': 'file' },
assetNames: 'fonts/[name]-[hash]',
legalComments: 'none',
metafile: true,
logLevel: 'warning',
})
// Exports carry one stylesheet: fonts inline, woff2 alone.
const exported = await build({
entryPoints: ['src/export.css'],
outdir: out,
bundle: true,
minify: true,
loader: { '.woff2': 'dataurl', '.woff': 'empty', '.ttf': 'empty' },
legalComments: 'none',
metafile: true,
logLevel: 'warning',
})
const sheet = join(out, 'export.css')
// The dropped woff and ttf left empty sources.
await writeFile(sheet, (await readFile(sheet, 'utf8')).replace(/,url\(\) format\("(?:woff|truetype)"\)/g, ''))
await copyFile('index.html', join(out, 'index.html'))
/** Each bundled package's folder under node_modules. */
function packages(inputs) {
const found = new Set()
for (const input of inputs) {
const at = input.lastIndexOf('node_modules/')
if (at < 0) continue
const parts = input.slice(at + 13).split('/')
const name = parts[0].startsWith('@') ? `${parts[0]}/${parts[1]}` : parts[0]
found.add(input.slice(0, at + 13) + name)
}
return [...found].sort()
}
/** Name, version and license; the file, else the manifest's. */
async function notice(folder) {
const manifest = JSON.parse(await readFile(join(folder, 'package.json'), 'utf8'))
const head = `${manifest.name} ${manifest.version} (${manifest.license})`
const file = (await readdir(folder)).find((name) => /^(licen[cs]e|copying)/i.test(name))
if (file) return `${head}\n\n${(await readFile(join(folder, file), 'utf8')).trim()}\n`
const author = manifest.author?.name ?? manifest.author
if (manifest.license !== 'MIT' || !author) throw new Error(`${manifest.name}: no license file`)
console.log(`${manifest.name} ships no license file; MIT from its manifest`)
return `${head}\n\nCopyright (c) ${author}\n\n${MIT}\n`
}
const MIT = `Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.`
const folders = packages([
...Object.keys(built.metafile.inputs),
...Object.keys(exported.metafile.inputs),
])
const notices = await Promise.all(folders.map(notice))
await writeFile(join(out, 'THIRD-PARTY-NOTICES.txt'), notices.join('\n' + '-'.repeat(72) + '\n\n'))
console.log(`built ${folders.length} packages into ${out}`)
-11
View File
@@ -1,11 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="main.css">
<script src="main.js"></script>
</head>
<body>
<main id="document"></main>
</body>
</html>
-4871
View File
File diff suppressed because it is too large. Load diff
-34
View File
@@ -1,34 +0,0 @@
{
"name": "ely-mark-content",
"private": true,
"type": "module",
"dependencies": {
"@codemirror/commands": "6.11.1",
"@codemirror/lang-markdown": "6.5.2",
"@codemirror/language": "6.12.4",
"@codemirror/language-data": "6.5.2",
"@codemirror/search": "6.7.2",
"@codemirror/state": "6.7.6",
"@codemirror/view": "6.43.13",
"@lezer/highlight": "1.2.5",
"@milkdown/kit": "7.22.2",
"codemirror": "6.0.2",
"hast-util-from-dom": "5.0.1",
"hast-util-to-text": "4.0.2",
"katex": "0.19.0",
"mermaid": "12.1.0",
"prosemirror-search": "1.1.1",
"rehype-katex": "7.0.1",
"rehype-stringify": "10.0.1",
"remark-gfm": "4.0.1",
"remark-math": "6.0.0",
"remark-parse": "11.0.0",
"remark-rehype": "11.1.2",
"unified": "11.0.5",
"unist-util-visit": "5.1.0",
"vfile": "6.0.3"
},
"devDependencies": {
"esbuild": "0.28.2"
}
}
-10
View File
@@ -1,10 +0,0 @@
/** One request to the app, as a promise. */
export function ask(request) {
return new Promise((resolve, reject) => {
window.elyQuery({
request: JSON.stringify(request),
onSuccess: resolve,
onFailure: (code, message) => reject(new Error(`${code}: ${message}`)),
})
})
}
-44
View File
@@ -1,44 +0,0 @@
// Code blocks in CodeMirror; LaTeX and Mermaid preview.
import { basicSetup, EditorView } from 'codemirror'
import { EditorState } from '@codemirror/state'
import { keymap } from '@codemirror/view'
import { indentWithTab } from '@codemirror/commands'
import { languages } from '@codemirror/language-data'
import { codeBlockConfig } from '@milkdown/kit/component/code-block'
import { theme } from './theme.js'
import { icons } from './icons.js'
import { render } from './math.js'
/** Previews for one editor's code blocks. */
function previews(owner) {
return (language, source, apply) => {
const kind = source.length === 0 ? '' : language.toLowerCase()
switch (kind) {
case 'latex':
return render(source, true, document.createElement('div'))
case 'mermaid':
return owner.show(source, apply)
default:
return null
}
}
}
/** Milkdown's code block config; read-only in preview. */
export function code(texts, editable, owner) {
const locked = editable ? [] : [EditorState.readOnly.of(true), EditorView.editable.of(false)]
return (ctx) =>
ctx.update(codeBlockConfig.key, (prev) => ({
...prev,
...icons,
extensions: [basicSetup, keymap.of([indentWithTab]), theme, ...locked],
languages,
searchPlaceholder: texts.searchLanguage,
noResultText: texts.noResult,
copyText: texts.copy,
previewLabel: texts.preview,
previewLoading: texts.loading,
previewToggleButton: (only) => (only ? texts.edit : texts.hide),
renderPreview: previews(owner),
}))
}
-34
View File
@@ -1,34 +0,0 @@
// Mermaid, loaded on first use; only saved source renders.
import mermaid from 'mermaid'
let count = 0
/** The theme from the page's Ely tokens. */
function variables() {
const style = getComputedStyle(document.documentElement)
const token = (name) => style.getPropertyValue(`--ely-${name}`).trim()
return {
background: token('bg'),
primaryColor: token('surface'),
primaryTextColor: token('fg'),
primaryBorderColor: token('border'),
lineColor: token('fg-muted'),
textColor: token('fg'),
fontFamily: token('font'),
}
}
/** `source` as SVG; `paper` draws Mermaid's light theme for export. */
window.elyDiagram = async (source, look) => {
const paper = look === 'paper'
mermaid.initialize({
startOnLoad: false,
securityLevel: 'strict',
suppressErrorRendering: true,
theme: paper ? 'default' : 'base',
themeVariables: paper ? {} : variables(),
})
count += 1
const { svg } = await mermaid.render(`ely-diagram-${count}`, source)
return svg
}
-90
View File
@@ -1,90 +0,0 @@
// Mermaid previews; each editor owns its records.
import { failure } from './failure.js'
// Beside this script; the page's base is the document's folder.
const here = document.currentScript.src
/** The Mermaid bundle, added once as a classic script. */
let loading
export function bundle() {
loading ??= new Promise((resolve, reject) => {
const script = document.createElement('script')
script.src = new URL('diagram.js', here).href
script.onload = () => resolve(window.elyDiagram)
script.onerror = () => reject(new Error('diagram.js did not load'))
document.head.append(script)
})
return loading
}
let queue = Promise.resolve()
let count = 0
const owners = new Set()
// New tokens mean a new theme; every owner redraws.
new MutationObserver(() => {
for (const owner of owners) owner.redraw()
}).observe(document.documentElement, { attributes: true, attributeFilter: ['style'] })
/** A block's diagram, by its id on the page. */
const box = (id, inner) => `<div class="ely-diagram" data-diagram="${id}">${inner}</div>`
const placed = (id) => document.querySelector(`[data-diagram="${id}"]`) !== null
// After the code block's next render.
const settle = () => new Promise((done) => setTimeout(done))
/** One editor's diagrams; a result lands only on its placeholder. */
export function diagrams() {
const records = new Map()
/** Drops diagrams whose placeholder has left the page. */
const sweep = () => {
for (const [id, record] of records) {
if (record.settled && !placed(id)) records.delete(id)
}
}
/** Each draw has a turn; a later draw voids earlier. */
const draw = (id, record) => {
const turn = ++record.turn
const live = () => records.get(id) === record && record.turn === turn && placed(id)
queue = queue.then(async () => {
await settle()
if (!live()) return
try {
const render = await bundle()
if (!live()) return
const svg = await render(record.source)
await settle()
if (live()) record.apply(box(id, svg))
} catch (error) {
await settle()
if (!live()) return
records.delete(id)
record.apply(failure(record.source, error.message))
}
})
}
const owner = {
/** The placeholder for `source`; its diagram follows. */
show(source, apply) {
sweep()
const id = `${++count}`
const record = { source, apply, settled: false, turn: 0 }
records.set(id, record)
settle().then(() => (record.settled = true))
draw(id, record)
return box(id, '')
},
redraw() {
sweep()
for (const [id, record] of records) draw(id, record)
},
release() {
records.clear()
owners.delete(owner)
},
}
owners.add(owner)
return owner
}
-53
View File
@@ -1,53 +0,0 @@
// Myers' diff over two lists.
/** Equal pairs between `a` and `b`, in order. */
export function common(a, b, same) {
const n = a.length
const m = b.length
const max = n + m
const reach = new Map([[1, 0]])
const trace = []
for (let d = 0; d <= max; d++) {
trace.push(new Map(reach))
for (let k = -d; k <= d; k += 2) {
const down = k === -d || (k !== d && reach.get(k - 1) < reach.get(k + 1))
let x = down ? reach.get(k + 1) : reach.get(k - 1) + 1
let y = x - k
while (x < n && y < m && same(a[x], b[y])) {
x += 1
y += 1
}
reach.set(k, x)
if (x >= n && y >= m) return walk(trace, n, m)
}
}
throw new Error('diff did not end')
}
/** Back through the trace: each equal pair, in order. */
function walk(trace, n, m) {
const pairs = []
let x = n
let y = m
for (let d = trace.length - 1; d > 0; d--) {
const reach = trace[d]
const k = x - y
const down = k === -d || (k !== d && reach.get(k - 1) < reach.get(k + 1))
const from = down ? k + 1 : k - 1
const fromX = reach.get(from)
const fromY = fromX - from
while (x > fromX + (down ? 0 : 1) && y > fromY + (down ? 1 : 0)) {
x -= 1
y -= 1
pairs.push([x, y])
}
x = fromX
y = fromY
}
while (x > 0 && y > 0) {
x -= 1
y -= 1
pairs.push([x, y])
}
return pairs.reverse()
}
-72
View File
@@ -1,72 +0,0 @@
/* Exports: KaTeX with its fonts inline, on light paper. */
@import 'katex/dist/katex.min.css';
:root {
color-scheme: light;
}
body {
margin: 0 auto;
max-width: 48rem;
padding: 2rem 1.5rem;
background: #fff;
color: #1f2328;
font: 16px / 1.6 -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif;
}
a {
color: #0969da;
}
pre,
code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.875em;
}
pre {
overflow-x: auto;
padding: 0.75rem 1rem;
border-radius: 6px;
background: #f6f8fa;
}
blockquote {
margin: 0;
padding: 0 1rem;
border-left: 3px solid #d0d7de;
color: #59636e;
}
table {
border-collapse: collapse;
}
th,
td {
padding: 0.25rem 0.75rem;
border: 1px solid #d0d7de;
}
img,
figure.diagram svg {
max-width: 100%;
}
figure.diagram {
margin: 1rem 0;
text-align: center;
}
@media print {
body {
max-width: none;
padding: 0;
}
pre,
table,
figure {
break-inside: avoid;
}
}
-11
View File
@@ -1,11 +0,0 @@
/** The source as saved, and why it did not render. */
export function failure(source, message, at) {
const box = document.createElement('div')
box.className = 'ely-failure'
const why = document.createElement('p')
why.textContent = at === undefined ? message : `${message} (${at})`
const code = document.createElement('pre')
code.textContent = source
box.append(why, code)
return box
}
-138
View File
@@ -1,138 +0,0 @@
// Find and replace in either editor (MARK-06).
import {
SearchQuery as TextQuery,
findNext as nextText,
findPrevious as previousText,
replaceAll as allText,
replaceNext as oneText,
setSearchQuery,
} from '@codemirror/search'
import {
SearchQuery as DocQuery,
findNext as nextDoc,
findPrev as previousDoc,
replaceAll as allDoc,
replaceNext as oneDoc,
setSearchState,
} from 'prosemirror-search'
import { TextSelection } from '@milkdown/kit/prose/state'
/** Why a pattern fails under the editors' `gmu` flags. */
function broken(spec) {
if (!spec.regexp || !spec.search) return null
try {
new RegExp(spec.search, 'gmu')
return null
} catch (error) {
return error.message
}
}
/** Whether two specs find the same hits. */
export function sameQuery(a, b) {
return ['search', 'caseSensitive', 'regexp', 'wholeWord'].every((key) => a[key] === b[key])
}
/** One character's width at `pos`: two for a surrogate pair. */
function width(doc, pos) {
const next = doc.textBetween(pos, Math.min(pos + 2, doc.content.size), '\n', '\n')
return next.codePointAt(0) > 0xffff ? 2 : 1
}
/** Where the selection stands among `ranges`. */
function standing(ranges, from, to) {
const at = ranges.findIndex((range) => range.from === from && range.to === to)
return { current: at < 0 ? null : at, total: ranges.length }
}
/** Find in Milkdown's view; `prosemirror-search` is installed there. */
export function docFinder(view) {
let spec = { search: '' }
const query = () => new DocQuery(spec)
const ranges = () => {
const found = []
const asked = query()
if (!asked.valid) return found
const { doc } = view.state
for (let at = 0; ;) {
const hit = asked.findNext(view.state, at)
// Mid-pair, a Unicode pattern answers before `at`.
if (!hit || hit.from < at) return found
found.push(hit)
at = hit.to > hit.from ? hit.to : hit.to + width(doc, hit.to)
}
}
const status = () => {
const error = broken(spec)
if (error) return { error }
const { from, to } = view.state.selection
return standing(ranges(), from, to)
}
const run = (command) => command(view.state, view.dispatch)
return {
seek(next) {
spec = next
const { from } = view.state.selection
const tr = setSearchState(view.state.tr, query())
// From the hit's start: the hit stays while typing.
tr.setSelection(TextSelection.near(tr.doc.resolve(from)))
view.dispatch(tr)
if (query().valid) run(nextDoc)
return status()
},
step(forward) {
if (query().valid) run(forward ? nextDoc : previousDoc)
return status()
},
replace(all) {
if (query().valid) run(all ? allDoc : oneDoc)
return status()
},
clear() {
spec = { search: '' }
view.dispatch(setSearchState(view.state.tr, query()))
},
}
}
/** Find in CodeMirror; its `search()` extension is installed there. */
export function textFinder(view) {
let spec = { search: '' }
const valid = () => new TextQuery(spec).valid
const ranges = () => {
const found = []
const asked = new TextQuery(spec)
if (!asked.valid) return found
const cursor = asked.getCursor(view.state)
for (let next = cursor.next(); !next.done; next = cursor.next()) found.push(next.value)
return found
}
const status = () => {
const error = broken(spec)
if (error) return { error }
const { from, to } = view.state.selection.main
return standing(ranges(), from, to)
}
return {
seek(next) {
spec = next
const selection = { anchor: view.state.selection.main.from }
view.dispatch({ effects: setSearchQuery.of(new TextQuery(spec)), selection })
// CodeMirror opens its own panel for invalid queries.
if (valid()) nextText(view)
return status()
},
step(forward) {
if (valid()) (forward ? nextText : previousText)(view)
return status()
},
replace(all) {
if (valid()) (all ? allText : oneText)(view)
return status()
},
clear() {
spec = { search: '' }
view.dispatch({ effects: setSearchQuery.of(new TextQuery(spec)) })
},
}
}
-12
View File
@@ -1,12 +0,0 @@
// Code block icons, drawn in the text color.
const icon = (path) =>
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${path}</svg>`
export const icons = {
expandIcon: icon('<path d="m6 9 6 6 6-6"/>'),
searchIcon: icon('<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>'),
clearSearchIcon: icon('<path d="M18 6 6 18M6 6l12 12"/>'),
copyIcon: icon(
'<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/>',
),
}
-14
View File
@@ -1,14 +0,0 @@
// Untitled images: remark gives null, the schema wants text.
import { imageSchema } from '@milkdown/kit/preset/commonmark'
export const image = imageSchema.extendSchema((prev) => (ctx) => {
const base = prev(ctx)
return {
...base,
parseMarkdown: {
match: base.parseMarkdown.match,
runner: (state, node, type) =>
state.addNode(type, { src: node.url, alt: node.alt ?? '', title: node.title ?? '' }),
},
}
})
-22
View File
@@ -1,22 +0,0 @@
// The writing view: focus, typewriter, width, font, size (MARK-09).
let typewriter = false
/** Applies `look`; the Markdown never changes. */
export function apply(look) {
const root = document.documentElement
root.dataset.focus = look.focus
root.dataset.width = look.width
root.dataset.font = look.font
root.dataset.typewriter = look.typewriter
typewriter = look.typewriter
const sheet =
document.getElementById('ely-look') ??
document.head.appendChild(Object.assign(document.createElement('style'), { id: 'ely-look' }))
sheet.textContent = `:root { --ely-text-scale: ${look.size / 100}; }`
}
/** Typewriter scrolling: the caret's line stays mid-view. */
export function follow(editor) {
if (typewriter) editor?.center?.()
}
-330
View File
@@ -1,330 +0,0 @@
@import 'katex/dist/katex.min.css';
@import '@milkdown/kit/prose/view/style/prosemirror.css';
html,
body {
margin: 0;
height: 100%;
background: var(--ely-bg);
color: var(--ely-fg);
font: calc(16px * var(--ely-font-scale, 1) * var(--ely-text-scale, 1)) / 1.6 var(--ely-font),
sans-serif;
}
::selection {
background: var(--ely-selection);
}
a {
color: var(--ely-link);
}
#document {
box-sizing: border-box;
max-width: 48rem;
margin: 0 auto;
padding: 2rem 1.5rem;
}
:root[data-width='narrow'] #document {
max-width: 36rem;
}
:root[data-width='wide'] #document {
max-width: 64rem;
}
:root[data-font='serif'] #document:not([data-mode='source']) {
font-family: ui-serif, Georgia, serif;
}
:root[data-font='mono'] #document:not([data-mode='source']) {
font-family: var(--ely-mono), monospace;
}
/* Typewriter: room to center the first and last lines. */
:root[data-typewriter='true'] #document:not([data-mode='source']) {
padding-block: 50vh;
}
:root[data-typewriter='true'] .cm-content {
padding-block: 50vh;
}
/* Focus mode: the caret's block, or line, stays bright. */
:root[data-focus='true'] .milkdown .ProseMirror > :not(.ely-current),
:root[data-focus='true'] .cm-line:not(.cm-activeLine) {
opacity: 0.35;
}
@media (prefers-reduced-motion: no-preference) {
.milkdown .ProseMirror > *,
.cm-line {
transition: opacity 150ms;
}
}
#document[data-mode='source'] {
max-width: none;
height: 100%;
padding: 0;
}
#document[data-mode='source'] > div,
#document[data-mode='source'] .cm-editor {
height: 100%;
}
.milkdown .ProseMirror {
outline: none;
}
.milkdown blockquote {
margin: 0;
padding-left: 1rem;
border-left: 3px solid var(--ely-border);
color: var(--ely-fg-muted);
}
.milkdown code {
font-family: var(--ely-mono), monospace;
font-size: 0.875em;
background: var(--ely-sunken);
border-radius: 4px;
padding: 0 0.25em;
}
.milkdown table {
border-collapse: collapse;
}
.milkdown th,
.milkdown td {
border: 1px solid var(--ely-border);
padding: 0.25rem 0.75rem;
}
.milkdown li > p,
.milkdown th > p,
.milkdown td > p {
margin: 0.25em 0;
}
.milkdown li[data-item-type='task'] {
display: flex;
gap: 0.5em;
list-style: none;
}
/* A text-sized box, centred on the first line. */
.milkdown li[data-item-type='task']::before {
content: '';
flex: none;
box-sizing: border-box;
width: 1em;
height: 1em;
margin-top: calc(0.25em + (1lh - 1em) / 2);
border: 0.09em solid var(--ely-fg-muted);
border-radius: 0.2em;
}
.milkdown li[data-item-type='task'][data-checked='true']::before {
border-color: var(--ely-link);
background: var(--ely-link);
box-shadow: inset 0 0 0 0.18em var(--ely-bg);
}
.milkdown hr {
border: none;
border-top: 1px solid var(--ely-border);
}
.milkdown img {
max-width: 100%;
}
.milkdown-code-block {
display: block;
position: relative;
margin: 0.5rem 0;
padding: 0.5rem 1rem 1rem;
background: var(--ely-surface);
border: 1px solid var(--ely-border);
border-radius: 6px;
}
.milkdown-code-block.selected {
outline: 1px solid var(--ely-focus);
}
.milkdown-code-block .hidden {
display: none !important;
}
.milkdown-code-block .tools {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
font-size: 0.75rem;
}
.milkdown-code-block button,
.milkdown-code-block .language-button {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border: 1px solid var(--ely-border);
border-radius: 4px;
background: var(--ely-sunken);
color: var(--ely-fg-muted);
font: inherit;
cursor: pointer;
}
[data-mode='preview'] .milkdown-code-block .language-button {
pointer-events: none;
}
.milkdown-code-block .tools-button-group {
display: flex;
gap: 4px;
}
.milkdown-code-block button:hover,
.milkdown-code-block .language-button:hover {
background: var(--ely-hover);
}
.milkdown-code-block .tools svg,
.milkdown-code-block .language-picker svg {
width: 14px;
height: 14px;
}
.ely-diagram svg {
max-width: 100%;
height: auto;
}
.milkdown-code-block .language-picker {
position: absolute;
z-index: 1;
padding-top: 4px;
}
.milkdown-code-block .list-wrapper {
width: 15rem;
padding-top: 0.5rem;
background: var(--ely-surface);
border: 1px solid var(--ely-border);
border-radius: 6px;
}
.milkdown-code-block .search-box {
display: flex;
align-items: center;
gap: 6px;
margin: 0 0.5rem 0.5rem;
padding: 4px 8px;
border: 1px solid var(--ely-border);
border-radius: 4px;
}
.milkdown-code-block .search-box input {
width: 100%;
border: none;
outline: none;
background: transparent;
color: var(--ely-fg);
font: inherit;
}
.milkdown-code-block .search-icon {
display: none;
}
.milkdown-code-block .language-list {
max-height: 20rem;
overflow-y: auto;
margin: 0;
padding: 0;
}
.milkdown-code-block .language-list-item {
display: flex;
gap: 8px;
padding: 2px 1rem;
cursor: pointer;
}
.milkdown-code-block .language-list-item:hover,
.milkdown-code-block .language-list-item:focus-visible {
outline: none;
background: var(--ely-hover);
}
.milkdown-code-block .language-list-item.no-result {
cursor: default;
color: var(--ely-fg-subtle);
}
.milkdown-code-block .preview-panel .preview-divider {
height: 1px;
margin: 0.5rem 0;
background: var(--ely-border);
}
.milkdown-code-block .preview-panel .preview-label {
margin: 0.5rem 0;
font-size: 0.75rem;
color: var(--ely-fg-muted);
}
.milkdown-code-block .preview {
overflow-x: auto;
text-align: center;
}
.ely-failure {
text-align: left;
color: var(--ely-danger);
}
.ely-failure pre {
margin: 0;
white-space: pre-wrap;
color: var(--ely-fg);
font-family: var(--ely-mono), monospace;
}
.ely-front {
margin: 0 0 1rem;
padding: 0.5rem 1rem;
border: 1px solid var(--ely-border);
border-radius: 6px;
color: var(--ely-fg-muted);
font-family: var(--ely-mono), monospace;
white-space: pre-wrap;
}
.ely-raw {
margin: 0.5rem 0;
padding: 0.25rem 0.5rem;
background: var(--ely-sunken);
border-radius: 4px;
font-family: var(--ely-mono), monospace;
font-size: 0.875em;
white-space: pre-wrap;
}
.ProseMirror-search-match,
.cm-searchMatch {
background: var(--ely-selection);
}
.ProseMirror-active-search-match,
.cm-searchMatch-selected {
outline: 1px solid var(--ely-focus);
}
-342
View File
@@ -1,342 +0,0 @@
// The page the app drives: one document, three modes.
import { visual } from './visual.js'
import { source } from './source.js'
import { ask } from './bridge.js'
import { sameQuery } from './find.js'
import { apply, follow } from './look.js'
import { html, text } from './render.js'
let texts = {}
let latest = 0
let version = 0
// The app's count of page loads; reports carry it.
let generation = 0
let timer
let pending = null
/** The editor shown. */
let current = null
// A mode asked for while an editor builds.
let asked = null
// The text no editor could show; a mode retries it.
let broken = null
// A place to show once the editor is built.
let revealing = null
// The search in force, kept across modes.
let seeking = null
/** The file's texts where a mode with edits ended (§14.3). */
let times = []
let at = 0
/** YAML front matter, kept out of the visual editor. */
function split(text) {
const front = /^---\n[\s\S]*?\n(?:---|\.\.\.)[ \t]*(?:\n|$)/.exec(text)
return front ? [front[0], text.slice(front[0].length)] : ['', text]
}
const tell = (said) => ask(said).catch((error) => console.error(`ely: ${error.message}`))
/** Sends the waiting text now. */
function flush() {
clearTimeout(timer)
if (pending) {
tell(pending())
outline()
}
pending = null
}
/** A link the user opens; the app decides. */
const linked = (href) => tell({ kind: 'link', generation, href })
/** The shown editor's headings, for the app's outline. */
function outline() {
if (current) tell({ kind: 'outline', generation, items: current.outline() })
}
/** A new text: told now, its words once typing rests. */
function revised(text) {
version += 1
const now = version
tell({ kind: 'changed', version: now, generation })
clearTimeout(timer)
pending = () => ({ kind: 'text', version: now, generation, text: text() })
timer = setTimeout(flush, 300)
}
/** An edit: later texts in the timeline are gone. */
function changed(text) {
times.length = at + 1
revised(text)
}
/** An editor for `mode`; a failed one leaves no holder. */
async function build(text, mode) {
const holder = document.createElement('div')
holder.hidden = true
document.getElementById('document').append(holder)
try {
return await fill(holder, text, mode)
} catch (error) {
holder.remove()
throw error
}
}
/** Builds in `holder`; visual falls back to source. */
async function fill(holder, text, mode) {
const entry = { mode, holder, fallback: false }
if (mode !== 'source') {
const [front, body] = split(text)
if (front) {
const raw = document.createElement('pre')
raw.className = 'ely-front'
raw.textContent = front
holder.append(raw)
}
const place = document.createElement('div')
holder.append(place)
const edited = (write) => changed(() => front + write())
let made
try {
made = await visual(place, body, mode === 'visual', texts, edited, edge, linked)
} catch (error) {
// A broken editor falls back to source and says so.
entry.broke = error.name
}
if (made) {
// Lines count from the file's top, front matter included.
const above = front ? front.split('\n').length - 1 : 0
const line = (n, query) => {
if (n > above) return made.line(n - above, query)
holder.firstChild.scrollIntoView({ block: 'center' })
return true
}
const redone = () => {
const ahead = made.redone()
return ahead === null ? null : front + ahead
}
const where = () => {
const { line, column } = made.where()
return { line: above + line, column }
}
const at = (n, column) => (n > above ? made.at(n - above, column) : line(n, ''))
return { ...entry, ...made, text: () => front + made.text(), redone, line, where, at }
}
// Blocks the editor cannot map open as source.
holder.replaceChildren()
Object.assign(entry, { mode: 'source', fallback: true })
}
return { ...entry, ...source(holder, text, changed, edge) }
}
function drop(entry) {
entry.close()
entry.holder.remove()
}
/** Shows `text` as `want`; the newest request alone lands. */
async function show(text, want) {
const turn = ++latest
asked = null
broken = null
if (current) drop(current)
current = null
let entry
try {
entry = await build(text, want)
} catch (error) {
if (turn !== latest) return
// Source failed too: tell the app, keep the text.
broken = text
tell({ kind: 'failed', generation, what: error.name })
const next = asked
asked = null
return next && mode(next)
}
if (turn !== latest) return drop(entry)
current = entry
entry.holder.hidden = false
document.getElementById('document').dataset.mode = entry.mode
entry.focus()
await ask({ kind: 'shown', mode: entry.mode, fallback: entry.fallback, broke: entry.broke ?? null })
// A newer request owns what is queued now.
if (turn !== latest) return
outline()
if (seeking) found(entry.find.seek(seeking))
const place = revealing
revealing = null
if (place) reveal(place)
const next = asked
asked = null
if (next && next !== entry.mode) return mode(next)
}
/** Undo or redo past an editor's own history. */
function edge(direction) {
const to = direction === 'undo' ? at - 1 : at + 1
if (!current || to < 0 || to >= times.length) return false
flush()
at = to
const text = times[at]
revised(() => text)
show(text, current.mode)
return true
}
/** Relative links and images resolve under `href`. */
function base(href) {
const link =
document.querySelector('base') ?? document.head.appendChild(document.createElement('base'))
link.href = href
}
/** Puts the caret on `place.line`, the page at `place.scroll`. */
function settle(place) {
if (!current || !place) return
current.at(place.line, place.column)
document.scrollingElement.scrollTop = place.scroll
}
/** A document, after each page load; `place` restores a session. */
async function open(text, want, words, load, folder, place) {
texts = words
generation = load
times = [text]
at = 0
base(folder)
await show(text, want)
settle(place)
}
/** Changed on disk: the caret and scroll stay near. */
async function refresh(text, load) {
const place = current && { ...current.where(), scroll: document.scrollingElement.scrollTop }
clearTimeout(timer)
pending = null
generation = load
version = 0
times = [text]
at = 0
await show(text, current?.mode ?? 'visual')
settle(place)
}
/** `text` in place of the editor's, as one edit. */
function adopt(text) {
if (current) flush()
times.length = at + 1
times.push(text)
at += 1
revised(() => text)
return show(text, current?.mode ?? 'visual')
}
let placing
/** The caret's line and the scroll, once they rest. */
function placed() {
clearTimeout(placing)
placing = setTimeout(() => {
if (!current) return
const scroll = document.scrollingElement.scrollTop
tell({ kind: 'place', generation, ...current.where(), scroll })
}, 500)
}
document.addEventListener('selectionchange', () => {
placed()
follow(current)
})
window.addEventListener('scroll', placed, { passive: true })
/** Another mode for the same text; edits are sent first. */
function mode(next) {
if (!current) {
if (broken !== null) return show(broken, next)
asked = next
return
}
const text = current.text()
const ahead = current.redone?.() ?? null
flush()
// Leaving a mode with edits marks the timeline.
if (text !== times[at]) {
times.length = at + 1
times.push(text)
at += 1
}
// Undone steps stay ahead, for redo.
if (ahead !== null && ahead !== text) {
times.length = at + 1
times.push(ahead)
}
return show(text, next)
}
/** Shows `{ heading }` or `{ line, query }`. */
function reveal(place) {
if (!current) {
revealing = place
return
}
const shown =
'heading' in place ? current.heading(place.heading) : current.line(place.line, place.query)
// The app logs a line number, never the query.
if (shown === false) tell({ kind: 'missed', generation, line: place.line ?? null })
}
/** The app's find box hears where the search stands. */
const found = (status) => tell({ kind: 'found', generation, ...status })
/** Searches for `spec`: search, caseSensitive, regexp, wholeWord, replace. */
function find(spec) {
seeking = spec
if (current) found(current.find.seek(spec))
}
/** The box's spec rules; a new query starts afresh. */
function step({ spec, forward }) {
const fresh = !seeking || !sameQuery(seeking, spec)
find(spec)
if (current && !fresh) found(current.find.step(forward))
}
function replace({ spec, all }) {
find(spec)
if (current && current.mode !== 'preview') found(current.find.replace(all))
}
function unfind() {
seeking = null
current?.find.clear()
}
/** The writing view's settings (MARK-09). */
function look(settings) {
apply(settings)
follow(current)
}
/** The whole text as `kind`: html or text, tagged `ask`. */
async function render({ kind, ask: asked }) {
try {
if (!current) throw new Error('no document is shown')
const [, body] = split(current.text())
const result = kind === 'text' ? { text: await text(body) } : await html(body)
tell({ kind: 'rendered', generation, ask: asked, ...result })
} catch (error) {
tell({ kind: 'rendered', generation, ask: asked, error: error.message })
}
}
/** A table step; the app hears whether it ran. */
function table(op) {
const done = current?.table?.(op) ?? false
tell({ kind: 'table', generation, op, done })
}
/** Image links at the caret; preview takes none. */
function insert(images) {
const done = current?.insert?.(images) ?? false
tell({ kind: 'inserted', generation, done })
}
window.ely = { open, refresh, adopt, look, render, mode, base, reveal, find, step, replace, unfind, table, insert }
-100
View File
@@ -1,100 +0,0 @@
// KaTeX math, from Milkdown Crepe's latex feature.
import katex from 'katex'
import remarkMath from 'remark-math'
import { visit } from 'unist-util-visit'
import { $nodeSchema, $remark } from '@milkdown/kit/utils'
import { codeBlockSchema } from '@milkdown/kit/preset/commonmark'
import { failure } from './failure.js'
/** Saved source as HTML; a bad one shows where. */
export function render(source, display, dom) {
try {
katex.render(source, dom, { displayMode: display, throwOnError: true })
} catch (error) {
const at = error instanceof katex.ParseError ? error.position : undefined
dom.replaceChildren(failure(source, error.message, at))
}
return dom
}
// remark-math, plus each inline formula's `$` count from the source.
const remark = $remark('remarkMath', () => function (options) {
remarkMath.call(this, options)
return (tree, file) => {
const text = String(file.value)
visit(tree, 'inlineMath', (node) => {
let at = node.position?.start.offset ?? 0
let fence = 0
while (text[at++] === '$') fence++
node.data = { ...node.data, fence: Math.max(fence, 1) }
})
}
})
// Block math becomes a code block in language LaTeX.
const blocks = $remark(
'remarkMathBlock',
() => () => (tree) =>
visit(tree, 'math', (node, index, parent) => {
parent.children.splice(index, 1, {
type: 'code',
lang: 'LaTeX',
value: node.value,
position: node.position,
})
}),
)
const block = codeBlockSchema.extendSchema((prev) => (ctx) => {
const base = prev(ctx)
return {
...base,
toMarkdown: {
match: base.toMarkdown.match,
runner: (state, node) => {
if ((node.attrs.language ?? '').toLowerCase() !== 'latex') {
return base.toMarkdown.runner(state, node)
}
state.addNode('math', undefined, node.content.firstChild?.text || '')
},
},
}
})
const inline = $nodeSchema('math_inline', () => ({
group: 'inline',
inline: true,
atom: true,
attrs: { value: { default: '' }, fence: { default: 1 } },
parseDOM: [
{
tag: 'span[data-type="math_inline"]',
getAttrs: (dom) => ({ value: dom.dataset.value ?? '', fence: Number(dom.dataset.fence ?? 1) }),
},
],
toDOM: (node) => {
const dom = document.createElement('span')
dom.dataset.type = 'math_inline'
dom.dataset.value = node.attrs.value
dom.dataset.fence = node.attrs.fence
// `$$…$$` displays, as GitHub shows it.
return render(node.attrs.value, node.attrs.fence > 1, dom)
},
parseMarkdown: {
match: (node) => node.type === 'inlineMath',
runner: (state, node, type) =>
state.addNode(type, { value: node.value, fence: node.data?.fence ?? 1 }),
},
toMarkdown: {
match: (node) => node.type.name === 'math_inline',
runner: (state, node) => {
const { value, fence } = node.attrs
if (fence === 1) return state.addNode('inlineMath', undefined, value)
// The stringifier knows one `$`; the source's own count stays.
const dollars = '$'.repeat(fence)
state.addNode('html', undefined, `${dollars}${value}${dollars}`)
},
},
}))
export const math = [remark, blocks, block, inline].flat()
-149
View File
@@ -1,149 +0,0 @@
// Blocks Milkdown cannot write back stay raw text (§14.3).
import { editorViewCtx, remarkCtx, schemaCtx, serializerCtx } from '@milkdown/kit/core'
import { canon } from './tables.js'
import { Fragment } from '@milkdown/kit/prose/model'
import { Selection } from '@milkdown/kit/prose/state'
import { $nodeSchema } from '@milkdown/kit/utils'
import { common } from './diff.js'
/** Source shown and written as it is. */
export const raw = $nodeSchema('ely_raw', () => ({
group: 'block',
content: 'text*',
marks: '',
code: true,
defining: true,
isolating: true,
parseDOM: [{ tag: 'pre.ely-raw', preserveWhitespace: 'full' }],
toDOM: () => ['pre', { class: 'ely-raw' }, 0],
parseMarkdown: { match: () => false, runner: () => {} },
toMarkdown: {
match: (node) => node.type.name === 'ely_raw',
runner: (state, node) => state.addNode('html', undefined, node.textContent),
},
}))
/** Each block's span, as Milkdown reads it; null if unplaced. */
function spans(ctx, text) {
const remark = ctx.get(remarkCtx)
const tree = remark.runSync(remark.parse(text), text)
const at = tree.children.map(
(node) => node.position && [node.position.start.offset, node.position.end.offset],
)
return at.every(Boolean) ? at : null
}
/** The text in `from`..`to` past its outer whitespace. */
function inner(text, from, to) {
const piece = text.slice(from, to)
const start = piece.search(/\S/)
if (start < 0) return null
return [from + start, from + piece.trimEnd().length]
}
/** Raw blocks in place; edits then rewrite only their blocks. */
export function faithful(ctx, original) {
const view = ctx.get(editorViewCtx)
const parsed = view.state.doc
const at = spans(ctx, original)
// No blocks: Milkdown still holds one empty paragraph.
const blank = at?.length === 0 && parsed.childCount === 1 && parsed.textContent === ''
if (!at || (at.length !== parsed.childCount && !blank)) return null
const schema = ctx.get(schemaCtx)
const serializer = ctx.get(serializerCtx)
const written = (nodes) => serializer(schema.topNodeType.create(null, nodes)).replace(/\n+$/, '')
// Table spacing is free; every cell's source must match.
const kept = (node, text) => {
const own = written([node])
return own === text || canon(own) === canon(text)
}
const rawOf = ([from, to]) => {
const text = original.slice(from, to)
return schema.nodes.ely_raw.create(null, text ? schema.text(text) : null)
}
const nodes = []
const places = []
// Text Milkdown drops between blocks, as raw blocks.
const gap = (from, to) => {
const place = inner(original, from, to)
if (!place) return
nodes.push(rawOf(place))
places.push(place)
}
let last = 0
if (!blank)
parsed.forEach((node, _, index) => {
gap(last, at[index][0])
nodes.push(kept(node, original.slice(...at[index])) ? node : rawOf(at[index]))
places.push(at[index])
last = at[index][1]
})
gap(last, original.length)
if (nodes.length) {
const swap = view.state.tr.replaceWith(0, parsed.content.size, Fragment.from(nodes))
swap.setSelection(Selection.atStart(swap.doc))
view.dispatch(swap.setMeta('addToHistory', false))
}
const first = view.state.doc
const before = []
first.forEach((node) => before.push(node))
/** Where each fresh block sits in `text` from `at`. */
const found = (nodes, text, at, spans) => {
let from = 0
for (const node of nodes) {
const own = written([node])
const place = text.indexOf(own, from)
spans.push(place < 0 ? null : [at + place, at + place + own.length])
if (place >= 0) from = place + own.length
}
}
// The text and each block's span in it.
return (doc) => {
if (doc.eq(first)) return { text: original, spans: places }
const after = []
doc.forEach((node) => after.push(node))
if (!places.length) {
const fresh = written(after)
const spans = []
found(after, fresh, original.length, spans)
return { text: `${original}${fresh}\n`, spans }
}
const pairs = common(before, after, (x, y) => x === y || x.eq(y))
pairs.push([before.length, after.length])
let out = ''
let cursor = 0
let [i, j] = [0, 0]
const spans = []
for (const [x, y] of pairs) {
if (x > i || y > j) {
const fresh = y > j ? written(after.slice(j, y)) : ''
let start, end, text
if (x > i && fresh) {
;[start, end, text] = [places[i][0], places[x - 1][1], fresh]
} else if (x > i && i > 0) {
// Removed blocks take the gap before them.
;[start, end, text] = [places[i - 1][1], places[x - 1][1], '']
} else if (x > i && x < places.length) {
;[start, end, text] = [places[0][0], places[x][0], '']
} else if (x > i) {
;[start, end, text] = [places[0][0], places[x - 1][1], '']
} else if (i > 0) {
;[start, end, text] = [places[i - 1][1], places[i - 1][1], `\n\n${fresh}`]
} else {
;[start, end, text] = [places[0][0], places[0][0], `${fresh}\n\n`]
}
out += original.slice(cursor, start)
found(after.slice(j, y), text, out.length, spans)
out += text
cursor = end
}
// Unchanged blocks keep their bytes, shifted.
if (x < places.length) {
const shift = out.length - cursor
spans.push([places[x][0] + shift, places[x][1] + shift])
}
;[i, j] = [x + 1, y + 1]
}
return { text: out + original.slice(cursor), spans }
}
}
-109
View File
@@ -1,109 +0,0 @@
// The whole document as one page or as text (MARK-11).
import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import remarkRehype from 'remark-rehype'
import rehypeKatex from 'rehype-katex'
import { fromDom } from 'hast-util-from-dom'
import rehypeStringify from 'rehype-stringify'
import { toText } from 'hast-util-to-text'
import { visit } from 'unist-util-visit'
import { VFile } from 'vfile'
import { bundle } from './diagrams.js'
const markdown = () => unified().use(remarkParse).use(remarkGfm).use(remarkMath)
/** Mermaid drawn for paper; a broken one stays code. */
async function draw(tree) {
const blocks = []
visit(tree, 'code', (node) => {
if (node.lang === 'mermaid') blocks.push(node)
})
const failed = []
if (!blocks.length) return failed
const paint = await bundle()
for (const node of blocks) {
try {
const svg = await paint(node.value, 'paper')
Object.assign(node, { type: 'html', value: `<figure class="diagram">${svg}</figure>` })
} catch {
// Lines only: Mermaid's message quotes the document.
failed.push(node.position.start.line)
}
}
return failed
}
/** A `srcset`'s first URL, split as the spec does. */
function first(srcset) {
const url = srcset.replace(/^[\s,]+/, '').split(/\s/)[0]
return url.replace(/,+$/, '')
}
/** One `src` per image, swapped for the app's mark. */
function marked(html) {
const doc = new DOMParser().parseFromString(html, 'text/html')
const token = `ely-image-${crypto.randomUUID()}-`
// Responsive sources go: the page carries one file per image.
for (const source of doc.querySelectorAll('picture > source')) source.remove()
const images = []
for (const image of doc.querySelectorAll('img')) {
const srcset = image.getAttribute('srcset')
image.removeAttribute('srcset')
image.removeAttribute('sizes')
const src = image.getAttribute('src') || (srcset && first(srcset))
if (!src) continue
images.push(src)
image.setAttribute('src', token + (images.length - 1))
}
return { html: doc.body.innerHTML, images, token }
}
/** KaTeX's failures: line and its message, never logged. */
function formulas(file) {
return file.messages
.filter((message) => message.source === 'rehype-katex')
.map((message) => ({
line: message.line,
error: message.cause?.rawMessage ?? message.cause?.name ?? message.reason
}))
}
/** `body` as HTML, images marked; the failed diagrams and formulas. */
export async function html(body) {
const tree = markdown().parse(body)
displayed(tree, body)
const failed = await draw(tree)
const page = unified()
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeKatex)
.use(rehypeStringify, { allowDangerousHtml: true })
const file = new VFile()
const done = page.stringify(await page.run(tree, file), file)
return { ...marked(done), failed, formulas: formulas(file) }
}
/** `$$…$$` inline displays, as in the editor. */
function displayed(tree, body) {
visit(tree, 'inlineMath', (node) => {
const at = node.position?.start.offset ?? 0
if (body.startsWith('$$', at)) {
node.data.hProperties = { className: ['language-math', 'math-display'] }
}
})
}
/** `body` as plain text: blocks on lines, math as TeX. */
export async function text(body) {
const page = unified().use(remarkRehype, { allowDangerousHtml: true })
const hast = await page.run(markdown().parse(body))
// Raw HTML alone goes through the DOM; blocks keep lines.
visit(hast, 'raw', (node, index, parent) => {
const parsed = new DOMParser().parseFromString(node.value, 'text/html').body
const shown = fromDom(parsed).children
parent.children.splice(index, 1, ...shown)
return index + shown.length
})
return toText(hast)
}
-109
View File
@@ -1,109 +0,0 @@
// CodeMirror 6: the Markdown source.
import { basicSetup, EditorView } from 'codemirror'
import { Prec } from '@codemirror/state'
import { indentWithTab, redo, redoDepth, undoDepth } from '@codemirror/commands'
import { keymap } from '@codemirror/view'
import { markdown as language } from '@codemirror/lang-markdown'
import { languages } from '@codemirror/language-data'
import { ensureSyntaxTree } from '@codemirror/language'
import { theme } from './theme.js'
import { search } from '@codemirror/search'
import { textFinder } from './find.js'
/** Alt text that cannot close its brackets. */
const escape = (alt) => alt.replace(/[\\[\]]/g, '\\$&')
export function source(root, markdown, changed, edge) {
const view = new EditorView({
doc: markdown,
extensions: [
basicSetup,
language({ codeLanguages: languages }),
EditorView.lineWrapping,
theme,
search(),
// Tab indents; it never leaves the editor.
keymap.of([indentWithTab]),
// Past their history, undo and redo reach the page.
Prec.low(
keymap.of([
{ key: 'Mod-z', run: (view) => undoDepth(view.state) === 0 && edge('undo') },
{ key: 'Mod-Shift-z', run: (view) => redoDepth(view.state) === 0 && edge('redo') },
{ key: 'Mod-y', run: (view) => redoDepth(view.state) === 0 && edge('redo') },
]),
),
EditorView.updateListener.of((update) => {
if (update.docChanged) changed(() => update.state.doc.toString())
}),
],
parent: root,
})
// ATX and setext headings from the parse, with their starts.
const headings = () => {
const found = []
const tree = ensureSyntaxTree(view.state, view.state.doc.length, 200)
tree?.iterate({
enter: ({ name, from, to }) => {
const level = /^(?:ATX|Setext)Heading(\d)$/.exec(name)?.[1]
if (!level) return
const head = view.state.doc.sliceString(from, to).split('\n')[0]
const title = head.replace(/^#{1,6}[ \t]*/, '').replace(/[ \t]+#+[ \t]*$/, '')
found.push({ level: Number(level), title: title.trim(), from })
return false
},
})
return found
}
const reveal = (from) => {
view.dispatch({ selection: { anchor: from }, scrollIntoView: true })
view.focus()
}
return {
// Images at the caret, as Markdown.
insert: (images) => {
const text = images.map(({ src, alt }) => `![${escape(alt)}](${src})`).join('\n')
view.dispatch(view.state.replaceSelection(text))
view.focus()
return true
},
find: textFinder(view),
outline: () => headings().map(({ level, title }) => ({ level, title })),
heading: (key) => {
const found = headings()[key]
if (found) reveal(found.from)
return Boolean(found)
},
line: (n, query) => {
if (n < 1 || n > view.state.doc.lines) return false
const line = view.state.doc.line(n)
const column = line.text.toLowerCase().indexOf(query.toLowerCase())
reveal(line.from + Math.max(0, column))
return true
},
close: () => view.destroy(),
center: () => {
const { head } = view.state.selection.main
view.dispatch({ effects: EditorView.scrollIntoView(head, { y: 'center' }) })
},
where: () => {
const { head } = view.state.selection.main
const line = view.state.doc.lineAt(head)
return { line: line.number, column: head - line.from }
},
at: (n, column) => {
if (n < 1 || n > view.state.doc.lines) return false
const line = view.state.doc.line(n)
reveal(line.from + Math.min(column, line.length))
return true
},
text: () => view.state.doc.toString(),
// The text once every undone step is redone.
redone: () => {
let state = view.state
if (!redoDepth(state)) return null
while (redoDepth(state)) redo({ state, dispatch: (tr) => (state = tr.state) })
return state.doc.toString()
},
focus: () => view.focus(),
}
}
-53
View File
@@ -1,53 +0,0 @@
// The document's own markers, so blocks write back unchanged.
import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkGfm from 'remark-gfm'
import { visit } from 'unist-util-visit'
/** Writing options most of `text` already uses; tables' for remark-gfm. */
export function style(text) {
const votes = {}
const vote = (key, value) => {
votes[key] ??= new Map()
votes[key].set(value, (votes[key].get(value) ?? 0) + 1)
}
visit(unified().use(remarkParse).use(remarkGfm).parse(text), (node) => {
const at = node.position?.start.offset
if (at === undefined) return
const head = text.slice(at, at + 12)
switch (node.type) {
case 'listItem': {
const ordered = /^\d+([.)])/.exec(head)
if (ordered) vote('bulletOrdered', ordered[1])
else if (/^[*+-]/.test(head)) vote('bullet', head[0])
break
}
case 'emphasis':
case 'strong':
vote(node.type, head[0])
break
case 'code':
if (/^[`~]/.test(head)) vote('fence', head[0])
break
case 'thematicBreak':
vote('rule', head[0])
break
case 'heading':
if (node.depth <= 2) vote('setext', head[0] !== '#')
break
case 'table': {
// Row nodes leave out quote and list prefixes.
const rows = node.children.map((row) => text.slice(row.position.start.offset, row.position.end.offset).trim())
const padded = /\| /.test(rows[0])
const cells = rows.flatMap((row) => row.replace(/^\||\|$/g, '').split('|'))
const inner = (cell) => (padded ? cell.replace(/^ | $/g, '') : cell)
vote('tableCellPadding', padded)
// Aligned cells carry spaces beyond the padding.
vote('tablePipeAlign', cells.some((cell) => /^\s|\s$/.test(inner(cell))))
break
}
}
})
const most = (key) => [...votes[key].entries()].sort((a, b) => b[1] - a[1])[0][0]
return Object.fromEntries(Object.keys(votes).map((key) => [key, most(key)]))
}
-35
View File
@@ -1,35 +0,0 @@
// Tables compare by alignment and cell source, not spacing.
import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkGfm from 'remark-gfm'
import { SKIP, visit } from 'unist-util-visit'
const reader = unified().use(remarkParse).use(remarkGfm)
/** A cell's own source, without its padding. */
function cell(text, node) {
const kids = node.children
return kids.length ? text.slice(kids[0].position.start.offset, kids.at(-1).position.end.offset) : ''
}
/** `text` with each table's spacing made canonical. */
export function canon(text) {
const tables = []
visit(reader.parse(text), 'table', (node) => {
tables.push(node)
return SKIP
})
let out = text
// Later tables first, so earlier offsets hold.
for (const table of tables.reverse()) {
const width = table.children[0].children.length
// GFM fills a short row with empty cells.
const rows = table.children.map((row) => {
const cells = row.children.map((node) => cell(text, node))
return [...cells, ...Array(Math.max(0, width - cells.length)).fill('')].join('|')
})
const shape = [table.align.join(','), ...rows].join('\n')
out = `${out.slice(0, table.position.start.offset)}\u0000${shape}\u0000${out.slice(table.position.end.offset)}`
}
return out
}
-42
View File
@@ -1,42 +0,0 @@
// CodeMirror colors from the page's Ely tokens.
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
import { EditorView } from '@codemirror/view'
import { tags } from '@lezer/highlight'
const token = (name) => `var(--ely-${name})`
const colors = HighlightStyle.define([
{ tag: [tags.keyword, tags.operatorKeyword, tags.modifier], color: token('keyword') },
{ tag: [tags.string, tags.regexp, tags.special(tags.string)], color: token('string') },
{ tag: [tags.number, tags.bool, tags.null, tags.atom], color: token('number') },
{ tag: [tags.comment, tags.meta], color: token('comment') },
{
tag: [tags.function(tags.variableName), tags.function(tags.propertyName)],
color: token('function'),
},
{ tag: [tags.typeName, tags.className, tags.namespace], color: token('type') },
{ tag: [tags.tagName, tags.heading], color: token('tag'), fontWeight: 'bold' },
{ tag: [tags.attributeName, tags.propertyName], color: token('attribute') },
{
tag: [tags.punctuation, tags.bracket, tags.processingInstruction],
color: token('punctuation'),
},
{ tag: [tags.link, tags.url], color: token('link') },
{ tag: tags.emphasis, fontStyle: 'italic' },
{ tag: tags.strong, fontWeight: 'bold' },
{ tag: tags.strikethrough, textDecoration: 'line-through' },
])
const frame = EditorView.theme({
'&': { color: token('fg'), backgroundColor: 'transparent' },
'.cm-content': { caretColor: token('fg'), fontFamily: 'var(--ely-mono), monospace' },
'.cm-cursor': { borderLeftColor: token('fg') },
'.cm-gutters': { backgroundColor: 'transparent', color: token('fg-subtle'), border: 'none' },
'.cm-activeLine, .cm-activeLineGutter': { backgroundColor: token('hover') },
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground': {
backgroundColor: token('selection'),
},
'&.cm-focused': { outline: 'none' },
})
export const theme = [frame, syntaxHighlighting(colors)]
-339
View File
@@ -1,339 +0,0 @@
// Milkdown: visual editing, or read-only preview.
import {
Editor,
defaultValueCtx,
remarkStringifyOptionsCtx,
editorViewCtx,
editorViewOptionsCtx,
parserCtx,
remarkCtx,
rootCtx,
} from '@milkdown/kit/core'
import { commonmark } from '@milkdown/kit/preset/commonmark'
import { gfm } from '@milkdown/kit/preset/gfm'
import { history } from '@milkdown/kit/plugin/history'
import { codeBlockComponent } from '@milkdown/kit/component/code-block'
import { Plugin, TextSelection } from '@milkdown/kit/prose/state'
import { Decoration, DecorationSet } from '@milkdown/kit/prose/view'
import { keymap } from '@milkdown/kit/prose/keymap'
import { liftListItem, sinkListItem } from '@milkdown/kit/prose/schema-list'
import { redo, redoDepth, undoDepth } from '@milkdown/kit/prose/history'
import { $prose, callCommand } from '@milkdown/kit/utils'
import { insertTableCommand, remarkGFMPlugin } from '@milkdown/kit/preset/gfm'
import {
addColumnAfter,
addColumnBefore,
addRowAfter,
addRowBefore,
deleteColumn,
deleteRow,
} from '@milkdown/kit/prose/tables'
import { visit } from 'unist-util-visit'
import { image } from './image.js'
import { math } from './math.js'
import { code } from './code.js'
import { diagrams } from './diagrams.js'
import { faithful, raw } from './preserve.js'
import { docFinder } from './find.js'
import { search } from 'prosemirror-search'
import { style } from './style.js'
/** An editor over `markdown`; null when blocks cannot map. */
export async function visual(root, markdown, editable, texts, changed, edge, linked) {
const owner = diagrams()
let write = () => ({ text: markdown, spans: [] })
// Setting up raw blocks is no edit.
let ready = false
const watch = $prose(
() =>
new Plugin({
view: () => ({
update: (view, before) => {
if (ready && !view.state.doc.eq(before.doc)) changed(() => write(view.state.doc).text)
},
}),
}),
)
// Focus mode dims all blocks but the caret's.
const current = $prose(
() =>
new Plugin({
props: {
decorations: (state) => {
const { $from } = state.selection
if ($from.depth < 1) return null
const start = $from.before(1)
const block = Decoration.node(start, start + $from.node(1).nodeSize, {
class: 'ely-current',
})
return DecorationSet.create(state.doc, [block])
},
},
}),
)
// Preview: a click opens. Editor: Cmd (Ctrl elsewhere).
const mac = navigator.platform.startsWith('Mac')
const links = $prose(
() =>
new Plugin({
props: {
handleDOMEvents: {
click: (view, event) => {
const anchor = event.target.closest?.('a')
if (!anchor || !view.dom.contains(anchor)) return false
event.preventDefault()
if (editable && !(mac ? event.metaKey : event.ctrlKey)) return true
// The DOM href is sanitized; the mark is not.
const node = view.state.doc.nodeAt(view.posAtDOM(anchor, 0))
const mark = node?.marks.find((mark) => mark.type.name === 'link')
if (!mark) throw new Error('ely: a link without its mark')
linked(mark.attrs.href)
return true
},
},
},
}),
)
// Past their history, undo and redo reach the page.
const ends = $prose(() =>
keymap({
'Mod-z': (state) => undoDepth(state) === 0 && edge('undo'),
'Mod-Shift-z': (state) => redoDepth(state) === 0 && edge('redo'),
'Mod-y': (state) => redoDepth(state) === 0 && edge('redo'),
// Tab moves list items; it never leaves the editor.
Tab: (state, dispatch) => sinkListItem(state.schema.nodes.list_item)(state, dispatch) || true,
'Shift-Tab': (state, dispatch) =>
liftListItem(state.schema.nodes.list_item)(state, dispatch) || true,
}),
)
const made = Editor.make()
.config((ctx) => {
ctx.set(rootCtx, root)
ctx.set(defaultValueCtx, markdown)
const { tableCellPadding, tablePipeAlign, ...marks } = style(markdown)
ctx.update(remarkStringifyOptionsCtx, (prev) => ({ ...prev, ...marks }))
ctx.set(remarkGFMPlugin.options.key, { tableCellPadding, tablePipeAlign })
ctx.update(editorViewOptionsCtx, (prev) => ({ ...prev, editable: () => editable }))
})
.use(commonmark)
.use(gfm)
.use(history)
.use(image)
.use(math)
.use(raw)
.config(code(texts, editable, owner))
.use(codeBlockComponent)
.use(watch)
.use(current)
.use(ends)
.use(links)
.use($prose(() => search()))
.create()
const editor = await made.catch((error) => {
owner.release()
throw error
})
const view = editor.action((ctx) => ctx.get(editorViewCtx))
const close = () => {
editor.destroy()
owner.release()
}
const keep = editor.action((ctx) => faithful(ctx, markdown))
if (!keep) {
close()
return null
}
write = keep
ready = true
// The text once every undone step is redone.
const redone = () => {
let state = view.state
if (!redoDepth(state)) return null
while (redoDepth(state)) redo(state, (tr) => (state = state.apply(tr)))
return write(state.doc).text
}
// Headings, raw ones too, by the title their source gives.
const [remark, parse] = editor.action((ctx) => [ctx.get(remarkCtx), ctx.get(parserCtx)])
/** An mdast node's words. */
const words = (node) => node.value ?? (node.children ?? []).map(words).join('')
// Headings; inside raw blocks, as remark reads their source.
const headings = () => {
const found = []
view.state.doc.descendants((node, pos) => {
if (node.type.name === 'heading')
found.push({ level: node.attrs.level, title: node.textContent, pos })
if (node.type.name !== 'ely_raw') return true
visit(remark.parse(node.textContent), 'heading', (heading) => {
const start = (heading.children[0] ?? heading).position.start.offset
found.push({ level: heading.depth, title: words(heading), pos, at: pos + 1 + start })
})
return false
})
return found
}
// `exact`: the caret at `pos`; else in that block.
const reveal = (pos, exact) => {
if (editable) {
const doc = view.state.doc
const place = exact
? TextSelection.create(doc, pos)
: TextSelection.near(doc.resolve(pos + 1))
view.dispatch(view.state.tr.setSelection(place).scrollIntoView())
return view.focus()
}
const { node } = view.domAtPos(pos + 1)
;(node.nodeType === 1 ? node : node.parentElement).scrollIntoView({ block: 'center' })
}
/** `node`'s text, and where a character of it sits. */
const reading = (node, start) => {
const chunks = []
// Text and line breaks, as `textContent` reads them.
node.descendants((child, at) => {
const words = child.isText
? child.text
: child.isLeaf
? child.type.spec.leafText?.(child)
: null
if (words) chunks.push({ text: words.toLowerCase(), at: start + 1 + at })
})
const at = (offset) => {
for (const [index, chunk] of chunks.entries()) {
const last = index === chunks.length - 1
if (offset < chunk.text.length || (last && offset === chunk.text.length))
return chunk.at + offset
offset -= chunk.text.length
}
return null
}
return { joined: chunks.map((chunk) => chunk.text).join(''), at }
}
// Non-overlapping hits, the same rule as `split`.
const hits = (text, needle) => text.split(needle).length - 1
/** The block holding file line `n`: its index and start. */
const blockOf = (n) => {
const { text, spans } = write(view.state.doc)
let offset = 0
for (let k = 1; k < n && offset >= 0; k++) offset = text.indexOf('\n', offset) + 1
if (n < 1 || offset < 0) return null
const index = spans.findIndex((span) => span && span[1] >= offset)
if (index < 0) return null
let pos = 0
for (let k = 0; k < index; k++) pos += view.state.doc.child(k).nodeSize
return { text, spans, offset, index, pos }
}
/** Line `n`: the query's place in its block. */
const line = (n, query) => {
const block = blockOf(n)
if (!block) return false
const { text, spans, offset, index, pos } = block
const needle = query.toLowerCase()
const node = view.state.doc.child(index)
// Text before and through the line, as shown.
const shown = (end) => {
const source = text.slice(spans[index][0], Math.max(spans[index][0], end))
return (node.type.name === 'ely_raw' ? source : parse(source).textContent).toLowerCase()
}
const ends = text.indexOf('\n', offset)
const before = shown(offset)
const through = shown(ends < 0 ? text.length : ends)
const { joined, at } = reading(node, pos)
const k = needle ? hits(before, needle) : 0
let found = -1
if (needle && hits(through, needle) > k) {
let from = 0
for (let seen = 0; seen <= k; seen++) {
found = joined.indexOf(needle, from)
if (found < 0) break
from = found + needle.length
}
}
// A hit hidden, as in a link: the line's start.
const place = found >= 0 ? at(found) : at(before.length)
reveal(place ?? pos, place !== null)
return true
}
/** The caret: its block's file line, and how far in. */
const where = () => {
const { text, spans } = write(view.state.doc)
const { from } = view.state.selection
let [start, pos] = [0, 0]
view.state.doc.forEach((node, offset, index) => {
if (offset <= from && spans[index]) [start, pos] = [spans[index][0], offset]
})
return { line: text.slice(0, start).split('\n').length, column: from - pos }
}
/** The caret back where `where` read it. */
const at = (n, column) => {
const block = blockOf(n)
if (!block) return false
const size = view.state.doc.child(block.index).nodeSize
reveal(block.pos + Math.min(Math.max(column, 1), size - 1) - 1, false)
return true
}
// Rows and columns; false when the caret is outside tables.
const tables = {
'row-above': addRowBefore,
'row-below': addRowAfter,
'column-left': addColumnBefore,
'column-right': addColumnAfter,
'delete-row': deleteRow,
'delete-column': deleteColumn,
}
const tablesIn = (doc) => {
const found = []
doc.descendants((node, pos) => {
if (node.type.name === 'table') found.push({ node, pos })
})
return found
}
// Milkdown may leave the caret before the new table.
const newTable = () => {
const old = new Set(tablesIn(view.state.doc).map(({ node }) => node))
editor.action(callCommand(insertTableCommand.key, { row: 3, col: 3 }))
const made = tablesIn(view.state.doc).find(({ node }) => !old.has(node))
if (!made) throw new Error('the inserted table is missing')
const cell = TextSelection.near(view.state.doc.resolve(made.pos + 1))
view.dispatch(view.state.tr.setSelection(cell).scrollIntoView())
return true
}
const table = (op) => {
if (!editable) return false
const done = op === 'insert' ? newTable() : tables[op](view.state, view.dispatch)
view.focus()
return done
}
/** Images at the caret: `{ src, alt }` each. */
const insert = (images) => {
if (!editable) return false
const image = view.state.schema.nodes.image
let tr = view.state.tr
for (const { src, alt } of images)
tr = tr.replaceSelectionWith(image.create({ src, alt, title: '' }), false)
view.dispatch(tr.scrollIntoView())
view.focus()
return true
}
return {
insert,
table,
find: docFinder(view),
close,
text: () => write(view.state.doc).text,
redone,
focus: () => editable && view.focus(),
outline: () => headings().map(({ level, title }) => ({ level, title })),
heading: (key) => {
const found = headings()[key]
if (found) reveal(found.at ?? found.pos, found.at !== undefined)
return Boolean(found)
},
line,
where,
at,
// The caret's line to the middle of the view.
center: () => {
const { top } = view.coordsAtPos(view.state.selection.head)
window.scrollBy(0, top - window.innerHeight / 2)
},
}
}
+1 -49
View File
@@ -83,34 +83,11 @@ pub fn open(app: AppId, files: &[PathBuf]) -> io::Result<()> {
}
let mut command = Command::new(&found);
command.args(files);
let child = fresh_signals(&mut command).spawn()?;
let child = 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") {
@@ -170,28 +147,3 @@ mod tests {
))));
}
}
#[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:?}");
}
}
+8 -12
View File
@@ -11,18 +11,15 @@ use crate::AppId;
#[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];
pub const ALL: [Kind; 2] = [Kind::Log, Kind::Recovery];
fn place(self) -> &'static str {
match self {
Kind::Log => "ely.log",
Kind::Cache => "web",
Kind::Recovery => "recovery",
}
}
@@ -152,15 +149,14 @@ mod tests {
#[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();
std::fs::write(dir.path().join("recovery/a/point"), vec![0u8; 4096]).unwrap();
std::fs::write(dir.path().join("ely.log"), b"line").unwrap();
assert_eq!(size(&dir.path().join("recovery")).unwrap(), 4096);
std::fs::write(dir.path().join(Kind::Recovery.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");
assert!(!dir.path().join("recovery").exists(), "the copies went");
assert!(dir.path().join("ely.log").exists(), "the log stays");
assert!(!dir.path().join(Kind::Recovery.mark()).exists(), "the mark is spent");
}
}
-2
View File
@@ -32,7 +32,6 @@ pub(super) const EN: &[(&str, &str)] = &[
("settings.git.bad", "{path} is not Git 2.40 or later: {error}"),
("settings.storage", "Storage"),
("settings.storage.log", "This app's log"),
("settings.storage.cache", "Web content cache"),
("settings.storage.recovery", "Recovery copies"),
("settings.storage.clear", "Clear"),
("settings.storage.clear_later", "Clear at next start"),
@@ -92,7 +91,6 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
("settings.git.bad", "{path} 不是 2.40 或更新的 Git:{error}"),
("settings.storage", "存储"),
("settings.storage.log", "本应用的日志"),
("settings.storage.cache", "网页内容缓存"),
("settings.storage.recovery", "恢复副本"),
("settings.storage.clear", "清除"),
("settings.storage.clear_later", "下次启动时清除"),
+1 -4
View File
@@ -74,9 +74,7 @@ impl SettingsView {
None => {}
Some(Err(error)) => section = section.row(Banner::new("storage-unread", Severity::Danger, error.clone())),
Some(Ok(usage)) => {
// Only ElyMark keeps web content.
let shown = usage.iter().filter(|(kind, ..)| *kind != Kind::Cache || self.app == ely_platform::AppId::Mark);
for (kind, bytes, clearing) in shown {
for (kind, bytes, clearing) in usage {
section = section.row(row(*kind, *bytes, *clearing, this.clone(), cx));
}
}
@@ -188,7 +186,6 @@ impl SettingsView {
fn name(kind: Kind, cx: &App) -> SharedString {
match kind {
Kind::Log => text(cx, "settings.storage.log", &[]),
Kind::Cache => text(cx, "settings.storage.cache", &[]),
Kind::Recovery => text(cx, "settings.storage.recovery", &[]),
}
}
-32
View File
@@ -1,32 +0,0 @@
[package]
name = "ely-web-content"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
anyhow.workspace = true
cef.workspace = true
ely-gpui-component.workspace = true
ely-ui.workspace = true
futures.workspace = true
gpui.workspace = true
image.workspace = true
libc.workspace = true
log.workspace = true
serde_json.workspace = true
[target.'cfg(target_os = "macos")'.dependencies]
objc2.workspace = true
[target.'cfg(windows)'.dependencies]
windows-sys.workspace = true
[lints]
workspace = true
[dev-dependencies]
ely-platform.workspace = true
tempfile.workspace = true
-317
View File
@@ -1,317 +0,0 @@
//! A live WebView: input, fence, failures, release, paint.
use std::{
net::{TcpListener, UdpSocket},
path::{Path, PathBuf},
time::Duration,
};
use ely_platform::AppId;
use ely_web_content::{Event, WebView};
use gpui::{
App, AppContext as _, AsyncWindowContext, Bounds, Context, ElementInputHandler, Entity,
Focusable as _, InputHandler as _, IntoElement, KeyUpEvent, Keystroke, ParentElement as _,
PlatformInput, Render, Styled as _, WeakEntity, Window, div,
};
/// How many views open and close to show their release.
const CHURN: usize = 20;
/// Views dropped before the pump makes their browsers.
const UNMADE: usize = 5;
/// Views dropped within 3 ms steps of opening.
const EARLY: u64 = 10;
struct Probe {
view: Entity<WebView>,
/// Pages beside the main one, each with its own test.
others: Vec<Entity<WebView>>,
/// The ratio before zoom, to compare after.
before: Option<f64>,
/// Fenced off: nothing may reach it.
server: TcpListener,
/// WebRTC's way out, by UDP; nothing may reach it either.
udp: UdpSocket,
churned: usize,
crashed: bool,
fixtures: PathBuf,
work: PathBuf,
}
fn typed(text: &str) -> Keystroke {
Keystroke {
key_char: Some(text.into()),
..Keystroke::parse(text).expect("a keystroke")
}
}
/// Waits, then runs `then` with the probe and its window.
fn later(
this: WeakEntity<Probe>,
ms: u64,
window: &mut Window,
cx: &mut Context<Probe>,
then: impl FnOnce(&mut Probe, &mut Window, &mut Context<Probe>) + 'static,
) {
cx.spawn_in(window, async move |_, cx: &mut AsyncWindowContext| {
cx.background_executor()
.timer(Duration::from_millis(ms))
.await;
let _ = this.update_in(cx, then);
})
.detach();
}
impl Probe {
fn main_heard(
&mut self,
_: &Entity<WebView>,
event: &Event,
window: &mut Window,
cx: &mut Context<Self>,
) {
match event {
// Held so the gate sees the page drawn again.
Event::Loaded if self.crashed => {
println!("probe: recovered");
let this = cx.weak_entity();
later(this, 4000, window, cx, |_, _, cx| cx.quit());
}
Event::Loaded => {
let port = self.server.local_addr().expect("an address").port();
let image = format!(
"new Image().src = 'http://127.0.0.1:{port}/pixel?x=1'; \
try {{ new WebSocket('ws://127.0.0.1:{port}/socket'); }} catch (e) {{}} \
try {{ const peer = new RTCPeerConnection({{ iceServers: \
[{{ urls: 'stun:127.0.0.1:{udp}' }}] }}); peer.createDataChannel('x'); \
peer.createOffer().then((offer) => peer.setLocalDescription(offer)); \
}} catch (e) {{}}",
udp = self.udp.local_addr().expect("an address").port()
);
self.view.read(cx).run_js(&image);
self.view.read(cx).run_js("report('loaded')");
}
Event::Query(said, reply) => {
reply.ok("ok");
let read: serde_json::Value = serde_json::from_str(said).expect("json");
self.step(&read, window, cx);
}
Event::Leave(url) => println!("probe: leave {url}"),
Event::Printed(path, ok) => println!("probe: printed {} {ok}", path.display()),
// Recovery comes from the shown Reload button.
Event::Failed(why) => {
println!("probe: main failed");
eprintln!("probe: because {why}");
self.crashed = true;
}
}
}
fn step(&mut self, read: &serde_json::Value, window: &mut Window, cx: &mut Context<Self>) {
let this = cx.weak_entity();
match read["step"].as_str() {
Some("loaded") => {
self.before = read["ratio"].as_f64();
println!("probe: outside={}", read["outside"]);
self.edit(window, cx);
}
Some("edited") => {
println!("probe: value={}", read["value"].as_str().unwrap_or("?"));
println!(
"probe: focused={}",
read["focused"].as_str().unwrap_or("none")
);
println!("probe: fg={}", read["fg"].as_str().unwrap_or(""));
let reached = self.server.accept().is_ok();
println!(
"probe: network={}",
if reached { "reached" } else { "refused" }
);
let heard = self.udp.recv(&mut [0u8; 2048]).is_ok();
println!("probe: udp={}", if heard { "reached" } else { "silent" });
println!("probe: rtc={}", read["rtc"].as_str().unwrap_or("?"));
println!(
"probe: network={}",
if reached { "reached" } else { "refused" }
);
self.view.read(cx).set_zoom(1.25);
later(this, 800, window, cx, |probe, _, cx| {
probe.view.read(cx).run_js("report('zoomed')");
});
}
Some("zoomed") => {
let zoom = read["ratio"].as_f64().unwrap_or(0.0) / self.before.unwrap_or(1.0);
println!("probe: zoom={zoom:.2}");
// A page cannot leave its bundled folder.
self.view
.read(cx)
.run_js("location.href = 'https://example.com/'");
later(this, 800, window, cx, |probe, _, cx| {
probe.view.read(cx).run_js("report('stayed')");
});
}
Some("stayed") => {
println!("probe: stayed");
self.churn(window, cx);
}
other => println!("probe: unknown step {other:?}"),
}
}
/// Compose 你好, commit it, then type A.
fn edit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let view = self.view.clone();
// Input runs outside this update; GPUI redraws during it.
cx.spawn_in(window, async move |_, cx| {
let typed_in = cx.update(|window, cx| {
let focus = view.read(cx).focus_handle(cx);
window.focus(&focus, cx);
let mut ime = ElementInputHandler::new(Bounds::default(), view.clone());
ime.replace_and_mark_text_in_range(None, "ni", Some(2..2), window, cx);
ime.replace_and_mark_text_in_range(Some(0..2), "你好", None, window, cx);
ime.replace_text_in_range(None, "你好", window, cx);
let stroke = typed("a");
window.dispatch_keystroke(stroke.clone(), cx);
window.dispatch_event(PlatformInput::KeyUp(KeyUpEvent { keystroke: stroke }), cx);
});
if typed_in.is_err() {
return;
}
cx.background_executor()
.timer(Duration::from_millis(500))
.await;
view.update(cx, |view, _| view.run_js("report('edited')"));
})
.detach();
}
/// Opens and drops pages; each must free its memory.
fn churn(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let page = self.fixtures.join("probe.html");
if self.churned == CHURN {
println!("probe: closed {CHURN}");
for _ in 0..UNMADE {
drop(WebView::open(&page, &self.fixtures, window, cx).expect("a view"));
}
for step in 0..EARLY {
let view = WebView::open(&page, &self.fixtures, window, cx).expect("a view");
self.others.push(view.clone());
later(cx.weak_entity(), step * 3, window, cx, move |probe, _, _| {
probe.others.retain(|other| *other != view);
});
}
println!("probe: dropped {UNMADE} unmade, {EARLY} early");
let this = cx.weak_entity();
later(this, 2000, window, cx, |_, _, _| {
// The log marks what freed before the hold.
log::info!("probe: hold");
println!("probe: hold");
});
return;
}
self.churned += 1;
let view = WebView::open(&page, &self.fixtures, window, cx).expect("a view");
cx.subscribe_in(&view, window, |probe, view, event, window, cx| {
if matches!(event, Event::Loaded) {
probe.others.retain(|other| other != view);
probe.churn(window, cx);
}
})
.detach();
self.others.push(view);
}
/// A page named like a URL, one gone, one inert.
fn edges(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let page = self.fixtures.join("inert.html");
let inert = WebView::open_inert(&page, &self.fixtures, window, cx).expect("a view");
cx.subscribe_in(&inert, window, |_, _, event, _, _| match event {
Event::Loaded => println!("probe: inert loaded"),
Event::Query(_, reply) => {
println!("probe: inert ran");
reply.ok("");
}
_ => {}
})
.detach();
// The same page with scripts: it must run.
let scripted = WebView::open(&page, &self.fixtures, window, cx).expect("a view");
cx.subscribe_in(&scripted, window, |_, _, event, window, cx| {
if let Event::Query(_, reply) = event {
println!("probe: scripted ran");
reply.ok("");
later(cx.weak_entity(), 2000, window, cx, |_, _, _| println!("probe: inert checked"));
}
})
.detach();
self.others.extend([inert, scripted]);
let hash = self.work.join("hash");
std::fs::create_dir_all(&hash).expect("makes");
std::fs::copy(
self.fixtures.join("probe.html"),
hash.join("name#part.html"),
)
.expect("copies");
let named = WebView::open(&hash.join("name#part.html"), &hash, window, cx).expect("a view");
cx.subscribe_in(&named, window, |_, _, event, _, _| match event {
Event::Loaded => println!("probe: hash loaded"),
Event::Failed(_) => println!("probe: hash failed"),
_ => {}
})
.detach();
let gone = self.work.join("gone");
std::fs::create_dir_all(&gone).expect("makes");
let page = gone.join("page.html");
std::fs::copy(self.fixtures.join("probe.html"), &page).expect("copies");
let missing = WebView::open(&page, &gone, window, cx).expect("a view");
std::fs::remove_file(&page).expect("removes");
cx.subscribe_in(&missing, window, |_, _, event, _, _| match event {
Event::Loaded => println!("probe: missing loaded"),
Event::Failed(_) => println!("probe: missing failed"),
_ => {}
})
.detach();
self.others.extend([named, missing]);
}
}
impl Render for Probe {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
div().size_full().child(self.view.clone())
}
}
fn open(app: AppId, window: &mut Window, cx: &mut App) -> Entity<ely_ui::Root> {
let work = tempfile::tempdir().expect("a temp dir").keep();
ely_web_content::start(&work, cx).expect("CEF starts");
let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/web");
let fixtures = std::fs::canonicalize(fixtures).expect("fixtures");
let view = WebView::open(&fixtures.join("probe.html"), &fixtures, window, cx).expect("a view");
let server = TcpListener::bind("127.0.0.1:0").expect("a port");
server.set_nonblocking(true).expect("nonblocking");
let udp = UdpSocket::bind("127.0.0.1:0").expect("a port");
udp.set_nonblocking(true).expect("nonblocking");
let probe = cx.new(|cx| {
cx.subscribe_in(&view, window, Probe::main_heard).detach();
let mut probe = Probe {
view,
others: Vec::new(),
before: None,
server,
udp,
churned: 0,
crashed: false,
fixtures,
work,
};
probe.edges(window, cx);
probe
});
ely_ui::root(app, Some(probe.into()), window, cx)
}
fn main() {
if let Some(code) = ely_web_content::subprocess() {
std::process::exit(code);
}
ely_ui::run(AppId::Mark, open);
}
@@ -1,9 +0,0 @@
//! CEF's helper processes inside an app bundle.
fn main() {
let Some(code) = ely_web_content::subprocess() else {
eprintln!("ely-web-helper: started without --type; CEF starts it");
std::process::exit(2);
};
std::process::exit(code);
}
-92
View File
@@ -1,92 +0,0 @@
//! AppKit's Command edit keys; off-screen Blink never gets them.
use cef::*;
const SHIFT: u32 = 1 << 1;
const CONTROL: u32 = 1 << 2;
const ALT: u32 = 1 << 3;
const COMMAND: u32 = 1 << 7;
/// What AppKit's standard bindings do, as page script.
pub(crate) fn script(windows_key: i32, modifiers: u32) -> Option<String> {
if modifiers & COMMAND == 0 || modifiers & (CONTROL | ALT) != 0 {
return None;
}
let alter = if modifiers & SHIFT != 0 {
"extend"
} else {
"move"
};
let (direction, unit) = match windows_key {
0x25 => ("backward", "lineboundary"),
0x27 => ("forward", "lineboundary"),
0x26 => ("backward", "documentboundary"),
0x28 => ("forward", "documentboundary"),
// Delete to the line's start or end.
0x08 | 0x2E if modifiers & SHIFT == 0 => {
let (direction, command) = match windows_key {
0x08 => ("backward", "delete"),
_ => ("forward", "forwardDelete"),
};
return Some(format!(
"{{const s=getSelection();if(s.isCollapsed)s.modify('extend','{direction}','lineboundary');document.execCommand('{command}')}}"
));
}
_ => return None,
};
Some(format!(
"{{const s=getSelection();s.modify('{alter}','{direction}','{unit}');s.focusNode?.parentElement?.scrollIntoView({{block:'nearest'}})}}"
))
}
wrap_keyboard_handler! {
pub(crate) struct Keys;
impl KeyboardHandler {
fn on_key_event(
&self,
browser: Option<&mut Browser>,
event: Option<&KeyEvent>,
_os_event: *mut u8,
) -> ::std::os::raw::c_int {
let (Some(browser), Some(event)) = (browser, event) else { return 0 };
let down = matches!(event.type_, KeyEventType::RAWKEYDOWN | KeyEventType::KEYDOWN);
if !down || event.focus_on_editable_field == 0 {
return 0;
}
let Some(script) = script(event.windows_key_code, event.modifiers) else { return 0 };
let Some(frame) = browser.focused_frame() else { return 0 };
log::debug!("web: AppKit edit key {}", event.windows_key_code);
frame.execute_java_script(Some(&CefString::from(script.as_str())), None, 0);
1
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn command_keys_only() {
assert!(
script(0x28, COMMAND)
.unwrap()
.contains("'move','forward','documentboundary'")
);
assert!(
script(0x25, COMMAND | SHIFT)
.unwrap()
.contains("'extend','backward','lineboundary'")
);
assert!(
script(0x08, COMMAND)
.unwrap()
.contains("execCommand('delete')")
);
assert!(script(0x2E, COMMAND).unwrap().contains("forwardDelete"));
assert_eq!(script(0x28, 0), None);
assert_eq!(script(0x28, COMMAND | ALT), None);
assert_eq!(script(0x41, COMMAND), None);
}
}
-356
View File
@@ -1,356 +0,0 @@
//! CEF itself: the framework, its processes, its message pump.
use std::{
path::{Path, PathBuf},
sync::{Mutex, OnceLock},
time::Duration,
};
use anyhow::Result;
use cef::{args::Args, *};
use futures::{FutureExt as _, StreamExt as _, channel::mpsc};
use gpui::{App, AsyncApp, Global, WeakEntity};
use crate::{handlers, surface::Shared, view::WebView};
/// The framework beside the app, else the fetched one.
#[cfg(target_os = "macos")]
fn framework_dir() -> Result<PathBuf> {
use anyhow::Context as _;
const NAME: &str = "Chromium Embedded Framework.framework";
let exe = std::env::current_exe()?;
let mut places: Vec<PathBuf> = Vec::new();
if let Some(dir) = std::env::var_os("ELY_CEF") {
places.push(PathBuf::from(dir).join(NAME));
}
if let Some(contents) = exe.parent().and_then(Path::parent) {
places.push(contents.join("Frameworks").join(NAME));
// A helper sits in Frameworks/<App> Helper.app/Contents/MacOS.
if let Some(frameworks) = contents.parent().and_then(Path::parent) {
places.push(frameworks.join(NAME));
}
}
if let Some(built) = option_env!("CEF_PATH") {
places.push(Path::new(built).join(NAME));
}
places
.into_iter()
.find(|place| place.is_dir())
.with_context(|| format!("no {NAME} beside {} or in engines/cef", exe.display()))
}
static LOADED: OnceLock<std::result::Result<PathBuf, String>> = OnceLock::new();
/// Loads CEF once and sets its API version.
fn load() -> Result<PathBuf> {
LOADED
.get_or_init(|| {
let dir = open()?;
// Versioned calls exit the process until this runs.
let _ = api_hash(sys::CEF_API_VERSION_LAST, 0);
log::info!("web: CEF loaded from {}", dir.display());
Ok(dir)
})
.clone()
.map_err(anyhow::Error::msg)
}
/// Loads the framework; its folder.
#[cfg(target_os = "macos")]
fn open() -> std::result::Result<PathBuf, String> {
let dir = framework_dir().map_err(|error| format!("{error:#}"))?;
let library = dir.join("Chromium Embedded Framework");
let name = std::ffi::CString::new(library.as_os_str().as_encoded_bytes()).map_err(|error| error.to_string())?;
if unsafe { load_library(Some(&*name.as_ptr().cast())) } != 1 {
return Err(format!("CEF did not load from {}", library.display()));
}
Ok(dir)
}
/// The folder of the libcef.dll this process linked.
#[cfg(windows)]
fn open() -> std::result::Result<PathBuf, String> {
use std::os::windows::ffi::OsStringExt as _;
use windows_sys::Win32::System::LibraryLoader::{GetModuleFileNameW, GetModuleHandleW};
let name: Vec<u16> = "libcef.dll".encode_utf16().chain([0]).collect();
let module = unsafe { GetModuleHandleW(name.as_ptr()) };
if module.is_null() {
return Err(format!("libcef.dll is not loaded: {}", std::io::Error::last_os_error()));
}
let mut path = vec![0u16; 32_768];
let len = unsafe { GetModuleFileNameW(module, path.as_mut_ptr(), path.len() as u32) } as usize;
if len == 0 || len == path.len() {
return Err(format!("libcef.dll has no path: {}", std::io::Error::last_os_error()));
}
let file = PathBuf::from(std::ffi::OsString::from_wide(&path[..len]));
file.parent().map(Path::to_path_buf).ok_or_else(|| format!("{} has no folder", file.display()))
}
/// The folder of the libcef.so this process linked.
#[cfg(target_os = "linux")]
fn open() -> std::result::Result<PathBuf, String> {
use std::os::unix::ffi::OsStrExt as _;
let mut info: libc::Dl_info = unsafe { std::mem::zeroed() };
let entry = sys::cef_api_hash as *const libc::c_void;
if unsafe { libc::dladdr(entry, &mut info) } == 0 || info.dli_fname.is_null() {
return Err("libcef.so is not loaded".into());
}
let name = unsafe { std::ffi::CStr::from_ptr(info.dli_fname) };
let file = PathBuf::from(std::ffi::OsStr::from_bytes(name.to_bytes()));
file.parent().map(Path::to_path_buf).ok_or_else(|| format!("{} has no folder", file.display()))
}
/// In a CEF helper process, runs it to its end.
pub fn subprocess() -> Option<i32> {
// CEF's own parser needs the framework; plain args do not.
if !std::env::args().any(|arg| arg.starts_with("--type=")) {
return None;
}
if let Err(error) = load() {
eprintln!("web: {error:#}");
return Some(1);
}
let args = Args::new();
let mut app = handlers::app();
let code = execute_process(
Some(args.as_main_args()),
Some(&mut app),
std::ptr::null_mut(),
);
Some(code)
}
/// The longest the pump waits between turns, as in cefclient.
const FALLBACK: Duration = Duration::from_millis(1000 / 30);
/// Pump work CEF asked for, from any thread.
static WAKE: Mutex<Option<mpsc::UnboundedSender<i64>>> = Mutex::new(None);
/// CEF wants its loop run within `delay_ms`.
pub(crate) fn schedule(delay_ms: i64) {
if let Some(wake) = WAKE.lock().expect("the pump lock").as_ref() {
let _ = wake.unbounded_send(delay_ms);
}
}
/// The live views the pump hands frames to.
#[derive(Default)]
pub(crate) struct Engine {
pub views: Vec<WeakEntity<WebView>>,
}
impl Global for Engine {}
thread_local! {
/// CEF calls asked inside GPUI's updates, run after them.
static LATER: std::cell::RefCell<Vec<Box<dyn FnOnce()>>> = Default::default();
}
/// Runs `work` on the pump's next turn.
pub(crate) fn later(work: impl FnOnce() + 'static) {
LATER.with_borrow_mut(|queue| queue.push(Box::new(work)));
schedule(0);
}
/// In asked order; work may ask for more.
fn run_later() {
loop {
let batch = LATER.with_borrow_mut(std::mem::take);
if batch.is_empty() {
return;
}
for work in batch {
work();
}
}
}
/// Browsers asked for, made on the pump's next turn.
static PENDING: Mutex<Vec<(Shared, String)>> = Mutex::new(Vec::new());
/// Why CEF could not start; every view is told.
static FAILED: Mutex<Option<String>> = Mutex::new(None);
pub(crate) fn failed() -> Option<String> {
FAILED.lock().expect("the start lock").clone()
}
/// Queues a browser for `surface` at `url`.
pub(crate) fn ask_browser(surface: Shared, url: String) {
PENDING
.lock()
.expect("the pending lock")
.push((surface, url));
schedule(0);
}
/// Outside GPUI's updates: CEF may spin the run loop.
fn make_browsers() {
let pending = std::mem::take(&mut *PENDING.lock().expect("the pending lock"));
for (surface, url) in pending {
if surface.lock().expect("the surface lock").closed {
continue;
}
// Off-screen pages work only in CEF's Alloy style.
let info = WindowInfo {
windowless_rendering_enabled: true as _,
runtime_style: RuntimeStyle::ALLOY,
..Default::default()
};
let scripts = match surface.lock().expect("the surface lock").scripts {
true => State::ENABLED,
false => State::DISABLED,
};
let settings = BrowserSettings {
windowless_frame_rate: 60,
javascript: scripts,
..Default::default()
};
// Made at once: no view ever waits on a browser.
let made = browser_host_create_browser_sync(
Some(&info),
Some(&mut handlers::client(&surface)),
Some(&url.as_str().into()),
Some(&settings),
None,
None,
);
match made {
Some(browser) => {
handlers::adopt(&browser, surface.clone());
surface.lock().expect("the surface lock").browser = Some(browser);
}
None => {
surface.lock().expect("the surface lock").failed =
Some(format!("CEF made no browser for {url}"))
}
}
}
}
/// Starts CEF in this process; once per app.
pub fn start(data: &Path, cx: &mut App) -> Result<()> {
if cx.has_global::<Engine>() {
return Ok(());
}
crate::input::bind(cx);
let framework = load()?;
#[cfg(target_os = "macos")]
crate::mac::adopt_app_protocol()?;
let exe = std::env::current_exe()?;
// Bundled, CEF finds its helper apps; else this binary helps.
let bundled = cfg!(target_os = "macos")
&& framework.parent().is_some_and(|dir| {
let name = exe.file_name().unwrap_or_default().to_string_lossy();
dir.join(format!("{name} Helper.app")).is_dir()
});
let helper = if bundled {
String::new()
} else {
exe.to_string_lossy().into_owned()
};
let cache = data.join("web");
std::fs::create_dir_all(&cache)?;
let settings = Settings {
windowless_rendering_enabled: true as _,
external_message_pump: true as _,
no_sandbox: true as _,
browser_subprocess_path: helper.as_str().into(),
#[cfg(target_os = "macos")]
framework_dir_path: framework.to_string_lossy().as_ref().into(),
#[cfg(not(target_os = "macos"))]
resources_dir_path: framework.to_string_lossy().as_ref().into(),
#[cfg(not(target_os = "macos"))]
locales_dir_path: framework.join("locales").to_string_lossy().as_ref().into(),
root_cache_path: cache.to_string_lossy().as_ref().into(),
persist_session_cookies: false as _,
log_severity: LogSeverity::WARNING,
..Default::default()
};
let (wake, mut asked) = mpsc::unbounded();
*WAKE.lock().expect("the pump lock") = Some(wake);
cx.set_global(Engine::default());
cx.spawn(async move |cx: &mut AsyncApp| {
// Not inside an update: CEF may run AppKit's loop here.
let args = Args::new();
let mut app = handlers::app();
let started = initialize(
Some(args.as_main_args()),
Some(&settings),
Some(&mut app),
std::ptr::null_mut(),
);
if started != 1 {
let why = held(&cache).unwrap_or_else(|| "CEF did not initialize".into());
log::error!("web: {why}");
*FAILED.lock().expect("the start lock") = Some(why);
cx.update(crate::view::deliver);
return;
}
log::info!("web: CEF started, cache {}", cache.display());
let quitting = cx.update(|cx| cx.on_app_quit(|_| async { stop() }));
quitting.detach();
let mut due: Option<Duration> = Some(Duration::ZERO);
loop {
let timer = match due {
Some(delay) => cx.background_executor().timer(delay).boxed_local(),
None => futures::future::pending().boxed_local(),
};
futures::select_biased! {
delay = asked.next() => match delay {
Some(delay) => {
let delay = Duration::from_millis(delay.max(0) as u64);
due = Some(due.map_or(delay, |now| now.min(delay)));
continue;
}
None => return,
},
_ = timer.fuse() => {}
}
run_later();
make_browsers();
do_message_loop_work();
cx.update(crate::view::deliver);
// As cefclient: CEF does not ask for all work.
due = Some(FALLBACK);
}
})
.detach();
Ok(())
}
/// Stops CEF as the app quits.
fn stop() {
WAKE.lock().expect("the pump lock").take();
shutdown();
log::info!("web: CEF stopped");
}
/// Why CEF would refuse this cache: another app holds it.
#[cfg(unix)]
fn held(cache: &Path) -> Option<String> {
let lock = std::fs::read_link(cache.join("SingletonLock")).ok()?;
let lock = lock.to_string_lossy();
let pid: i32 = lock.rsplit('-').next()?.parse().ok()?;
// Only a live process holds it.
let alive = unsafe { libc::kill(pid, 0) } == 0;
alive.then(|| format!("another copy (process {pid}) is using {}", cache.display()))
}
/// Chromium's Windows lock names no process.
#[cfg(windows)]
fn held(_cache: &Path) -> Option<String> {
None
}
#[cfg(test)]
mod tests {
#[test]
fn cef_loads_with_its_api_version() {
let dir = super::load().expect("CEF loads");
#[cfg(not(target_os = "macos"))]
assert!(dir.join("locales").is_dir(), "{} holds CEF's files", dir.display());
// Without the version this call exits the process.
assert!(cef::command_line_create().is_some());
}
}
-88
View File
@@ -1,88 +0,0 @@
//! The fence: where pages go, what they load.
use cef::{wrapper::message_router::MessageRouterBrowserSideHandlerCallbacks, *};
use crate::{handlers::router, surface::Shared};
wrap_request_handler! {
pub(crate) struct Requests {
surface: Shared,
}
impl RequestHandler {
fn on_before_browse(
&self,
browser: Option<&mut Browser>,
frame: Option<&mut Frame>,
request: Option<&mut Request>,
user_gesture: ::std::os::raw::c_int,
_is_redirect: ::std::os::raw::c_int,
) -> ::std::os::raw::c_int {
let url = request.map(|r| CefString::from(&r.url()).to_string()).unwrap_or_default();
let mut surface = self.surface.lock().expect("the surface lock");
// Content stays bundled; a clicked link leaves for the system.
if !surface.navigable(&url) {
if user_gesture == 1 {
surface.leaving.push(url);
}
return 1;
}
drop(surface);
router().on_before_browse(browser.map(|b| b.clone()), frame.map(|f| f.clone()));
0
}
fn resource_request_handler(
&self,
_browser: Option<&mut Browser>,
_frame: Option<&mut Frame>,
_request: Option<&mut Request>,
_is_navigation: ::std::os::raw::c_int,
_is_download: ::std::os::raw::c_int,
_request_initiator: Option<&CefString>,
_disable_default_handling: Option<&mut ::std::os::raw::c_int>,
) -> Option<ResourceRequestHandler> {
Some(Resources::new(self.surface.clone()))
}
fn on_render_process_terminated(
&self,
browser: Option<&mut Browser>,
status: TerminationStatus,
error_code: ::std::os::raw::c_int,
_error_string: Option<&CefString>,
) {
router().on_render_process_terminated(browser.map(|b| b.clone()));
self.surface.lock().expect("the surface lock").failed =
Some(format!("the page stopped: {status:?} ({error_code})"));
}
}
}
wrap_resource_request_handler! {
struct Resources {
surface: Shared,
}
impl ResourceRequestHandler {
// Every request, scripts and images too, meets the fence.
fn on_before_resource_load(
&self,
_browser: Option<&mut Browser>,
_frame: Option<&mut Frame>,
request: Option<&mut Request>,
_callback: Option<&mut Callback>,
) -> ReturnValue {
let Some(request) = request else {
return ReturnValue::CANCEL;
};
let url = CefString::from(&request.url()).to_string();
let image = request.resource_type() == ResourceType::IMAGE;
if self.surface.lock().expect("the surface lock").loadable(&url, image) {
return ReturnValue::CONTINUE;
}
log::warn!("web: a request outside the allowed folders was refused");
ReturnValue::CANCEL
}
}
}
-482
View File
@@ -1,482 +0,0 @@
//! What CEF calls back: per process, and per browser.
use std::{
collections::HashMap,
sync::{Arc, Mutex, OnceLock},
};
use cef::{
wrapper::message_router::{
BrowserSideCallback, BrowserSideHandler, BrowserSideRouter, MessageRouterBrowserSide,
MessageRouterBrowserSideHandlerCallbacks, MessageRouterConfig, MessageRouterRendererSide,
MessageRouterRendererSideHandlerCallbacks, RendererSideRouter,
},
*,
};
use crate::{fence::Requests, surface::Shared};
/// The page calls `window.elyQuery({request, onSuccess, onFailure})`.
fn config() -> MessageRouterConfig {
MessageRouterConfig {
js_query_function: "elyQuery".into(),
js_cancel_function: "elyQueryCancel".into(),
..Default::default()
}
}
/// Each browser's surface, by CEF's browser id.
fn surfaces() -> &'static Mutex<HashMap<i32, Shared>> {
static SURFACES: OnceLock<Mutex<HashMap<i32, Shared>>> = OnceLock::new();
SURFACES.get_or_init(Default::default)
}
pub(crate) fn adopt(browser: &Browser, surface: Shared) {
surfaces()
.lock()
.expect("the surface lock")
.insert(browser.identifier(), surface);
}
fn surface_of(browser: Option<&Browser>) -> Option<Shared> {
let id = browser?.identifier();
surfaces()
.lock()
.expect("the surface lock")
.get(&id)
.cloned()
}
/// Queries go to the view whose page asked.
struct Queries;
impl BrowserSideHandler for Queries {
fn on_query_str(
&self,
browser: Option<Browser>,
_frame: Option<Frame>,
_query_id: i64,
request: &str,
_persistent: bool,
callback: Arc<Mutex<dyn BrowserSideCallback>>,
) -> bool {
let Some(surface) = surface_of(browser.as_ref()) else {
return false;
};
surface
.lock()
.expect("the surface lock")
.queries
.push((request.to_string(), callback));
true
}
}
pub(crate) fn router() -> &'static Arc<BrowserSideRouter> {
static ROUTER: OnceLock<Arc<BrowserSideRouter>> = OnceLock::new();
ROUTER.get_or_init(|| {
let router = BrowserSideRouter::new(config());
router.add_handler(Arc::new(Queries), true);
router
})
}
fn renderer_router() -> &'static Arc<RendererSideRouter> {
static ROUTER: OnceLock<Arc<RendererSideRouter>> = OnceLock::new();
ROUTER.get_or_init(|| RendererSideRouter::new(config()))
}
/// WebRTC reaches the network by UDP; pages lose it.
const NO_PEERS: &str = "for (const name of ['RTCPeerConnection', 'webkitRTCPeerConnection', \
'RTCDataChannel', 'RTCSessionDescription', 'RTCIceCandidate', 'RTCRtpSender', \
'RTCRtpReceiver', 'RTCRtpTransceiver']) delete globalThis[name];";
wrap_app! {
struct ElyApp;
impl App {
fn on_before_command_line_processing(
&self,
_process_type: Option<&CefString>,
command_line: Option<&mut CommandLine>,
) {
let Some(line) = command_line else { return };
// No keychain prompt; the content keeps no secrets.
line.append_switch(Some(&"use-mock-keychain".into()));
line.append_switch(Some(&"disable-extensions".into()));
// Offline pages: all traffic meets a dead proxy.
line.append_switch_with_value(Some(&"proxy-server".into()), Some(&"127.0.0.1:9".into()));
line.append_switch_with_value(
Some(&"proxy-bypass-list".into()),
Some(&"<-loopback>".into()),
);
// WebRTC sends UDP past proxies; it may use proxies only.
line.append_switch_with_value(
Some(&"force-webrtc-ip-handling-policy".into()),
Some(&"disable_non_proxied_udp".into()),
);
}
fn browser_process_handler(&self) -> Option<BrowserProcessHandler> {
Some(Pump::new())
}
fn render_process_handler(&self) -> Option<RenderProcessHandler> {
Some(Renderer::new())
}
}
}
pub(crate) fn app() -> cef::App {
ElyApp::new()
}
wrap_browser_process_handler! {
struct Pump;
impl BrowserProcessHandler {
fn on_schedule_message_pump_work(&self, delay_ms: i64) {
crate::engine::schedule(delay_ms);
}
}
}
wrap_render_process_handler! {
struct Renderer;
impl RenderProcessHandler {
fn on_context_created(
&self,
browser: Option<&mut Browser>,
frame: Option<&mut Frame>,
context: Option<&mut V8Context>,
) {
// Before any page script: no peer connections at all.
if let Some(context) = context.as_ref() {
let code = CefString::from(NO_PEERS);
let (mut value, mut thrown) = (None, None);
if context.eval(Some(&code), None, 0, Some(&mut value), Some(&mut thrown)) != 1 {
eprintln!("web: WebRTC could not be removed from a page");
}
}
renderer_router().on_context_created(
browser.map(|b| b.clone()),
frame.map(|f| f.clone()),
context.map(|c| c.clone()),
);
}
fn on_context_released(
&self,
browser: Option<&mut Browser>,
frame: Option<&mut Frame>,
context: Option<&mut V8Context>,
) {
renderer_router().on_context_released(
browser.map(|b| b.clone()),
frame.map(|f| f.clone()),
context.map(|c| c.clone()),
);
}
fn on_process_message_received(
&self,
browser: Option<&mut Browser>,
frame: Option<&mut Frame>,
source_process: ProcessId,
message: Option<&mut ProcessMessage>,
) -> ::std::os::raw::c_int {
renderer_router().on_process_message_received(
browser.map(|b| b.clone()),
frame.map(|f| f.clone()),
Some(source_process),
message.map(|m| m.clone()),
) as _
}
}
}
wrap_render_handler! {
struct Paint {
surface: Shared,
}
impl RenderHandler {
fn view_rect(&self, _browser: Option<&mut Browser>, rect: Option<&mut Rect>) {
let Some(rect) = rect else { return };
let surface = self.surface.lock().expect("the surface lock");
// CEF needs a page at least one point square.
rect.width = (surface.size.0.round() as i32).max(1);
rect.height = (surface.size.1.round() as i32).max(1);
}
fn screen_info(
&self,
_browser: Option<&mut Browser>,
screen_info: Option<&mut ScreenInfo>,
) -> ::std::os::raw::c_int {
let Some(info) = screen_info else { return 0 };
info.device_scale_factor = self.surface.lock().expect("the surface lock").scale;
1
}
fn on_paint(
&self,
_browser: Option<&mut Browser>,
type_: PaintElementType,
_dirty_rects: Option<&[Rect]>,
buffer: *const u8,
width: ::std::os::raw::c_int,
height: ::std::os::raw::c_int,
) {
// Popups (select menus) are not drawn; pages avoid them.
if type_ != PaintElementType::VIEW || buffer.is_null() || width <= 0 || height <= 0 {
return;
}
let length = width as usize * height as usize * 4;
let bgra = unsafe { std::slice::from_raw_parts(buffer, length) }.to_vec();
self.surface.lock().expect("the surface lock").frame =
Some((width as u32, height as u32, bgra));
}
fn on_ime_composition_range_changed(
&self,
_browser: Option<&mut Browser>,
_selected_range: Option<&Range>,
character_bounds: Option<&[Rect]>,
) {
let bounds = character_bounds.unwrap_or_default();
self.surface.lock().expect("the surface lock").ime = bounds
.iter()
.map(|r| (r.x as f32, r.y as f32, r.width as f32, r.height as f32))
.collect();
}
}
}
wrap_display_handler! {
struct Display {
surface: Shared,
}
impl DisplayHandler {
fn on_console_message(
&self,
_browser: Option<&mut Browser>,
level: LogSeverity,
_message: Option<&CefString>,
_source: Option<&CefString>,
line: ::std::os::raw::c_int,
) -> ::std::os::raw::c_int {
// Words and sources a document may forge stay out.
if level.get_raw() >= LogSeverity::WARNING.get_raw() {
log::warn!("web: the page warned at line {line}");
}
0
}
fn on_cursor_change(
&self,
_browser: Option<&mut Browser>,
_cursor: CursorHandle,
type_: CursorType,
_custom_cursor_info: Option<&CursorInfo>,
) -> ::std::os::raw::c_int {
self.surface.lock().expect("the surface lock").cursor = type_;
1
}
}
}
wrap_load_handler! {
struct Load {
surface: Shared,
}
impl LoadHandler {
fn on_load_start(
&self,
_browser: Option<&mut Browser>,
frame: Option<&mut Frame>,
_transition_type: TransitionType,
) {
if frame.is_some_and(|frame| frame.is_main() == 1) {
self.surface.lock().expect("the surface lock").errored = false;
}
}
fn on_load_error(
&self,
_browser: Option<&mut Browser>,
frame: Option<&mut Frame>,
error_code: Errorcode,
error_text: Option<&CefString>,
failed_url: Option<&CefString>,
) {
// A stopped load is no failure; a fenced one is.
if frame.is_none_or(|frame| frame.is_main() != 1) || error_code == Errorcode::ABORTED {
return;
}
let text = error_text.map(CefString::to_string).unwrap_or_default();
let url = failed_url.map(CefString::to_string).unwrap_or_default();
let mut surface = self.surface.lock().expect("the surface lock");
surface.errored = true;
surface.failed = Some(format!("{text} ({}) {url}", error_code.get_raw()));
}
fn on_load_end(
&self,
_browser: Option<&mut Browser>,
frame: Option<&mut Frame>,
_http_status_code: ::std::os::raw::c_int,
) {
if frame.is_some_and(|frame| frame.is_main() == 1) {
let mut surface = self.surface.lock().expect("the surface lock");
surface.loaded = !surface.errored;
}
}
}
}
wrap_life_span_handler! {
struct Life {
surface: Shared,
}
impl LifeSpanHandler {
fn on_after_created(&self, browser: Option<&mut Browser>) {
if let Some(browser) = browser {
log::info!("web: browser {} made", browser.identifier());
}
}
fn on_before_close(&self, browser: Option<&mut Browser>) {
let browser = browser.map(|b| b.clone());
router().on_before_close(browser.clone());
if let Some(browser) = browser {
surfaces().lock().expect("the surface lock").remove(&browser.identifier());
}
// The browser holds these handlers; they hold the surface.
self.surface.lock().expect("the surface lock").browser = None;
}
}
}
wrap_client! {
struct Handlers {
paint: RenderHandler,
display: DisplayHandler,
load: LoadHandler,
life: LifeSpanHandler,
requests: RequestHandler,
keys: Option<KeyboardHandler>,
}
impl Client {
fn keyboard_handler(&self) -> Option<KeyboardHandler> {
self.keys.clone()
}
fn render_handler(&self) -> Option<RenderHandler> {
Some(self.paint.clone())
}
fn display_handler(&self) -> Option<DisplayHandler> {
Some(self.display.clone())
}
fn load_handler(&self) -> Option<LoadHandler> {
Some(self.load.clone())
}
fn life_span_handler(&self) -> Option<LifeSpanHandler> {
Some(self.life.clone())
}
fn request_handler(&self) -> Option<RequestHandler> {
Some(self.requests.clone())
}
fn on_process_message_received(
&self,
browser: Option<&mut Browser>,
frame: Option<&mut Frame>,
source_process: ProcessId,
message: Option<&mut ProcessMessage>,
) -> ::std::os::raw::c_int {
router().on_process_message_received(
browser.map(|b| b.clone()),
frame.map(|f| f.clone()),
source_process,
message.map(|m| m.clone()),
) as _
}
}
}
/// A browser's handlers, all sharing its surface.
pub(crate) fn client(surface: &Shared) -> cef::Client {
Handlers::new(
Paint::new(surface.clone()),
Display::new(surface.clone()),
Load::new(surface.clone()),
Life::new(surface.clone()),
Requests::new(surface.clone()),
keys(),
)
}
#[cfg(target_os = "macos")]
fn keys() -> Option<KeyboardHandler> {
Some(crate::edit::Keys::new())
}
/// Elsewhere Chromium binds these keys itself.
#[cfg(not(target_os = "macos"))]
fn keys() -> Option<KeyboardHandler> {
None
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex, OnceLock};
use cef::{CefString, ImplDisplayHandler, LogSeverity};
struct Kept;
fn kept() -> &'static Mutex<Vec<String>> {
static KEPT: OnceLock<Mutex<Vec<String>>> = OnceLock::new();
KEPT.get_or_init(Mutex::default)
}
impl log::Log for Kept {
fn enabled(&self, _: &log::Metadata) -> bool {
true
}
fn log(&self, record: &log::Record) {
kept().lock().expect("the kept lock").push(record.args().to_string());
}
fn flush(&self) {}
}
#[test]
fn a_pages_words_and_sources_never_reach_the_log() {
log::set_boxed_logger(Box::new(Kept)).expect("the only logger");
log::set_max_level(log::LevelFilter::Trace);
let surface = crate::surface::Surface::new(std::env::temp_dir(), 1.0);
let display = super::Display::new(Arc::new(Mutex::new(surface)));
let said = CefString::from("ely: M11_SECRET");
let at = CefString::from("file:///content/M11_SECRET.js");
for level in [LogSeverity::WARNING, LogSeverity::ERROR, LogSeverity::INFO] {
display.on_console_message(None, level, Some(&said), Some(&at), 7);
}
let records = kept().lock().expect("the kept lock").clone();
let warned = records.iter().filter(|said| *said == "web: the page warned at line 7");
assert_eq!(warned.count(), 2, "warnings and errors stay seen: {records:#?}");
assert!(!records.iter().any(|said| said.contains("M11_SECRET")), "{records:#?}");
}
}
-344
View File
@@ -1,344 +0,0 @@
//! GPUI's mouse, keys, focus and IME, as CEF events.
use std::ops::Range;
use cef::*;
use gpui::{
App, Bounds, Context, Div, Entity, EntityInputHandler, InteractiveElement as _, KeyDownEvent,
KeyUpEvent, Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels,
Point, ScrollDelta, ScrollWheelEvent, UTF16Selection, Window, point, px, size,
};
use crate::{keys, view::WebView};
gpui::actions!(web, [PageTab, PageBackTab]);
/// The page's key context: Tab stays in the page.
const CONTEXT: &str = "WebView";
/// Tab and Shift-Tab go to the page, not to focus.
pub(crate) fn bind(cx: &mut App) {
cx.bind_keys([
gpui::KeyBinding::new("tab", PageTab, Some(CONTEXT)),
gpui::KeyBinding::new("shift-tab", PageBackTab, Some(CONTEXT)),
]);
}
/// CEF's `cef_event_flags_t` for these modifiers.
pub(crate) fn flags(modifiers: &Modifiers) -> u32 {
let mut flags = 0;
for (on, bit) in [
(modifiers.shift, 1 << 1),
(modifiers.control, 1 << 2),
(modifiers.alt, 1 << 3),
(modifiers.platform, 1 << 7),
] {
if on {
flags |= bit;
}
}
flags
}
fn button(button: MouseButton) -> Option<(MouseButtonType, u32)> {
match button {
MouseButton::Left => Some((MouseButtonType::LEFT, 1 << 4)),
MouseButton::Middle => Some((MouseButtonType::MIDDLE, 1 << 5)),
MouseButton::Right => Some((MouseButtonType::RIGHT, 1 << 6)),
_ => None,
}
}
impl WebView {
/// A window point as a page point.
fn at(&self, position: Point<Pixels>, modifiers: u32) -> MouseEvent {
let local = position - self.bounds.origin;
MouseEvent {
x: f32::from(local.x).round() as i32,
y: f32::from(local.y).round() as i32,
modifiers,
}
}
fn mouse_down(&mut self, event: &MouseDownEvent, window: &mut Window, cx: &mut Context<Self>) {
window.focus(&self.focus, cx);
let Some((kind, held)) = button(event.button) else {
return;
};
let at = self.at(event.position, flags(&event.modifiers) | held);
let count = event.click_count as i32;
self.on_host(move |host| host.send_mouse_click_event(Some(&at), kind, false as _, count));
}
fn mouse_up(&mut self, event: &MouseUpEvent, _: &mut Window, _: &mut Context<Self>) {
let Some((kind, _)) = button(event.button) else {
return;
};
let at = self.at(event.position, flags(&event.modifiers));
let count = event.click_count as i32;
self.on_host(move |host| host.send_mouse_click_event(Some(&at), kind, true as _, count));
}
fn mouse_move(&mut self, event: &MouseMoveEvent, _: &mut Window, _: &mut Context<Self>) {
let held = event
.pressed_button
.and_then(button)
.map_or(0, |(_, bit)| bit);
let at = self.at(event.position, flags(&event.modifiers) | held);
self.on_host(move |host| host.send_mouse_move_event(Some(&at), false as _));
}
fn scroll(&mut self, event: &ScrollWheelEvent, _: &mut Window, _: &mut Context<Self>) {
// Lines as Chromium counts them: forty points.
let delta = match event.delta {
ScrollDelta::Pixels(delta) => (f32::from(delta.x), f32::from(delta.y)),
ScrollDelta::Lines(delta) => (delta.x * 40.0, delta.y * 40.0),
};
let at = self.at(event.position, flags(&event.modifiers));
let (x, y) = (delta.0.round() as i32, delta.1.round() as i32);
self.on_host(move |host| host.send_mouse_wheel_event(Some(&at), x, y));
}
fn key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
let stroke = &event.keystroke;
// Edit commands Chromium only takes from its own menu.
let edit = stroke.key.clone();
if keys::edit_chord(&stroke.modifiers) && matches!(edit.as_str(), "c" | "x" | "v" | "a") {
// The owner may take a paste, such as an image.
if edit == "v" && self.paste.clone().is_some_and(|take| take(window, cx)) {
return cx.stop_propagation();
}
self.on_browser(move |browser| {
let Some(frame) = browser.main_frame() else {
return;
};
match edit.as_str() {
"c" => frame.copy(),
"x" => frame.cut(),
"v" => frame.paste(),
_ => frame.select_all(),
}
});
return cx.stop_propagation();
}
let Some(code) = keys::code(&stroke.key) else {
return log::debug!("web: no key code for {}", stroke.key);
};
let event = KeyEvent {
modifiers: flags(&stroke.modifiers),
windows_key_code: code.windows,
native_key_code: code.native,
..Default::default()
};
let chord = stroke.modifiers.control || stroke.modifiers.platform;
match (keys::character(&stroke.key), &stroke.key_char) {
// Typed text arrives by the input handler, which sends it.
(None, Some(_)) if !chord => self.typing = Some(event),
(character, typed) => {
let character = character
.or_else(|| typed.as_deref().and_then(|t| t.encode_utf16().next()))
.unwrap_or(0);
self.press(event, character);
}
}
}
/// Tab, with Shift when `back`, down and up.
fn tab(&mut self, back: bool) {
let code = keys::code("tab").expect("tab has a key code");
let event = KeyEvent {
modifiers: if back { 1 << 1 } else { 0 },
windows_key_code: code.windows,
native_key_code: code.native,
..Default::default()
};
self.press(event.clone(), 0x09);
let up = KeyEvent {
type_: KeyEventType::KEYUP,
character: 0x09,
unmodified_character: 0x09,
..event
};
self.on_host(move |host| host.send_key_event(Some(&up)));
}
/// A key down and its character, as cefclient sends them.
fn press(&self, event: KeyEvent, character: u16) {
let mut event = KeyEvent {
type_: KeyEventType::KEYDOWN,
character,
unmodified_character: character,
..event
};
self.on_host(move |host| {
host.send_key_event(Some(&event));
event.type_ = KeyEventType::CHAR;
host.send_key_event(Some(&event));
});
}
fn key_up(&mut self, event: &KeyUpEvent, _: &mut Window, _: &mut Context<Self>) {
let stroke = &event.keystroke;
let Some(code) = keys::code(&stroke.key) else {
return;
};
// An up without its character reads as another down.
let character = keys::character(&stroke.key)
.or_else(|| {
stroke
.key_char
.as_deref()
.and_then(|t| t.encode_utf16().next())
})
.or_else(|| stroke.key.encode_utf16().next())
.unwrap_or(0);
let up = KeyEvent {
type_: KeyEventType::KEYUP,
modifiers: flags(&stroke.modifiers),
windows_key_code: code.windows,
native_key_code: code.native,
character,
unmodified_character: character,
..Default::default()
};
self.on_host(move |host| host.send_key_event(Some(&up)));
}
}
/// The view's listeners on its outer element.
pub(crate) fn listen(element: Div, cx: &mut Context<WebView>) -> Div {
element
.key_context(CONTEXT)
.on_action(cx.listener(|view, _: &PageTab, _, _| view.tab(false)))
.on_action(cx.listener(|view, _: &PageBackTab, _, _| view.tab(true)))
.on_mouse_down(MouseButton::Left, cx.listener(WebView::mouse_down))
.on_mouse_down(MouseButton::Right, cx.listener(WebView::mouse_down))
.on_mouse_down(MouseButton::Middle, cx.listener(WebView::mouse_down))
.on_mouse_up(MouseButton::Left, cx.listener(WebView::mouse_up))
.on_mouse_up(MouseButton::Right, cx.listener(WebView::mouse_up))
.on_mouse_up(MouseButton::Middle, cx.listener(WebView::mouse_up))
.on_mouse_move(cx.listener(WebView::mouse_move))
.on_scroll_wheel(cx.listener(WebView::scroll))
.on_key_down(cx.listener(WebView::key_down))
.on_key_up(cx.listener(WebView::key_up))
}
/// CEF's page focus follows the view's.
pub(crate) fn follow_focus(view: &Entity<WebView>, window: &mut Window, cx: &mut App) {
view.update(cx, |this, cx| {
let focus = this.focus.clone();
cx.on_focus(&focus, window, |view, _, _| {
view.on_host(|host| host.set_focus(true as _));
})
.detach();
cx.on_blur(&focus, window, |view, _, _| {
view.on_host(|host| host.set_focus(false as _));
})
.detach();
});
}
/// CEF's "no range": both ends at the maximum.
const NO_RANGE: cef::Range = cef::Range {
from: u32::MAX,
to: u32::MAX,
};
fn utf16(text: &str) -> Vec<u16> {
text.encode_utf16().collect()
}
impl EntityInputHandler for WebView {
/// The IME sees none of the page's text.
fn text_for_range(
&mut self,
_: Range<usize>,
_: &mut Option<Range<usize>>,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<String> {
None
}
fn selected_text_range(
&mut self,
_: bool,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<UTF16Selection> {
None
}
fn marked_text_range(&self, _: &mut Window, _: &mut Context<Self>) -> Option<Range<usize>> {
self.composing.clone()
}
fn unmark_text(&mut self, _: &mut Window, _: &mut Context<Self>) {
if self.composing.take().is_some() {
self.on_host(|host| host.ime_finish_composing_text(false as _));
}
}
fn replace_text_in_range(
&mut self,
_: Option<Range<usize>>,
text: &str,
_: &mut Window,
_: &mut Context<Self>,
) {
let typed = self.typing.take();
let was = self.composing.take();
let units = utf16(text);
// One character, no composition: a key press, as cefclient.
if let (Some(event), None, [character]) = (typed, was, units.as_slice()) {
return self.press(event, *character);
}
let text = CefString::from(text);
self.on_host(move |host| host.ime_commit_text(Some(&text), Some(&NO_RANGE), 0));
}
fn replace_and_mark_text_in_range(
&mut self,
_: Option<Range<usize>>,
new_text: &str,
new_selected_range: Option<Range<usize>>,
_: &mut Window,
_: &mut Context<Self>,
) {
let length = utf16(new_text).len();
self.composing = (length > 0).then_some(0..length);
let selection = new_selected_range.unwrap_or(length..length);
let range = cef::Range {
from: selection.start as _,
to: selection.end as _,
};
let text = CefString::from(new_text);
self.on_host(move |host| {
host.ime_set_composition(Some(&text), None, Some(&NO_RANGE), Some(&range))
});
}
fn bounds_for_range(
&mut self,
range: Range<usize>,
element: Bounds<Pixels>,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<Bounds<Pixels>> {
let surface = self.surface.lock().expect("the surface lock");
let (x, y, w, h) = *surface.ime.get(range.start).or(surface.ime.last())?;
Some(Bounds::new(
element.origin + point(px(x), px(y)),
size(px(w), px(h)),
))
}
fn character_index_for_point(
&mut self,
_: Point<Pixels>,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<usize> {
None
}
}
-189
View File
@@ -1,189 +0,0 @@
//! GPUI key names as the codes Chromium reads.
use gpui::Modifiers;
/// Windows' virtual key, and macOS's hardware key.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Code {
pub windows: i32,
pub native: i32,
}
/// Letters, then digits, as macOS numbers its keys.
const MAC_LETTERS: [i32; 26] = [
0, 11, 8, 2, 14, 3, 5, 4, 34, 38, 40, 37, 46, 45, 31, 35, 12, 15, 1, 17, 32, 9, 13, 7, 16, 6,
];
const MAC_DIGITS: [i32; 10] = [29, 18, 19, 20, 21, 23, 22, 26, 28, 25];
const MAC_F: [i32; 12] = [122, 120, 99, 118, 96, 97, 98, 100, 101, 109, 103, 111];
/// Named keys: GPUI's name, Windows, macOS.
const NAMED: &[(&str, i32, i32)] = &[
("backspace", 0x08, 51),
("tab", 0x09, 48),
("enter", 0x0D, 36),
("escape", 0x1B, 53),
("space", 0x20, 49),
("pageup", 0x21, 116),
("pagedown", 0x22, 121),
("end", 0x23, 119),
("home", 0x24, 115),
("left", 0x25, 123),
("up", 0x26, 126),
("right", 0x27, 124),
("down", 0x28, 125),
("delete", 0x2E, 117),
(";", 0xBA, 41),
("=", 0xBB, 24),
(",", 0xBC, 43),
("-", 0xBD, 27),
(".", 0xBE, 47),
("/", 0xBF, 44),
("`", 0xC0, 50),
("[", 0xDB, 33),
("\\", 0xDC, 42),
("]", 0xDD, 30),
("'", 0xDE, 39),
];
pub fn code(key: &str) -> Option<Code> {
if let Some(&(_, windows, native)) = NAMED.iter().find(|(name, _, _)| *name == key) {
return Some(Code { windows, native });
}
let mut chars = key.chars();
let (Some(c), None) = (chars.next(), chars.next()) else {
let n: usize = key.strip_prefix('f')?.parse().ok()?;
let native = *MAC_F.get(n.checked_sub(1)?)?;
return Some(Code {
windows: 0x6F + n as i32,
native,
});
};
match c {
'a'..='z' => Some(Code {
windows: c.to_ascii_uppercase() as i32,
native: MAC_LETTERS[(c as u8 - b'a') as usize],
}),
'0'..='9' => Some(Code {
windows: c as i32,
native: MAC_DIGITS[(c as u8 - b'0') as usize],
}),
_ => None,
}
}
/// The character AppKit gives keys that type none.
#[cfg(target_os = "macos")]
pub fn character(key: &str) -> Option<u16> {
let n: Option<u16> = key.strip_prefix('f').and_then(|n| n.parse().ok());
Some(match key {
"backspace" => 0x7F,
"tab" => 0x09,
"enter" => 0x0D,
"escape" => 0x1B,
"up" => 0xF700,
"down" => 0xF701,
"left" => 0xF702,
"right" => 0xF703,
"delete" => 0xF728,
"home" => 0xF729,
"end" => 0xF72B,
"pageup" => 0xF72C,
"pagedown" => 0xF72D,
_ => match n {
Some(n @ 1..=12) => 0xF703 + n,
_ => return None,
},
})
}
/// The control character other systems type, if any.
#[cfg(not(target_os = "macos"))]
pub fn character(key: &str) -> Option<u16> {
Some(match key {
"backspace" => 0x08,
"tab" => 0x09,
"enter" => 0x0D,
"escape" => 0x1B,
_ => return None,
})
}
/// The chord for cut, copy, paste and select-all here.
pub fn edit_chord(modifiers: &Modifiers) -> bool {
let primary = if cfg!(target_os = "macos") {
modifiers.platform && !modifiers.control
} else {
modifiers.control && !modifiers.platform
};
primary && !modifiers.alt && !modifiers.shift
}
#[cfg(test)]
mod tests {
use super::{Code, code};
#[test]
fn keys_carry_both_codes() {
assert_eq!(
code("a"),
Some(Code {
windows: 0x41,
native: 0
})
);
assert_eq!(
code("z"),
Some(Code {
windows: 0x5A,
native: 6
})
);
assert_eq!(
code("0"),
Some(Code {
windows: 0x30,
native: 29
})
);
assert_eq!(
code("enter"),
Some(Code {
windows: 0x0D,
native: 36
})
);
assert_eq!(
code("f1"),
Some(Code {
windows: 0x70,
native: 122
})
);
assert_eq!(
code("f12"),
Some(Code {
windows: 0x7B,
native: 111
})
);
assert_eq!(
code("["),
Some(Code {
windows: 0xDB,
native: 33
})
);
assert_eq!(code("f13"), None);
assert_eq!(code("é"), None);
}
#[cfg(target_os = "macos")]
#[test]
fn keys_without_text_carry_appkit_characters() {
assert_eq!(super::character("backspace"), Some(0x7F));
assert_eq!(super::character("up"), Some(0xF700));
assert_eq!(super::character("f1"), Some(0xF704));
assert_eq!(super::character("f12"), Some(0xF70F));
assert_eq!(super::character("a"), None);
}
}
-19
View File
@@ -1,19 +0,0 @@
//! Bundled pages: CEF draws off screen, GPUI shows.
#[cfg(target_os = "macos")]
mod edit;
mod engine;
mod fence;
mod handlers;
mod input;
mod keys;
#[cfg(target_os = "macos")]
mod mac;
mod print;
mod surface;
mod tokens;
mod view;
pub use engine::{start, subprocess};
pub use surface::file_url;
pub use view::{Event, Reply, WebView};
-105
View File
@@ -1,105 +0,0 @@
//! Chromium's app protocol on GPUI's own `NSApplication` class.
use std::{cell::Cell, ffi::CStr};
use anyhow::{Result, bail};
use objc2::{
ffi,
runtime::{AnyClass, AnyObject, Bool, Imp, Sel},
sel,
};
thread_local! {
/// NSApp lives on the main thread; one flag serves it.
static SENDING: Cell<bool> = const { Cell::new(false) };
}
extern "C-unwind" fn is_handling(_this: *mut AnyObject, _cmd: Sel) -> Bool {
Bool::new(SENDING.get())
}
extern "C-unwind" fn set_handling(_this: *mut AnyObject, _cmd: Sel, on: Bool) {
SENDING.set(on.as_bool());
}
extern "C-unwind" fn send_event(this: *mut AnyObject, _cmd: Sel, event: *mut AnyObject) {
let was = SENDING.replace(true);
let base = AnyClass::get(c"NSApplication").expect("AppKit has NSApplication");
unsafe {
let _: () = objc2::msg_send![super(&*this, base), sendEvent: event];
}
SENDING.set(was);
}
fn add(class: &AnyClass, name: Sel, imp: Imp, types: &CStr) -> Result<()> {
let added = unsafe {
ffi::class_addMethod(
class as *const AnyClass as *mut AnyClass,
name,
imp,
types.as_ptr(),
)
};
if !added.as_bool() {
bail!("{} already has -{name}", class.name().to_string_lossy());
}
Ok(())
}
/// Gives NSApp's class what CEF checks before it runs.
pub fn adopt_app_protocol() -> Result<()> {
let Some(app) = (unsafe { objc2_app_kit_nsapp() }) else {
bail!("no NSApp yet");
};
let class = unsafe { &*ffi::object_getClass(app.cast()) };
if class.responds_to(sel!(isHandlingSendEvent)) {
return Ok(());
}
unsafe {
add(
class,
sel!(isHandlingSendEvent),
std::mem::transmute(is_handling as *const ()),
c"c@:",
)?;
add(
class,
sel!(setHandlingSendEvent:),
std::mem::transmute(set_handling as *const ()),
c"v@:c",
)?;
add(
class,
sel!(sendEvent:),
std::mem::transmute(send_event as *const ()),
c"v@:@",
)?;
}
// Chromium checks the two `Cr` protocols; CEF's own is header-only.
for (name, needed) in [
(c"CrAppProtocol", true),
(c"CrAppControlProtocol", true),
(c"CefAppProtocol", false),
] {
let protocol = unsafe { ffi::objc_getProtocol(name.as_ptr()) };
match (protocol.is_null(), needed) {
(true, true) => bail!("CEF did not register {}", name.to_string_lossy()),
(true, false) => log::info!("web: {} is not registered", name.to_string_lossy()),
(false, _) => unsafe {
ffi::class_addProtocol(class as *const AnyClass as *mut AnyClass, protocol);
},
}
}
log::info!(
"web: {} takes CefAppProtocol",
class.name().to_string_lossy()
);
Ok(())
}
/// The shared application, if made.
unsafe fn objc2_app_kit_nsapp() -> Option<*mut AnyObject> {
let class = AnyClass::get(c"NSApplication")?;
let app: *mut AnyObject = unsafe { objc2::msg_send![class, sharedApplication] };
(!app.is_null()).then_some(app)
}
-39
View File
@@ -1,39 +0,0 @@
//! A page as PDF, printed by Chromium itself (MARK-11).
use std::path::{Path, PathBuf};
use cef::*;
use crate::{surface::Shared, view::WebView};
wrap_pdf_print_callback! {
struct Printed {
surface: Shared,
}
impl PdfPrintCallback {
fn on_pdf_print_finished(&self, path: Option<&CefString>, ok: ::std::os::raw::c_int) {
let path = PathBuf::from(path.map(CefString::to_string).unwrap_or_default());
log::info!("web: printed {} ok {}", path.display(), ok != 0);
self.surface.lock().expect("the surface lock").printed.push((path, ok != 0));
}
}
}
impl WebView {
/// Prints the page to `path`; `Event::Printed` tells how.
pub fn print_pdf(&self, path: &Path) {
let (path, surface) = (
CefString::from(path.to_string_lossy().as_ref()),
self.surface.clone(),
);
self.on_host(move |host| {
let settings = PdfPrintSettings {
print_background: 1,
..Default::default()
};
let mut done = Printed::new(surface);
host.print_to_pdf(Some(&path), Some(&settings), Some(&mut done));
});
}
}
-278
View File
@@ -1,278 +0,0 @@
//! What CEF's handlers leave the view; the page fence.
use std::{
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
use cef::{wrapper::message_router::BrowserSideCallback, *};
pub(crate) type Shared = Arc<Mutex<Surface>>;
pub(crate) struct Surface {
/// Points, as GPUI lays the view out.
pub size: (f32, f32),
pub scale: f32,
pub frame: Option<(u32, u32, Vec<u8>)>,
/// Composing characters' boxes, in points.
pub ime: Vec<(f32, f32, f32, f32)>,
pub cursor: CursorType,
pub loaded: bool,
/// The main frame's load failed since it started.
pub errored: bool,
pub queries: Vec<(String, Arc<Mutex<dyn BrowserSideCallback>>)>,
pub leaving: Vec<String>,
/// PDFs printed: where, and whether it worked.
pub printed: Vec<(PathBuf, bool)>,
/// Why the page cannot show: load, renderer or start.
pub failed: Option<String>,
/// Made on the pump's turn, after the view.
pub browser: Option<Browser>,
/// The view went before its browser came.
pub closed: bool,
/// Whether the page's scripts run.
pub scripts: bool,
/// The only folder pages load from, real path.
root: PathBuf,
/// A folder images alone may also come from.
pub images: Option<PathBuf>,
}
impl Surface {
pub fn new(root: PathBuf, scale: f32) -> Self {
Surface {
size: (1.0, 1.0),
scale,
frame: None,
ime: Vec::new(),
cursor: CursorType::POINTER,
loaded: false,
errored: false,
queries: Vec::new(),
leaving: Vec::new(),
printed: Vec::new(),
failed: None,
browser: None,
closed: false,
scripts: true,
root,
images: None,
}
}
/// A file under `root`, by its real path.
fn holds(root: &Path, url: &str) -> bool {
let Some(path) = url.strip_prefix("file://") else {
return false;
};
let path = path.split(['?', '#']).next().unwrap_or_default();
let Ok(path) = String::from_utf8(decoded(path)) else {
return false;
};
#[cfg(windows)]
let path = windows_path(&path);
std::fs::canonicalize(path).is_ok_and(|real| real.starts_with(root))
}
/// Where the page may go: its own files, or blank.
pub fn navigable(&self, url: &str) -> bool {
url == "about:blank" || Self::holds(&self.root, url)
}
/// What a page loads: its files, data, allowed images.
pub fn loadable(&self, url: &str, image: bool) -> bool {
let pictured = image
&& self
.images
.as_deref()
.is_some_and(|dir| Self::holds(dir, url));
self.navigable(url)
|| url.starts_with("data:")
|| url.starts_with("blob:file://")
|| pictured
}
}
impl Drop for Surface {
fn drop(&mut self) {
log::info!("web: a page's memory is freed");
}
}
/// `%XX` decoded; a file URL's path.
fn decoded(text: &str) -> Vec<u8> {
let bytes = text.as_bytes();
let (mut out, mut at) = (Vec::with_capacity(bytes.len()), 0);
while at < bytes.len() {
let hex = bytes
.get(at + 1..at + 3)
.and_then(|pair| u8::from_str_radix(std::str::from_utf8(pair).ok()?, 16).ok());
match (bytes[at], hex) {
(b'%', Some(byte)) => {
out.push(byte);
at += 3;
}
(byte, _) => {
out.push(byte);
at += 1;
}
}
}
out
}
/// Bytes as URL text, reserved ones escaped.
fn escaped(bytes: &[u8]) -> String {
let mut out = String::new();
for &byte in bytes {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => {
out.push(byte as char)
}
_ => out.push_str(&format!("%{byte:02X}")),
}
}
out
}
/// A path as a file URL, every reserved byte escaped.
pub fn file_url(path: &Path) -> String {
#[cfg(windows)]
return windows_url(&path.to_string_lossy());
#[cfg(not(windows))]
format!("file://{}", escaped(path.as_os_str().as_encoded_bytes()))
}
/// A drive or UNC path as `file:///C:/…` or `file://host/…`.
#[cfg(any(windows, test))]
fn windows_url(path: &str) -> String {
let path = match path.strip_prefix(r"\\?\UNC\") {
Some(rest) => format!(r"\\{rest}"),
None => path.strip_prefix(r"\\?\").unwrap_or(path).to_owned(),
};
let slashed = |text: &str| escaped(text.replace('\\', "/").as_bytes());
match path.strip_prefix(r"\\") {
Some(unc) => {
let (host, rest) = unc.split_once('\\').unwrap_or((unc, ""));
format!("file://{}/{}", slashed(host), slashed(rest))
}
None if path.get(1..2) == Some(":") => {
format!("file:///{}{}", &path[..2], slashed(&path[2..]))
}
None => format!("file:///{}", slashed(&path)),
}
}
/// A decoded file URL's rest as a Windows path.
#[cfg(any(windows, test))]
fn windows_path(rest: &str) -> String {
match rest.strip_prefix('/') {
Some(drive) => drive.replace('/', "\\"),
None => format!(r"\\{}", rest.replace('/', "\\")),
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
fn surface(root: &Path) -> super::Surface {
super::Surface::new(std::fs::canonicalize(root).expect("real"), 1.0)
}
#[test]
fn only_real_files_under_the_root_load() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = dir.path().join("content");
let outside = dir.path().join("outside");
std::fs::create_dir_all(&root).expect("makes");
std::fs::create_dir_all(&outside).expect("makes");
std::fs::write(root.join("index.html"), "x").expect("writes");
std::fs::write(root.join("a #b?.html"), "x").expect("writes");
std::fs::write(outside.join("o.html"), "x").expect("writes");
let surface = surface(&root);
let url = |path: &Path| super::file_url(&std::fs::canonicalize(path).expect("real"));
assert!(surface.navigable(&url(&root.join("index.html"))));
assert!(
surface.navigable(&url(&root.join("a #b?.html"))),
"reserved bytes"
);
assert!(surface.navigable("about:blank"));
assert!(!surface.navigable(&url(&outside.join("o.html"))));
let up = format!("{}/../outside/o.html", super::file_url(&surface.root));
assert!(!surface.navigable(&up));
assert!(!surface.navigable(&format!("{}/missing.html", super::file_url(&surface.root))));
assert!(!surface.navigable("https://example.com/"));
assert!(
!surface.navigable("data:text/html,x")
&& surface.loadable("data:image/png;base64,x", true)
);
assert!(!surface.loadable("http://127.0.0.1:9/pixel", true));
}
#[test]
fn an_image_folder_admits_images_alone() {
let dir = tempfile::tempdir().expect("a temp dir");
let (root, docs) = (dir.path().join("content"), dir.path().join("docs"));
std::fs::create_dir_all(&root).expect("makes");
std::fs::create_dir_all(&docs).expect("makes");
std::fs::write(docs.join("a.png"), "x").expect("writes");
let mut surface = surface(&root);
let picture = super::file_url(&std::fs::canonicalize(docs.join("a.png")).expect("real"));
assert!(
!surface.loadable(&picture, true),
"not before it is allowed"
);
surface.images = Some(std::fs::canonicalize(&docs).expect("real"));
assert!(surface.loadable(&picture, true));
assert!(!surface.loadable(&picture, false), "a script from there");
assert!(!surface.navigable(&picture));
}
#[cfg(unix)]
#[test]
fn a_link_out_of_the_root_is_outside() {
let dir = tempfile::tempdir().expect("a temp dir");
let root = dir.path().join("content");
let outside = dir.path().join("outside");
std::fs::create_dir_all(&root).expect("makes");
std::fs::create_dir_all(&outside).expect("makes");
std::fs::write(outside.join("o.html"), "x").expect("writes");
std::os::unix::fs::symlink(&outside, root.join("link")).expect("links");
let surface = surface(&root);
let through = format!("{}/link/o.html", super::file_url(&surface.root));
assert!(!surface.navigable(&through));
}
#[test]
fn windows_paths_make_valid_file_urls_and_back() {
let cases = [
(r"C:\Users\A b\doc.md", "file:///C:/Users/A%20b/doc.md"),
(r"\\?\C:\notes\doc.md", "file:///C:/notes/doc.md"),
(r"\\server\share\doc.md", "file://server/share/doc.md"),
(r"\\?\UNC\server\share\doc.md", "file://server/share/doc.md"),
];
for (path, url) in cases {
assert_eq!(super::windows_url(path), url, "{path}");
}
assert_eq!(
super::windows_path("/C:/Users/A b/doc.md"),
r"C:\Users\A b\doc.md"
);
assert_eq!(
super::windows_path("server/share/doc.md"),
r"\\server\share\doc.md"
);
}
#[test]
fn percent_escapes_decode_and_bad_ones_stay() {
assert_eq!(super::decoded("a%20b%e4%b8%ad"), "a b中".as_bytes());
assert_eq!(super::decoded("100%"), b"100%");
assert_eq!(super::decoded("%zz"), b"%zz");
assert_eq!(
super::file_url(Path::new("/a b/中#?.html")),
"file:///a%20b/%E4%B8%AD%23%3F.html"
);
}
}
-77
View File
@@ -1,77 +0,0 @@
//! The app's theme as CSS variables on the page's root.
use ely_gpui_component::theme::{ActiveTheme as _, Palette};
use gpui::{App, Hsla, Rgba};
fn css(color: Hsla) -> String {
let rgba = Rgba::from(color);
let byte = |part: f32| (part.clamp(0.0, 1.0) * 255.0).round() as u8;
format!(
"rgba({}, {}, {}, {:.3})",
byte(rgba.r),
byte(rgba.g),
byte(rgba.b),
rgba.a
)
}
/// `--ely-*` names and values, palette then type.
pub fn tokens(cx: &App) -> Vec<(String, String)> {
let theme = cx.theme();
let colors: &Palette = &theme.colors;
let mut out: Vec<(String, String)> = [
("bg", colors.bg),
("surface", colors.surface),
("sunken", colors.sunken),
("hover", colors.hover),
("border", colors.border),
("fg", colors.fg),
("fg-muted", colors.fg_muted),
("fg-subtle", colors.fg_subtle),
("accent", colors.accent),
("on-accent", colors.on_accent),
("focus", colors.focus),
("link", colors.link),
("selection", colors.selection),
("danger", colors.danger),
("warning", colors.warning),
("success", colors.success),
("keyword", colors.syntax.keyword),
("string", colors.syntax.string),
("number", colors.syntax.number),
("comment", colors.syntax.comment),
("function", colors.syntax.function),
("type", colors.syntax.type_name),
("tag", colors.syntax.tag),
("attribute", colors.syntax.attribute),
("punctuation", colors.syntax.punctuation),
]
.into_iter()
.map(|(name, color)| (format!("--ely-{name}"), css(color)))
.collect();
out.push(("--ely-font".into(), format!("\"{}\"", theme.font_family)));
out.push(("--ely-mono".into(), format!("\"{}\"", theme.mono_family)));
out.push(("--ely-font-scale".into(), theme.font_scale.to_string()));
let scheme = if theme.is_dark() { "dark" } else { "light" };
out.push(("color-scheme".into(), scheme.into()));
out
}
/// Script that sets `tokens` on `:root`.
pub fn script(tokens: &[(String, String)]) -> String {
let pairs = serde_json::to_string(tokens).expect("strings serialize");
format!("for (const [k, v] of {pairs}) document.documentElement.style.setProperty(k, v);")
}
#[cfg(test)]
mod tests {
#[test]
fn a_script_sets_each_token_as_json() {
let tokens = vec![("--ely-fg".to_string(), "rgba(1, 2, 3, 1.000)".to_string())];
let script = super::script(&tokens);
assert!(
script.contains(r#"[["--ely-fg","rgba(1, 2, 3, 1.000)"]]"#),
"{script}"
);
}
}
-381
View File
@@ -1,381 +0,0 @@
//! A CEF page drawn by GPUI: frames in, events out.
use std::{
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
use anyhow::{Result, bail};
use cef::{wrapper::message_router::BrowserSideCallback, *};
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
feedback::ErrorView,
primitives::IconName,
};
use gpui::{
AnyElement, App, AppContext as _, Bounds, Context, CursorStyle, ElementInputHandler, Entity,
EventEmitter, FocusHandle, Focusable, Hitbox, HitboxBehavior, InteractiveElement as _,
IntoElement, ParentElement as _, Pixels, Render, RenderImage, Styled as _, Window, canvas, div,
};
use image::{Frame, ImageBuffer, Rgba};
use crate::{
engine::Engine,
surface::{Shared, Surface, file_url},
};
/// A page's answer to one `elyQuery`.
#[derive(Clone)]
pub struct Reply(Arc<Mutex<dyn BrowserSideCallback>>);
impl Reply {
pub fn ok(&self, text: &str) {
self.0.lock().expect("the reply lock").success_str(text);
}
pub fn fail(&self, message: &str) {
self.0.lock().expect("the reply lock").failure(1, message);
}
}
/// What the page tells its owner.
pub enum Event {
Loaded,
Query(String, Reply),
/// A link to leave by; the owner decides.
Leave(String),
/// The page cannot show: load, start or run failed.
Failed(String),
/// A PDF print ended: where, and whether it worked.
Printed(PathBuf, bool),
}
pub struct WebView {
pub(crate) surface: Shared,
image: Option<Arc<RenderImage>>,
/// Replaced frames, freed from GPUI's atlas next paint.
stale: Vec<Arc<RenderImage>>,
pub(crate) focus: FocusHandle,
pub(crate) bounds: Bounds<Pixels>,
pub(crate) composing: Option<std::ops::Range<usize>>,
/// The key whose text the input handler brings next.
pub(crate) typing: Option<cef::KeyEvent>,
url: String,
/// Shown instead of the page until reloaded.
fault: Option<String>,
/// Takes a paste before the page; true when it did.
pub(crate) paste: Option<Paste>,
}
/// Takes a paste before the page; true when it did.
pub(crate) type Paste = std::rc::Rc<dyn Fn(&mut Window, &mut App) -> bool>;
impl EventEmitter<Event> for WebView {}
impl Focusable for WebView {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus.clone()
}
}
impl WebView {
/// Its frames, for a closing owner to free.
pub fn retire_all(&mut self) -> ely_gpui_component::primitives::Retired {
let mut images = ely_gpui_component::primitives::Retired::default();
for image in self.image.take().into_iter().chain(self.stale.drain(..)) {
images.push(image);
}
images
}
/// Opens `page`, a file in `root`, in a new browser.
pub fn open(page: &Path, root: &Path, window: &mut Window, cx: &mut App) -> Result<Entity<Self>> {
Self::opened(page, root, true, window, cx)
}
/// Opens `page` with its scripts off: shown, never run.
pub fn open_inert(page: &Path, root: &Path, window: &mut Window, cx: &mut App) -> Result<Entity<Self>> {
Self::opened(page, root, false, window, cx)
}
fn opened(
page: &Path,
root: &Path,
scripts: bool,
window: &mut Window,
cx: &mut App,
) -> Result<Entity<Self>> {
if !cx.has_global::<Engine>() {
bail!("the web engine has not started");
}
let root = std::fs::canonicalize(root)?;
let page = std::fs::canonicalize(page)?;
let mut surface = Surface::new(root, window.scale_factor());
surface.scripts = scripts;
let surface = Arc::new(Mutex::new(surface));
let url = file_url(&page);
if !surface.lock().expect("the surface lock").navigable(&url) {
bail!("{} is outside its content folder", page.display());
}
log::info!("web: a browser asked for {url}");
crate::engine::ask_browser(surface.clone(), url.clone());
let view = cx.new(|cx| WebView {
surface,
image: None,
stale: Vec::new(),
focus: cx.focus_handle(),
bounds: Bounds::default(),
composing: None,
typing: None,
url,
fault: None,
paste: None,
});
crate::input::follow_focus(&view, window, cx);
view.update(cx, |_, cx| {
cx.observe_global::<ely_gpui_component::theme::Theme>(|view, cx| view.theme(cx))
.detach();
});
cx.global_mut::<Engine>().views.push(view.downgrade());
Ok(view)
}
/// The browser, on the pump's next turn, outside any update.
pub(crate) fn on_browser(&self, work: impl FnOnce(&Browser) + 'static) {
let surface = self.surface.clone();
crate::engine::later(move || {
// Not held while CEF runs: its callbacks lock it too.
let browser = surface.lock().expect("the surface lock").browser.clone();
if let Some(browser) = browser {
work(&browser);
}
});
}
pub(crate) fn on_host(&self, work: impl FnOnce(&BrowserHost) + 'static) {
self.on_browser(move |browser| {
if let Some(host) = browser.host() {
work(&host);
}
});
}
/// Lets images under `dir` load; one folder at a time.
pub fn allow_images(&self, dir: &Path) -> Result<()> {
let dir = std::fs::canonicalize(dir)?;
log::info!("web: images may come from {}", dir.display());
self.surface.lock().expect("the surface lock").images = Some(dir);
Ok(())
}
/// `take` sees pastes first; true keeps them from the page.
pub fn intercept_paste(&mut self, take: impl Fn(&mut Window, &mut App) -> bool + 'static) {
self.paste = Some(std::rc::Rc::new(take));
}
/// Runs script in the page's main frame.
pub fn run_js(&self, code: &str) {
let code = CefString::from(code);
self.on_browser(move |browser| {
if let Some(frame) = browser.main_frame() {
frame.execute_java_script(Some(&code), None, 0);
}
});
}
/// The app's colors and fonts, into the page.
fn theme(&mut self, cx: &mut Context<Self>) {
self.run_js(&crate::tokens::script(&crate::tokens::tokens(cx)));
}
/// Zoom as a factor; CEF counts in 1.2 steps.
pub fn set_zoom(&self, factor: f64) {
let level = factor.ln() / 1.2f64.ln();
self.on_host(move |host| host.set_zoom_level(level));
}
/// Takes what the handlers left; says if anything came.
fn take(&mut self, cx: &mut Context<Self>) {
let (frame, events) = {
let mut surface = self.surface.lock().expect("the surface lock");
let mut events = Vec::new();
if std::mem::take(&mut surface.loaded) {
events.push(Event::Loaded);
}
for (request, callback) in surface.queries.drain(..) {
events.push(Event::Query(request, Reply(callback)));
}
events.extend(surface.leaving.drain(..).map(Event::Leave));
events.extend(
surface
.printed
.drain(..)
.map(|(path, ok)| Event::Printed(path, ok)),
);
events.extend(surface.failed.take().map(Event::Failed));
if let Some(why) = crate::engine::failed().filter(|_| !surface.closed) {
surface.closed = true;
events.push(Event::Failed(why));
}
(surface.frame.take(), events)
};
if let Some((width, height, bgra)) = frame {
match ImageBuffer::<Rgba<u8>, _>::from_raw(width, height, bgra) {
Some(picture) => {
let image = Arc::new(RenderImage::new([Frame::new(picture)]));
self.stale.extend(self.image.replace(image));
cx.notify();
}
None => log::error!("web: a {width}×{height} frame was short"),
}
}
for event in events {
match &event {
// Each load starts unthemed; the tokens go in first.
Event::Loaded => {
self.fault = None;
self.theme(cx);
}
Event::Failed(why) => {
log::error!("web: {} failed: {why}", self.url);
self.fault = Some(why.clone());
}
_ => {}
}
cx.notify();
cx.emit(event);
}
}
/// Follows the room GPUI gave; CEF resizes to it.
fn place(&mut self, bounds: Bounds<Pixels>, scale: f32) {
self.bounds = bounds;
let size = (f32::from(bounds.size.width), f32::from(bounds.size.height));
let (resized, rescaled) = {
let mut surface = self.surface.lock().expect("the surface lock");
let changed = (surface.size != size, surface.scale != scale);
(surface.size, surface.scale) = (size, scale);
changed
};
if resized || rescaled {
self.on_host(move |host| {
if rescaled {
host.notify_screen_info_changed();
}
host.was_resized();
});
}
}
/// Tries the page again; a lost browser is made anew.
pub fn reload(&mut self, cx: &mut Context<Self>) {
self.fault = None;
let made = self
.surface
.lock()
.expect("the surface lock")
.browser
.is_some();
match made {
true => self.on_browser(|browser| browser.reload()),
false => crate::engine::ask_browser(self.surface.clone(), self.url.clone()),
}
cx.notify();
}
/// The fault, with a way to try again.
fn fault_view(&self, why: &str, cx: &mut Context<Self>) -> AnyElement {
let this = cx.weak_entity();
let title = ely_ui::i18n::text(cx, "web.failed", &[]);
let again = Button::new("web-reload", ely_ui::i18n::text(cx, "web.reload", &[]))
.variant(ButtonVariant::Primary)
.on_click(move |_, window, cx| {
// The page, not the gone button, takes keys.
this.update(cx, |view, cx| {
view.reload(cx);
window.focus(&view.focus, cx);
})
.ok();
});
let view =
ErrorView::new("web-failed", IconName::TriangleAlert, title).detail(why.to_string());
// Without a running engine nothing can try again.
match crate::engine::failed() {
Some(_) => view.into_any_element(),
None => view.action(again).into_any_element(),
}
}
fn cursor(&self) -> CursorStyle {
match self.surface.lock().expect("the surface lock").cursor {
CursorType::HAND => CursorStyle::PointingHand,
CursorType::IBEAM => CursorStyle::IBeam,
CursorType::CROSS => CursorStyle::Crosshair,
CursorType::EASTWESTRESIZE | CursorType::COLUMNRESIZE => CursorStyle::ResizeLeftRight,
CursorType::NORTHSOUTHRESIZE | CursorType::ROWRESIZE => CursorStyle::ResizeUpDown,
CursorType::NOTALLOWED => CursorStyle::OperationNotAllowed,
_ => CursorStyle::Arrow,
}
}
}
impl Drop for WebView {
fn drop(&mut self) {
self.surface.lock().expect("the surface lock").closed = true;
self.on_host(|host| host.close_browser(true as _));
}
}
/// After each pump: every live view takes its news.
pub(crate) fn deliver(cx: &mut App) {
let views = std::mem::take(&mut cx.global_mut::<Engine>().views);
let alive: Vec<_> = views
.into_iter()
.filter(|view| view.update(cx, |view, cx| view.take(cx)).is_ok())
.collect();
cx.global_mut::<Engine>().views.extend(alive);
}
impl Render for WebView {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
if let Some(why) = self.fault.clone() {
return div()
.size_full()
.child(self.fault_view(&why, cx))
.into_any_element();
}
let this = cx.entity();
let paint = this.clone();
let element = canvas(
move |bounds, window, cx| {
let scale = window.scale_factor();
this.update(cx, |view, _| view.place(bounds, scale));
window.insert_hitbox(bounds, HitboxBehavior::Normal)
},
move |bounds, hitbox: Hitbox, window, cx| {
let (image, stale, cursor, focus) = paint.update(cx, |view, _| {
let stale = std::mem::take(&mut view.stale);
(view.image.clone(), stale, view.cursor(), view.focus.clone())
});
for old in stale {
if let Err(error) = window.drop_image(old) {
log::error!("web: an old frame stayed: {error}");
}
}
window.set_cursor_style(cursor, &hitbox);
window.handle_input(&focus, ElementInputHandler::new(bounds, paint.clone()), cx);
let Some(image) = image else { return };
let painted =
window.paint_image(bounds, bounds, Default::default(), image, 0, false);
if let Err(error) = painted {
log::error!("web: a frame was not painted: {error}");
}
},
)
.size_full();
crate::input::listen(div().size_full(), cx)
.track_focus(&self.focus)
.child(element)
.into_any_element()
}
}
+3 -3
View File
@@ -8,7 +8,7 @@
| --- | --- | --- |
| 键盘完成打开、编辑、保存、搜索与主要工具 | 共用命令(`ely_ui::commands`:新建、打开、保存、另存为、导出、打印、关闭标签 Cmd+W、命令面板、设置),各应用的查找(Cmd+F)与模式键;`shell.sh`、`doc-shell.sh`、`mark-tools.sh` 以按键驱动 | 拖动类操作(图片手柄、透视表字段、PDF 区域标记)没有键盘替代 |
| 焦点框不依赖悬停 | 组件库可按元素都是 Tab 停靠点并带 `focus_ring`(7fab072 起 git 行带透明边框使焦点框可见) | — |
| 内容区与界面间焦点可预测 | Tab 顺序导航→内容→属性→状态(C05.4,`shell.sh`);浮层关闭后焦点回到打开前之处(组件库 8658999);文档(LOK 视图)与网页(ElyMark)把 Tab 留给自己(制表符、跳单元格、缩进),F6 与 Shift+F6 照 Tab 的次序移出与移回(组件库 029632b;原先没有键盘出口) | CEF 页面内的焦点顺序由页面决定 |
| 内容区与界面间焦点可预测 | Tab 顺序导航→内容→属性→状态(C05.4,`shell.sh`);浮层关闭后焦点回到打开前之处(组件库 8658999);文档(LOK 视图)把 Tab 留给自己(制表符、跳单元格、缩进),F6 与 Shift+F6 照 Tab 的次序移出与移回(组件库 029632b;原先没有键盘出口) | ElyMark 原生组件的焦点次序待 C14.16 核对 |
| 对比度 4.5:1 | 主题与 Omarchy 色板按组件库 `A11yChecker` 规则收口(正文、状态色 4.5:1,焦点与图表色 3:1),核对不过的主题整套不用(C05.1) | 文档内容的颜色取自文件,照原样显示 |
## 辅助技术
@@ -16,7 +16,7 @@
| 要求 | 依据 | 缺口 |
| --- | --- | --- |
| 控件有角色与名称 | 组件库核心控件、对话框、列表、树、提交与差异行、设置行(组与说明)、提示(Toast 有角色与名称);真窗口测试都经 AX 树操作(`ax-values`、`ax-press`) | `lists::Outline` 与 `feedback::Alert` 无角色(上游待办);gpui 无 aria-disabled,以说明文字表达 |
| 文档内容可达 | LOK 视图把当前段落(Calc 为当前单元格与输入行)以 `MultilineTextInput` 交给 AX(`lokview.sh` 读到键入后的段落) | gpui 没有文本选区与插入点的 AX:选区、插入点、整篇文档到不了辅助技术;ElyMark 与内容宿主的页面不进 AX 树(CEF 离屏);PDF 页面文字未接 AX |
| 文档内容可达 | LOK 视图把当前段落(Calc 为当前单元格与输入行)以 `MultilineTextInput` 交给 AX(`lokview.sh` 读到键入后的段落) | gpui 没有文本选区与插入点的 AX:选区、插入点、整篇文档到不了辅助技术;ElyMark 原生组件的 AX 待 C14.16 核对;PDF 页面文字未接 AX |
| 编辑状态可达 | 保存状态、风险、引擎停止以提示条与状态栏文字表达 | 状态栏文字是否进 AX 树未逐项核对 |
## 国际化
@@ -24,7 +24,7 @@
| 要求 | 依据 | 缺口 |
| --- | --- | --- |
| 简中与英文界面 | `ely_ui::i18n` 缺键即失败;组件库面板族、对话框、批注栏等内建中英目录;设置可切语言(下次启动生效) | 组件库 `SortBuilder`、`FilterBuilder`、欢迎页等文案仍为英文(上游待办);ElyTable 选区统计以外的内核文字为英文 |
| 中英文输入、组合输入、标点 | LOK 视图与 CEF 页面都经输入法回调处理组字(`lokview.sh`、`webview.sh` 组“你好”);往返用例含中文(`independent.rs`) | 系统拼音输入法实机只做过人工检查,测试以输入法回调模拟 |
| 中英文输入、组合输入、标点 | LOK 视图与 ElyMark 的编辑器都经输入法回调处理组字(`lokview.sh` 组“你好”;组件库的文档测试);往返用例含中文(`independent.rs`) | 系统拼音输入法实机只做过人工检查,测试以输入法回调模拟 |
| 混合字体 | 内核按字体回退排版;缺失字体在 ElyDoc 页面上方列出 | 字体缺失环境的分页对照未做(见 `docs/compatibility.md`) |
| 非 ASCII 路径 | `paths_keep_spaces_cjk_and_case`(保存);启动参数按当前目录取绝对路径(C20.1 修) | — |
| 日期、数字、纸张用显式区域 | 内核载入选项固定 `Language=en-US`(`ely-lok/src/worker.rs` 的 `LOAD_OPTIONS`),公式为 Excel A1 语法;纸张取自文档本身并原样打印(C19.2) | — |
+4 -4
View File
@@ -3,7 +3,7 @@
随发行说明发布,每个版本一份。支持某格式不等于支持它的全部功能;下表只写已验证的内容、已知限制与保存行为。
版本:Ely Office 0.1.3(首版六个应用:ElyDoc、ElyTable、ElyShow、ElyPDF、ElyTree、ElyMark)。日期 2026-10-11。
内核:LibreOffice 26.2.6.3(8221e31,加 `patches/libreoffice` 0001–0041);MuPDF 1.28.5(8ad45e9,加 `patches/mupdf` 0001–0005);Git 2.56.0(a018953)与 git-lfs 3.8.0;CEF 154.0.32。
内核:LibreOffice 26.2.6.3(8221e31,加 `patches/libreoffice` 0001–0041);MuPDF 1.28.5(8ad45e9,加 `patches/mupdf` 0001–0005);Git 2.56.0(a018953)与 git-lfs 3.8.0。
验证机:macOS 27.0 arm64。Windows 与 Linux 未验证。本机没有 Microsoft Office:与 Office 的交叉检查未做(待做)。
## 往返验证
@@ -66,9 +66,9 @@
| | 格式 |
| --- | --- |
| 编辑与保存 | MD、Markdown(UTF-8,带不带 BOM;保留换行风格;未改动的块逐字节照原文写回) |
| 导入或受限 | UTF-16 与非 UTF-8 拒绝打开并指出字节位置;Milkdown 不支持的语法以原文块保留;超过 2 MiB 只以源码打开 |
| 输出 | HTML(单文件或带资源文件夹)、PDF、纯文本、打印(macOS) |
| 编辑与保存 | MD、Markdown(UTF-8,带不带 BOM;保留换行风格;文本即原文,只写回所改的字) |
| 导入或受限 | UTF-16 与非 UTF-8 拒绝打开并指出字节位置;markdown-rs 不认的语法照原文显示;超过 2 MiB 只以源码打开 |
| 输出 | 原生重做中(C14.14),此版没有 |
## 第二批
+1 -1
View File
@@ -19,7 +19,7 @@
- 有未保存改动时,每 10 秒在本机数据目录写一个恢复点(加密的文档其恢复点也加密),只留在本机,不上传。
- 应用崩溃后再启动,未保存的内容以未命名副本打开,原文件不动;只恢复内容,光标与撤销历史不恢复。
- 文档引擎单独停止时,窗口上方写明原因与最近一次恢复点的时间,“重新打开文档”起新引擎并逐个重开:有改动的从恢复点、无改动的从文件。
- 设置“存储”里可看日志、网页缓存、恢复副本的大小,选每个文档保留几个恢复点与多少空间,并清除它们(先问)。
- 设置“存储”里可看日志、恢复副本的大小,选每个文档保留几个恢复点与多少空间,并清除它们(先问)。
## 离线边界
+2 -3
View File
@@ -10,7 +10,7 @@ Ely Office 的自有代码以 AGPL-3.0-or-later 发布,全文在仓库根的 `
| 文件 | 内容 | 生成 |
| --- | --- | --- |
| `ElyOffice-<版本>-source.tar.gz` | 自有源码、`Cargo.lock`、`rust-toolchain.toml`、ElyMark 网页的 `package-lock.json`、上游补丁(`patches/libreoffice`、`patches/mupdf`)、构建与取上游的脚本(各上游钉在提交,脚本里写明地址与散列)、测试、文档 | `packaging/macos/release.sh` |
| `ElyOffice-<版本>-source.tar.gz` | 自有源码、`Cargo.lock`、`rust-toolchain.toml`、上游补丁(`patches/libreoffice`、`patches/mupdf`)、构建与取上游的脚本(各上游钉在提交,脚本里写明地址与散列)、测试、文档 | `packaging/macos/release.sh` |
| `ElyOffice-<版本>-upstream-source.tar.gz` | 实际编译的 MuPDF 源码(钉住的提交加我们的补丁,AGPL)与随包 Git 的源码(v2.56.0,GPLv2) | 同上;`tests/scripts/release-macos.sh` 核对其中是打过补丁的 MuPDF |
| 各应用包 `Resources/Licenses/` | Ely 的 AGPL 全文;`rust-crates.txt`(链进该应用各二进制的全部 crate 与其许可文件,无许可文件的取标准文本并写明作者);所带引擎的许可与通知(见下) | `packaging/licenses.sh`,缺任何一份即打包失败 |
@@ -26,8 +26,7 @@ Ely Office 的自有代码以 AGPL-3.0-or-later 发布,全文在仓库根的 `
| Great Vibes 字体 | OFL-1.1 | OFL | PDF |
| Git 2.56.0 | GPL-2.0(部分 LGPL-2.1) | COPYING、LGPL-2.1;源码在上游源码包 | Tree(Mark 使用同级 Tree 包内的 Git) |
| git-lfs 3.8.0 | MIT | LICENSE.md(按标签与散列取得) | Tree |
| CEF 154 / Chromium | BSD 及其依赖各自许可 | CEF LICENSE、Chromium CREDITS | Mark |
| Milkdown、CodeMirror、KaTeX、Mermaid 等网页依赖 | 各自许可(多为 MIT) | `THIRD-PARTY-NOTICES.txt`,按打进包的每个包取其许可文件 | Mark |
| KaTeX 字体(随 RaTeX 嵌入) | OFL-1.1 | OFL、FONT_NOTICE | Mark |
| 媒体播放 | 系统框架(AVFoundation),不随包 | — | Show |
## 未尽之处
+1 -1
View File
@@ -12,6 +12,6 @@
| repo | 10 万个提交 | PERF-06 |
| notes.md | 1 MB | PERF-07 |
计时从启动进程起,到日志里出现标记为止(脚本每 10 毫秒读一次日志):PERF-01 到首帧(`ELY_SMOKE=1` 打印 `ready`),首次运行(新的用户目录)与日常启动分记,每次等上一次的进程(含仍在启动的内核)退出后再测;Doc、Table、Show 另记空白文档就绪(`opened Untitled`),它要等内核启动;PERF-03、04 到第一块图块真正画到窗口上(`lok: first tile drawn`,在 `paint_image` 成功之后记;其间内核画图块失败或窗口画图块失败即整次作废),PERF-03 之前先核对样本经内核排版恰为 100 页(`lok: 100 pages`);PERF-05 到第一页的第一块画到窗口上(`pdf: first tile drawn, page 0`;画失败即整次作废);PERF-06 的历史首屏从按下“历史”到显示(`history shows`),查询从按下回车到结果(`searched true`),性能要求的指标是后者;PERF-07 到可视编辑器显示(`mark: shown as visual`)。PERF-02 为 ElyDoc 每次按键到显示该编辑的图块(`lok: edit to tile`,每键须各有一条,缺即失败),并以 `ELY_STALLS=1` 记下主线程连续忙于 100 毫秒以上的次数(`main thread was busy`)。内存为应用及其全部子进程(内核 worker、CEF 辅助进程)的 RSS 之和。PERF-08 为 ElyDoc 新建再关闭文档 40 次:循环前记下基线(总内存、全部句柄的类型与名称、内核中的文档数 `lok-worker: N documents open`),每次关闭后等内核回到基线文档数(等不到或没有这条日志即失败),再按身份比对句柄:比基线多出的只许是 `/System/Library/` 下的只读系统资源(第一次打开模板对话框时一次性载入两个系统字体与 AppKit 的两个本地化表),且此后每次都相同;多出任何别的句柄即失败。记录写基线与每次的内存、句柄数、文档数。
计时从启动进程起,到日志里出现标记为止(脚本每 10 毫秒读一次日志):PERF-01 到首帧(`ELY_SMOKE=1` 打印 `ready`),首次运行(新的用户目录)与日常启动分记,每次等上一次的进程(含仍在启动的内核)退出后再测;Doc、Table、Show 另记空白文档就绪(`opened Untitled`),它要等内核启动;PERF-03、04 到第一块图块真正画到窗口上(`lok: first tile drawn`,在 `paint_image` 成功之后记;其间内核画图块失败或窗口画图块失败即整次作废),PERF-03 之前先核对样本经内核排版恰为 100 页(`lok: 100 pages`);PERF-05 到第一页的第一块画到窗口上(`pdf: first tile drawn, page 0`;画失败即整次作废);PERF-06 的历史首屏从按下“历史”到显示(`history shows`),查询从按下回车到结果(`searched true`),性能要求的指标是后者;PERF-07 到可视编辑器显示(`mark: shown as visual`)。PERF-02 为 ElyDoc 每次按键到显示该编辑的图块(`lok: edit to tile`,每键须各有一条,缺即失败),并以 `ELY_STALLS=1` 记下主线程连续忙于 100 毫秒以上的次数(`main thread was busy`)。内存为应用及其全部子进程(内核 worker)的 RSS 之和。PERF-08 为 ElyDoc 新建再关闭文档 40 次:循环前记下基线(总内存、全部句柄的类型与名称、内核中的文档数 `lok-worker: N documents open`),每次关闭后等内核回到基线文档数(等不到或没有这条日志即失败),再按身份比对句柄:比基线多出的只许是 `/System/Library/` 下的只读系统资源(第一次打开模板对话框时一次性载入两个系统字体与 AppKit 的两个本地化表),且此后每次都相同;多出任何别的句柄即失败。记录写基线与每次的内存、句柄数、文档数。
记录开头的 JSON 写机型、CPU、核数、内存、GPU、系统、系统字体文件数、各应用发行二进制的散列与样本散列。冷启动未清系统文件缓存(需要管理员权限),“日常启动”即文件缓存已热时的启动。
+4 -3
View File
@@ -21,7 +21,6 @@ put . "$root/LICENSE"
case $slug in
ely-doc | ely-table | ely-show) crates=(ely-lok) ;;
ely-pdf) crates=(ely-pdf-engine) ;;
ely-mark) crates=(ely-web-content) ;;
*) crates=() ;;
esac
python3 "$root/packaging/crates.py" "$slug" ${crates[@]+"${crates[@]}"} >"$out/rust-crates.txt"
@@ -81,8 +80,10 @@ case $slug in
put GreatVibes "$root/crates/ely-pdf-engine/fonts/OFL.txt"
;;
ely-mark)
put CEF "$root/reference/cef/LICENSE.txt" "${ELY_CEF:-$root/engines/cef}/CREDITS.html"
put content "$root/engines/content/ely-mark/THIRD-PARTY-NOTICES.txt"
# RaTeX embeds KaTeX's fonts.
katex=$(cargo metadata --format-version 1 --locked --manifest-path "$root/Cargo.toml" |
python3 -c 'import json, pathlib, sys; print(next(pathlib.Path(p["manifest_path"]).parent for p in json.load(sys.stdin)["packages"] if p["name"] == "ratex-katex-fonts"))')
put KaTeX "$katex/fonts/OFL.txt" "$katex/fonts/FONT_NOTICE.txt"
;;
esac
# ElyTree carries Git; on Windows and Linux ElyMark does too.
+1 -12
View File
@@ -106,20 +106,9 @@ for helper in ely-lok-worker ely-pdf-worker; do
deployed+=(--executable "$bin/$helper")
fi
done
if [ -f "$bin/libcef.so" ]; then
# Binaries here load from usr/lib; CEF's data sits with libcef.
cef=${ELY_CEF:-$root/engines/cef}
mkdir -p "$appdir/usr/lib"
for item in "$bin"/*; do
if [ -e "$cef/${item##*/}" ]; then
mv "$item" "$appdir/usr/lib/"
fi
done
deployed+=(--library "$appdir/usr/lib/libcef.so")
fi
# linuxdeploy bundles libraries and fails on a missing one.
LD_LIBRARY_PATH=$appdir/usr/lib "$deploy" --appimage-extract-and-run --appdir "$appdir" "${deployed[@]}" \
"$deploy" --appimage-extract-and-run --appdir "$appdir" "${deployed[@]}" \
--desktop-file "$work/$slug.desktop" --icon-file "$icon"
mkdir -p "$dist"
+3 -57
View File
@@ -66,7 +66,7 @@ if uses ely-lok; then
echo "bundle.sh: $binary is not code; Frameworks holds only code" >&2
exit 1
fi
done < <(find "$app/Contents/Frameworks" -type f -not -path "*/Chromium Embedded Framework.framework/*")
done < <(find "$app/Contents/Frameworks" -type f)
fi
if uses ely-pdf-engine; then
tessdata=${ELY_TESSDATA:-$root/engines/tessdata}
@@ -84,53 +84,6 @@ if [ "$slug" = ely-tree ]; then
file -b "$binary" | grep -q '^Mach-O' && inner+=("$binary")
done < <(find "$app/Contents/Resources/git" -type f -perm -111)
fi
# Web content apps carry CEF and their bundled pages.
framework=
helpers=()
if grep -q '^ely-web-content' "$root/apps/$slug/Cargo.toml"; then
cef=${ELY_CEF:-$root/engines/cef}
[ -d "$cef/Chromium Embedded Framework.framework" ] || {
echo "bundle.sh: no CEF at $cef; run scripts/fetch-cef.sh" >&2
exit 1
}
mkdir -p "$app/Contents/Frameworks"
framework="$app/Contents/Frameworks/Chromium Embedded Framework.framework"
cp -R "$cef/Chromium Embedded Framework.framework" "$framework"
case $profile in
release) cargo build --manifest-path "$root/Cargo.toml" --release -p ely-web-content --bin ely-web-helper ;;
debug) cargo build --manifest-path "$root/Cargo.toml" -p ely-web-content --bin ely-web-helper ;;
esac
# Chromium looks for these five, named after the app's binary.
for kind in "" " (GPU)" " (Renderer)" " (Plugin)" " (Alerts)"; do
helper="$slug Helper$kind"
contents="$app/Contents/Frameworks/$helper.app/Contents"
mkdir -p "$contents/MacOS"
cp "$root/target/$profile/ely-web-helper" "$contents/MacOS/$helper"
id=$(echo "${slug#ely-}.helper$kind" | tr -d '()' | tr ' ' '.' | tr 'A-Z' 'a-z')
cat >"$contents/Info.plist" <<HELPER
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key><string>$helper</string>
<key>CFBundleIdentifier</key><string>$prefix.$id</string>
<key>CFBundleName</key><string>$helper</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>$version</string>
<key>LSMinimumSystemVersion</key><string>14.0</string>
<key>LSUIElement</key><string>1</string>
</dict>
</plist>
HELPER
helpers+=("$app/Contents/Frameworks/$helper.app")
done
content=$root/engines/content/$slug
[ -f "$content/index.html" ] || {
echo "bundle.sh: no page at $content; run its scripts/build-*-content.sh" >&2
exit 1
}
cp -R "$content" "$app/Contents/Resources/content"
fi
"$root/packaging/licenses.sh" "$slug" "$app/Contents/Resources/Licenses"
cat >"$app/Contents/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
@@ -153,18 +106,11 @@ $("$root/packaging/macos/documents.sh" "$slug")
</plist>
PLIST
# Nested code is signed before the app that holds it.
for target in ${inner[@]+"${inner[@]}"} ${framework:+"$framework"} ${helpers[@]+"${helpers[@]}"} "$app"; do
for target in ${inner[@]+"${inner[@]}"} "$app"; do
if [ "$identity" = - ]; then
codesign --force --sign - "$target"
else
# Hardened runtime: V8 in CEF's helpers needs its entitlements.
entitled=()
case $target in
*" Helper (Renderer).app") entitled=(--entitlements "$root/packaging/macos/entitlements/renderer.plist") ;;
*" Helper (GPU).app") entitled=(--entitlements "$root/packaging/macos/entitlements/gpu.plist") ;;
*" Helper (Plugin).app") entitled=(--entitlements "$root/packaging/macos/entitlements/plugin.plist") ;;
esac
codesign --force --options runtime --timestamp ${entitled[@]+"${entitled[@]}"} --sign "$identity" "$target"
codesign --force --options runtime --timestamp --sign "$identity" "$target"
fi
done
echo "$app"
-7
View File
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key><true/>
</dict>
</plist>
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key><true/>
<key>com.apple.security.cs.disable-library-validation</key><true/>
</dict>
</plist>
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key><true/>
</dict>
</plist>
-11
View File
@@ -65,16 +65,5 @@ if [ "$slug" = ely-tree ] || [ "$slug" = ely-mark ]; then
need "$root/engines/git" "scripts/build-git.sh and scripts/fetch-git-lfs.sh"
cp -R "$root/engines/git" "$out/git"
fi
if uses ely-web-content; then
cef=${ELY_CEF:-$root/engines/cef}
need "$cef/icudtl.dat" scripts/fetch-cef.sh
# CEF's runtime sits beside the app, as cef-dll-sys has it.
find "$cef" -maxdepth 1 -type f \( -name '*.dll' -o -name '*.so' -o -name '*.so.*' -o -name '*.pak' \
-o -name '*.bin' -o -name '*.dat' -o -name '*.json' -o -name chrome-sandbox \) -exec cp {} "$out/" \;
cp -R "$cef/locales" "$out/"
content=$root/engines/content/$slug
need "$content/index.html" "its scripts/build-*-content.sh"
cp -R "$content" "$out/content"
fi
"$root/packaging/licenses.sh" "$slug" "$out/licenses"
echo "$out"
-10
View File
@@ -1,10 +0,0 @@
#!/usr/bin/env bash
# Builds ElyMark's page, pinned by the lockfile, into engines/content.
set -euo pipefail
root=$(cd "$(dirname "$0")/.." && pwd)
web=$root/apps/ely-mark/web
cd "$web"
# Install scripts never run; esbuild's binary is its own package.
npm ci --ignore-scripts --no-audit --no-fund
node build.mjs "$root/engines/content/ely-mark"
-2
View File
@@ -40,11 +40,9 @@ if [ -n "${ELY_SHARE:-}" ]; then
else
scripts/fetch-references.sh
${inside[@]+"${inside[@]}"} scripts/build-mupdf.sh
${inside[@]+"${inside[@]}"} scripts/fetch-cef.sh
${inside[@]+"${inside[@]}"} scripts/build-git.sh
${inside[@]+"${inside[@]}"} scripts/fetch-git-lfs.sh
${inside[@]+"${inside[@]}"} scripts/fetch-tessdata.sh
${inside[@]+"${inside[@]}"} scripts/build-mark-content.sh
${inside[@]+"${inside[@]}"} scripts/build-libreoffice.sh
fi
+1 -1
View File
@@ -4,5 +4,5 @@ set -euo pipefail
root=$(cd "$(dirname "$0")/.." && pwd)
cd "$root"
find scripts/fetch-references.sh scripts/apply-lo-patches.sh scripts/build-libreoffice.sh scripts/build-mupdf.sh \
scripts/build-git.sh scripts/fetch-git-lfs.sh scripts/fetch-tessdata.sh scripts/fetch-cef.sh \
scripts/build-git.sh scripts/fetch-git-lfs.sh scripts/fetch-tessdata.sh \
packaging/libreoffice patches -type f | LC_ALL=C sort | xargs shasum -a 256 | shasum -a 256 | cut -c1-12
-15
View File
@@ -1,15 +0,0 @@
#!/usr/bin/env bash
# CEF's minimal build at cef-rs's pin, SHA-1 checked, into engines/cef.
set -euo pipefail
root=$(cd "$(dirname "$0")/.." && pwd)
[ -d "$root/reference/cef-rs" ] || { echo "fetch-cef: run scripts/fetch-references.sh first" >&2; exit 1; }
# Not the system temp: Windows may block programs there.
mkdir -p "$root/engines"
work=$(mktemp -d "$root/engines/cef-export.XXXXXX")
trap 'rm -rf "$work"' EXIT
cd "$root/reference/cef-rs"
# The exporter checks each archive against CEF's own index.
CARGO_TARGET_DIR="$work" cargo run -q -p export-cef-dir -- --force "$root/engines/cef"
rm -f "$root"/engines/cef_binary_*.tar.bz2
cat "$root/engines/cef/archive.json"
-4
View File
@@ -48,10 +48,6 @@ fetch tesseract https://github.com/tesseract-ocr/tesseract.git db0ec62f81b0737fb
fetch mailcore2 https://github.com/MailCore/mailcore2.git 1b48bab20b2492fa8ed3965abcbbe099d4ce9d92
git -C "$ref/mailcore2" submodule update --init --depth 1 --recursive
fetch libetpan https://github.com/dinhviethoa/libetpan.git 4d478625c4c43721191ae1bd7aed387a66c28ec0
fetch cef https://github.com/chromiumembedded/cef.git ff57d4eae16d36457895f2de115a71d502e85a08
fetch cef-rs https://github.com/tauri-apps/cef-rs.git a712ad9bacd234c050631a95b9745b27d346b7cc
fetch milkdown https://github.com/Milkdown/milkdown.git bbb8bcbbb8ce0aa16f92567c23f6236747be9b3b
fetch codemirror-dev https://github.com/codemirror/dev.git c010426d06689a7115aa9df08425126b6d3ead2f
fetch marktext https://github.com/marktext/marktext.git b29cb5621e12cedb801c1cd886d9005159feaa2e
fetch colamd https://github.com/marswaveai/ColaMD.git 5e0f3c3584bb771180afe1a4670efbaf28dee65b
fetch magpie https://github.com/yetone/magpie.git e4c8316e3fa9d73683079df3f9142561265bc1bb
+3 -11
View File
@@ -10,17 +10,7 @@ fail() {
exit 1
}
check() { python3 "$root/tests/scripts/comments.py" "$work" >/dev/null 2>&1; }
mkdir -p "$work/apps/a/web/src" "$work/tests/scripts"
printf '// One short line.\nconst a = 1\n' >"$work/apps/a/web/src/a.js"
check || fail "a short comment failed"
printf '/* A comment\n spanning lines. */\n' >"$work/apps/a/web/src/a.js"
! check || fail "a multi-line JS block comment passed"
printf 'const a = 1 /* A comment\n spanning lines. */\n' >"$work/apps/a/web/src/a.js"
! check || fail "an inline multi-line JS block comment passed"
printf '// One two three four five six seven eight nine ten eleven.\n' >"$work/apps/a/web/src/a.js"
! check || fail "an eleven-word JS comment passed"
rm "$work/apps/a/web/src/a.js"
mkdir -p "$work/site/src"
mkdir -p "$work/site/src" "$work/tests/scripts"
for ext in ts css; do
printf '/* A comment\n spanning lines. */\n' >"$work/site/src/a.$ext"
! check || fail "a multi-line .$ext block comment passed"
@@ -30,6 +20,8 @@ for ext in ts css; do
check || fail "a short .$ext comment failed"
rm "$work/site/src/a.$ext"
done
printf 'const a = 1 /* A comment\n spanning lines. */\n' >"$work/site/src/a.ts"
! check || fail "an inline multi-line .ts block comment passed"
printf 'const glob = "/*.css";\n' >"$work/site/src/a.ts"
check || fail "a /* inside a .ts string failed"
printf 'const a = 1; // One two three four five six seven eight nine ten eleven.\n' >"$work/site/src/a.ts"
+1 -2
View File
@@ -20,7 +20,6 @@ PATTERNS = [
"crates/**/*.h",
"patches/**/*.patch",
"tests/**/*.swift",
"apps/*/web/src/**/*.js",
"site/src/**/*.ts",
"site/src/**/*.css",
"site/test/**/*.ts",
@@ -37,7 +36,7 @@ LINE_COMMENT = {
}
# Scanned by tokens: strings skipped, trailing comments found.
SCANNED = {".js": True, ".ts": True, ".css": False}
SCANNED = {".ts": True, ".css": False}
def scan(text, slashes):
-36
View File
@@ -1,36 +0,0 @@
#!/usr/bin/env bash
# ElyMark signed with hardened runtime still draws its page.
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
[ "$(uname)" = Darwin ] || {
echo "SKIP hardened-macos: macOS only"
exit 0
}
[ -n "${ELY_SIGN_IDENTITY:-}" ] || {
echo "SKIP hardened-macos: set ELY_SIGN_IDENTITY to a signing identity"
exit 0
}
work=$(cd "$(mktemp -d)" && pwd -P)
trap 'pkill -f "$work/ElyMark.app/Contents/MacOS/" 2>/dev/null || true; sleep 1; rm -rf "$work"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
ELY_DIST=$work "$root/packaging/macos/bundle.sh" ely-mark debug >"$work/bundle.log" 2>&1 ||
fail "bundle: $(tail -n 3 "$work/bundle.log")"
app=$work/ElyMark.app
codesign --verify --deep --strict "$app" || fail "the signature does not verify"
codesign -d --entitlements - "$app/Contents/Frameworks/ely-mark Helper (Renderer).app" 2>/dev/null |
grep -q allow-jit || fail "the renderer helper has no JIT entitlement"
printf '# Hardened\n' >"$work/t.md"
open -n -a "$app" --env "HOME=$work/home" --env ELY_LOG=info --env ELY_NO_DIALOG=1 "$work/t.md"
log="$work/home/Library/Application Support/ElyOffice/ely-mark/ely.log"
for _ in $(seq 240); do
grep -q 'mark: shown as' "$log" 2>/dev/null && {
echo "hardened-macos: the signed page draws"
exit 0
}
sleep 0.25
done
fail "the page never showed: $(grep -E 'web:|mark:' "$log" 2>/dev/null | tail -n 5)"
+1 -1
View File
@@ -31,7 +31,7 @@ docker build -q -t ely-smoke-fedora - >/dev/null <<'EOF'
FROM fedora:44@sha256:43b29f65a41eb9c35e1cd5323e3bdf3b655c2357a9f4f1ff2f9c2798e5045d80
RUN dnf install -y -q xorg-x11-server-Xvfb mesa-vulkan-drivers xwd xwininfo && dnf clean all
EOF
# As the work dir's owner: Git and CEF refuse root.
# As the work dir's owner: Git refuses others' repos.
inside='set -euo pipefail
slug=$1 work=$2 file=$3
shift 3
+2 -2
View File
@@ -17,7 +17,7 @@ done
release=$root/target/ubuntu/release
lo=$root/engines/lo-src/instdir/program
required=("$release"/ely-{doc,table,show,pdf,tree,mark,lok-worker,pdf-worker} "$lo/libmergedlo.so"
"$root/engines/git/bin/git" "$root/engines/cef/libcef.so")
"$root/engines/git/bin/git")
for file in "${required[@]}"; do
[ -f "$file" ] || fail "no $file; build in packaging/linux/container.sh"
magic=$(head -c 4 "$file" | od -An -c | tr -d ' ') || fail "cannot read $file"
@@ -26,7 +26,7 @@ done
list=$(mktemp)
trap 'rm -f "$list"' EXIT
find -L "$release"/ely-{doc,table,show,pdf,tree,mark,lok-worker,pdf-worker} "$lo" "$root/engines/git" \
"$root/engines/cef" -type f -print0 >"$list" || fail "cannot list every file"
-type f -print0 >"$list" || fail "cannot list every file"
checked=0
while IFS= read -r -d '' file; do
magic=$(head -c 4 "$file" | od -An -c | tr -d ' ') || fail "cannot read $file"
-105
View File
@@ -1,105 +0,0 @@
#!/usr/bin/env bash
# Idle on Linux: ElyMark rereads no theme, settings or file.
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
fail() {
echo "FAIL: $*" >&2
exit 1
}
[ "$(uname)" = Linux ] || {
echo "SKIP: inotify is Linux"
exit 0
}
[ -n "${WAYLAND_DISPLAY:-}${DISPLAY:-}" ] || {
echo "SKIP: no display"
exit 0
}
for tool in inotifywait jq hyprctl grim magick tesseract; do
command -v "$tool" >/dev/null || {
echo "SKIP: no $tool"
exit 0
}
done
[ -f "$root/engines/cef/libcef.so" ] || fail "no engines/cef; run scripts/fetch-cef.sh"
[ -f "$root/engines/content/ely-mark/index.html" ] || fail "no ElyMark page; run scripts/build-mark-content.sh"
app=$(cargo build -q --locked --message-format=json --manifest-path "$root/Cargo.toml" -p ely-mark --bin ely-mark |
jq -r 'select(.reason == "compiler-artifact" and .target.name == "ely-mark") | .executable // empty')
[ -x "$app" ] || fail "Cargo reported no ElyMark binary"
work=$(mktemp -d)
pid=
spy=
trap '[ -z "$spy" ] || kill "$spy" 2>/dev/null; [ -z "$pid" ] || stop_app "$pid"; rm -rf "$work"' EXIT
# shellcheck source=tests/scripts/linux-common.sh
. "$root/tests/scripts/linux-common.sh"
current=$work/omarchy/current
mkdir -p "$current/theme" "$work/data/ElyOffice" "$work/notes"
cp "$root/crates/ely-ui/tests/fixtures/omarchy/catppuccin.toml" "$current/theme/colors.toml"
echo catppuccin >"$current/theme.name"
echo '{"version": 1}' >"$work/data/ElyOffice/settings.json"
note=$work/notes/note.md
printf '# Idle\n\nNothing moves.\n' >"$note"
LD_LIBRARY_PATH="$root/engines/cef" ELY_OMARCHY_DIR="$current" ELY_SMOKE=hold ELY_LOG=info HOME="$work" \
XDG_DATA_HOME="$work/data" setsid "$app" "$note" 2>"$work/app.err" &
pid=$!
seen() { grep -c "$1" "$work/app.err" || true; }
logged() { # pattern, what, count before
local before=${3:-0}
for _ in $(seq 120); do
[ "$(seen "$1")" -gt "$before" ] && return
kill -0 "$pid" 2>/dev/null || fail "ElyMark quit: $(tail -n 5 "$work/app.err")"
sleep 0.25
done
fail "ElyMark missed $2"
}
logged 'mark: watching 1 folders' "its file's folder"
logged 'omarchy catppuccin applied' "the Omarchy theme"
sleep 3
# Four quiet seconds: nothing opens the theme, settings or note.
idle() { # when
inotifywait -q -m -e open --format '%w%f' "$current" "$current/theme" "$work/data/ElyOffice" "$work/notes" \
>"$work/opens" 2>/dev/null &
spy=$!
for _ in 1 2 3 4 5; do
sleep 1
kill -0 "$pid" 2>/dev/null || fail "ElyMark quit while $1: $(tail -n 5 "$work/app.err")"
done
kill "$spy"
spy=
local opens
opens=$(grep -c -E '(colors\.toml|theme\.name|settings\.json|note\.md)$' "$work/opens" || true)
[ "$opens" -eq 0 ] || fail "ElyMark $1 opened files $opens times: $(sort "$work/opens" | uniq -c)"
echo "linux: ElyMark $1 reads nothing"
}
idle "idle after start"
mkdir "$current/next-theme"
cp "$root/crates/ely-ui/tests/fixtures/omarchy/gruvbox.toml" "$current/next-theme/colors.toml"
rm -rf "$current/theme"
mv "$current/next-theme" "$current/theme"
echo gruvbox >"$current/theme.name"
logged 'omarchy gruvbox applied' "Omarchy's theme swap"
echo '{"version": 1, "author": "Tester"}' >"$work/data/ElyOffice/settings.json"
logged 'settings: changed on disk' "a settings write"
changed=$(seen 'changed outside')
printf '# Idle\n\nSomething moved.\n' >"$note"
logged 'changed outside' "an outside edit" "$changed"
# The window shows the new words.
shown=
for _ in $(seq 20); do
kill -0 "$pid" 2>/dev/null || fail "ElyMark quit: $(tail -n 5 "$work/app.err")"
id=$(hyprctl clients -j | jq -r --argjson pid "$pid" '[.[] | select(.pid == $pid and .mapped)][0].stableId // empty')
if [ -n "$id" ]; then
grim -T "$id" "$work/shot.png" 2>"$work/grim.err" || fail "grim failed: $(cat "$work/grim.err")"
magick "$work/shot.png" -colorspace Gray -negate "$work/ocr.png"
text=$(tesseract "$work/ocr.png" - 2>"$work/ocr.err") || fail "tesseract failed: $(cat "$work/ocr.err")"
grep -q 'moved' <<<"$text" && shown=1 && break
fi
sleep 0.5
done
[ -n "$shown" ] || fail "ElyMark does not show the outside edit"
sleep 2
idle "idle after changes"
+2 -3
View File
@@ -4,7 +4,7 @@ set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
script=$root/scripts/fetch-references.sh
names="Ely-GPUI-Components zed libreoffice-core mupdf git tesseract mailcore2 libetpan cef cef-rs milkdown codemirror-dev marktext colamd magpie agent-client-protocol markion markdown-rs omarchy"
names="Ely-GPUI-Components zed libreoffice-core mupdf git tesseract mailcore2 libetpan marktext colamd magpie agent-client-protocol markion markdown-rs omarchy"
fail() {
echo "FAIL: $*" >&2
@@ -29,8 +29,7 @@ for header in \
tesseract/include/tesseract/capi.h \
mailcore2/CMakeLists.txt \
libetpan/src/low-level/gmail/mailgmail.h \
libetpan/src/low-level/activesync/mailactivesync.h \
cef-rs/Cargo.toml; do
libetpan/src/low-level/activesync/mailactivesync.h; do
[ -s "$root/reference/$header" ] || fail "missing reference/$header"
done
+3 -8
View File
@@ -72,7 +72,7 @@ while IFS=$'\t' read -r slug name; do
ely-doc | ely-table | ely-show) engines=(LibreOffice) ;;
ely-pdf) engines=(MuPDF MuPDF/fonts/urw MuPDF/fonts/sil MuPDF/fonts/noto MuPDF/fonts/droid MuPDF/fonts/han OpenSSL tessdata GreatVibes) ;;
ely-tree) engines=(Git git-lfs) ;;
ely-mark) engines=(CEF content) ;;
ely-mark) engines=(KaTeX) ;;
esac
for part in LICENSE rust-crates.txt "${engines[@]}"; do
[ -n "$(find "$licenses/$part" -type f -size +0 2>/dev/null)" ] || fail "$name lacks the licences of $part"
@@ -83,9 +83,8 @@ while IFS=$'\t' read -r slug name; do
[ -n "$(find "$family" -type f -size +0 \( -iname 'licen[cs]e*' -o -iname 'copying*' -o -iname 'notice*' -o -iname 'ofl*' \))" ] ||
fail "$name lacks the licence of the ${family##*/} fonts"
done
# Nothing inside registers with macOS but CEF's helpers.
extra=$(find "$app/Contents" \( -name '*.app' -o -name '*.appex' -o -name '*.mdimporter' -o -name '*.qlgenerator' \) |
grep -v "/Frameworks/$slug Helper" || true)
# Nothing inside registers with macOS.
extra=$(find "$app/Contents" \( -name '*.app' -o -name '*.appex' -o -name '*.mdimporter' -o -name '*.qlgenerator' \))
[ -z "$extra" ] || fail "$name carries bundles macOS registers: $extra"
# No binary may need a newer macOS than the app.
least=$(/usr/libexec/PlistBuddy -c "Print :LSMinimumSystemVersion" "$app/Contents/Info.plist")
@@ -125,10 +124,6 @@ while IFS=$'\t' read -r slug name; do
# Built-ins stay links to one binary, not copies.
[ "$(du -sm "$app/Contents/Resources/git" | cut -f1)" -lt 100 ] || fail "ElyTree's Git copies its built-ins"
;;
ely-mark)
grep -qF "web: CEF loaded from $app/Contents/Frameworks" "$log" || fail "ElyMark used another CEF"
grep -qF "mark: page from $app/Contents/Resources/content" "$log" || fail "ElyMark used another page"
;;
esac
# A running app takes Finder's next file.
open -a "$app" "$files/$second"
+21 -21
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Storage: sizes shown; log cleared now, cache at restart.
# Storage: sizes shown; log cleared now, copies at restart.
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
@@ -48,18 +48,18 @@ quit() {
for _ in $(seq 40); do kill -0 "$pid" 2>/dev/null || break; sleep 0.25; done
wait "$pid" 2>/dev/null || true
}
mkdir -p "$data/web"
# A known 2 MiB in the cache for the row.
dd if=/dev/zero of="$data/web/ely-sample" bs=1024 count=2048 2>/dev/null
mkdir -p "$data/recovery"
# A known 2 MiB in the copies for the row.
dd if=/dev/zero of="$data/recovery/ely-sample" bs=1024 count=2048 2>/dev/null
# A log long enough that clearing it shows.
head -c 8192 /dev/zero | tr '\0' 'x' >"$data/ely.log"
start
keys 'keystroke "," using command down'
seen "title=Storage" || fail "no settings window"
"$work/ax-press" "$pid" Storage >/dev/null
seen "title=Web content cache" || fail "the storage page lists no cache"
shown=$(kb "Web content cache")
[ "${shown:-0}" -ge 2048 ] || fail "the cache shows ${shown:-nothing} KB, not at least 2048"
seen "title=Recovery copies" || fail "the storage page lists no copies"
shown=$(kb "Recovery copies")
[ "${shown:-0}" -ge 2048 ] || fail "the copies show ${shown:-nothing} KB, not at least 2048"
shown=$(kb "This app's log")
[ "${shown:-0}" -ge 8 ] || fail "the log shows ${shown:-nothing} KB, not at least 8"
# Each clear asks first; nothing goes before the yes.
@@ -70,11 +70,11 @@ seen "title=Delete" || fail "clearing the log did not ask"
sleep 1
[ "$(wc -c <"$data/ely.log")" -lt 4096 ] || fail "the log was not cleared"
"$work/ax-press" "$pid" "Clear at next start" AXButton >/dev/null
seen "title=Delete" || fail "clearing the cache did not ask"
[ ! -e "$data/.clear-web" ] || fail "the cache was marked before the yes"
seen "title=Delete" || fail "clearing the copies did not ask"
[ ! -e "$data/.clear-recovery" ] || fail "the copies were marked before the yes"
"$work/ax-press" "$pid" Delete AXButton >/dev/null
seen "Clears at next start" || fail "the cache clear is not shown as pending"
[ -e "$data/ely-sample" ] || [ -d "$data/web" ] || fail "the cache went while in use"
seen "Clears at next start" || fail "the copies clear is not shown as pending"
[ -e "$data/recovery/ely-sample" ] || fail "the copies went while in use"
# Ten points kept per document, once twelve were made.
"$work/ax-press" "$pid" 10 >/dev/null
for _ in $(seq 20); do grep -q '"keep": 10' "$data/settings.json" 2>/dev/null && break; sleep 0.25; done
@@ -82,8 +82,9 @@ grep -q '"keep": 10' "$data/settings.json" || fail "the recovery limit is not ke
osascript -e "tell application \"System Events\" to click button 1 of (first window of (first process whose unix id is $pid) whose name is \"Settings\")" >/dev/null
sleep 1
# An untitled document: never autosaved, so it keeps recovery points.
tabs=$(grep -c "as tab" "$data/ely.log" || true)
keys 'keystroke "n" using command down'
for _ in $(seq 40); do grep -q "mark: shown" "$data/ely.log" && break; sleep 0.25; done
for _ in $(seq 40); do [ "$(grep -c "as tab" "$data/ely.log")" -gt "$tabs" ] && break; sleep 0.25; done
sleep 2
# Twelve points written, one edit each.
written() { grep -c "recovery: a point at" "$data/ely.log" || true; }
@@ -146,22 +147,21 @@ grep -q '"keep": 20' "$data/settings.json" || fail "the retried limit is not kep
sleep 0.5
"$work/ax-values" "$pid" | grep -q "Not done" && fail "the failure stays after a good save"
quit
touch "$data/web/ely-marker"
touch "$data/recovery/ely-marker"
start
[ ! -e "$data/web/ely-marker" ] || fail "the cache was not cleared at the start"
[ ! -e "$data/recovery/ely-marker" ] || fail "the copies were not cleared at the start"
quit
# A failed clear stops the start; the mark stays.
mkdir -p "$data/web/locked"
touch "$data/web/locked/kept" "$data/.clear-web"
chmod 555 "$data/web/locked"
mkdir -p "$data/recovery/locked"
touch "$data/recovery/locked/kept" "$data/.clear-recovery"
chmod 555 "$data/recovery/locked"
status=0
ELY_SMOKE=hold ELY_LOCALE=en ELY_NO_DIALOG=1 HOME="$home" perl -e 'alarm 60; exec @ARGV' "$root/target/debug/ely-mark" >"$work/out" 2>&1 || status=$?
chmod 755 "$data/web/locked"
chmod 755 "$data/recovery/locked"
[ "$status" = 1 ] || fail "a failed clear started anyway ($status)"
grep -q "could not be deleted" "$work/out" || fail "the failed clear is not told: $(tail -3 "$work/out")"
grep -q "web: CEF" "$work/out" && fail "the cache opened after a failed clear"
[ -e "$data/.clear-web" ] && [ -e "$data/web/locked/kept" ] || fail "the failed clear lost its mark or data"
[ -e "$data/.clear-recovery" ] && [ -e "$data/recovery/locked/kept" ] || fail "the failed clear lost its mark or data"
start
[ ! -e "$data/web/locked/kept" ] || fail "the clear was not tried again"
[ ! -e "$data/recovery/locked/kept" ] || fail "the clear was not tried again"
quit
echo "ok: storage"
-77
View File
@@ -1,77 +0,0 @@
#!/usr/bin/env bash
# A live WebView: input, fence, failures, release, paint.
set -euo pipefail
root=$(cd "$(dirname "$0")/../.." && pwd)
[ "$(uname)" = Darwin ] || { echo "SKIP: macOS only"; exit 0; }
[ -d "${ELY_CEF:-$root/engines/cef}/Chromium Embedded Framework.framework" ] || {
echo "SKIP: no CEF; run scripts/fetch-cef.sh"
exit 0
}
work=$(mktemp -d)
probe=
trap '[ -n "$probe" ] && kill "$probe" 2>/dev/null; rm -rf "$work"' EXIT
fail() {
echo "FAIL: $*" >&2
exit 1
}
cargo build -q -p ely-web-content --example webprobe --manifest-path "$root/Cargo.toml"
swiftc -O -o "$work/share" "$root/tests/scripts/window-share.swift"
swiftc -O -o "$work/press" "$root/tests/scripts/ax-press.swift"
ELY_LOG=info ELY_LOCALE=en ELY_SMOKE=hold HOME="$work" XDG_DATA_HOME="$work/data" \
perl -e 'alarm 150; exec @ARGV' "$root/target/debug/examples/webprobe" >"$work/out" 2>"$work/err" &
probe=$!
wait_for() {
for _ in $(seq 400); do
grep -qxF "$1" "$work/out" && return 0
kill -0 "$probe" 2>/dev/null || break
sleep 0.25
done
fail "never saw '$1': $(cat "$work/out") $(tail -n 5 "$work/err")"
}
wait_for "probe: hold"
[ "$("$work/press" $$ none)" != untrusted ] || fail "needs Accessibility permission"
# The page's magenta marker: share of the window.
marker() {
local share
share=$("$work/share" "$probe" 255,0,170)
case $share in
denied) fail "needs Screen Recording permission" ;;
none) fail "the probe has no window" ;;
esac
echo "$share"
}
share=$(marker)
awk -v s="$share" 'BEGIN { exit !(s > 0.01) }' || fail "the page is not painted (marker share $share)"
# A killed renderer: the fault shows, its button reloads.
pkill -P "$probe" -f -- "--type=renderer" || fail "no renderer to stop"
wait_for "probe: main failed"
sleep 1
gone=$(marker)
awk -v s="$gone" 'BEGIN { exit !(s < 0.001) }' || fail "the dead page still shows (marker share $gone)"
pressed=
for _ in $(seq 20); do
[ "$("$work/press" "$probe" Reload)" = pressed ] && pressed=1 && break
sleep 0.25
done
[ -n "$pressed" ] || fail "no Reload button in the fault view"
wait_for "probe: recovered"
sleep 1.5
back=$(marker)
awk -v s="$back" 'BEGIN { exit !(s > 0.01) }' || fail "the page did not come back (marker share $back)"
wait "$probe" || fail "the probe did not exit cleanly: $(tail -n 5 "$work/err")"
probe=
out=$(cat "$work/out")
for want in "probe: value=你好a" "probe: focused=text" "probe: zoom=1.25" "probe: stayed" \
"probe: outside=false" "probe: network=refused" "probe: udp=silent" "probe: rtc=undefined" "probe: main failed" "probe: hash loaded" "probe: missing failed" \
"probe: closed 20" "probe: dropped 5 unmade, 10 early" "probe: inert loaded" "probe: scripted ran" "probe: inert checked"; do
grep -qxF "$want" <<<"$out" || fail "expected '$want', got: $out"
done
! grep -q "probe: inert ran" <<<"$out" || fail "an inert page ran a script: $out"
grep -qE '^probe: fg=rgba\([0-9]+, [0-9]+, [0-9]+, ' <<<"$out" || fail "no theme token: $out"
# Freed before the hold: 20 closed, 5 unmade, 10 early.
freed=$(awk "/probe: hold/ { exit } /a page's memory is freed/ { n++ } END { print n + 0 }" "$work/err")
[ "$freed" -ge 35 ] || fail "only $freed of 35 closed pages freed their memory"
! grep -q "panicked" "$work/err" || fail "panics: $(grep -m3 panicked "$work/err")"
echo "ok: webview (marker share $share, $freed pages freed)"