diff --git a/Cargo.toml b/Cargo.toml index 92959cb..dea4df4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,7 +40,7 @@ serde_json = "1" sha2 = "0.10" tempfile = "3" ureq = "3" -windows-sys = { version = "0.60", features = ["Win32_UI_WindowsAndMessaging", "Win32_Globalization", "Win32_Storage_FileSystem", "Win32_Security", "Win32_System_Threading", "Win32_System_IO", "Win32_System_LibraryLoader", "Win32_System_Com", "Win32_UI_Shell", "Win32_Security_Authentication_Identity"] } +windows-sys = { version = "0.60", features = ["Win32_UI_WindowsAndMessaging", "Win32_Graphics_Gdi", "Win32_Globalization", "Win32_Storage_FileSystem", "Win32_Security", "Win32_System_Threading", "Win32_System_IO", "Win32_System_LibraryLoader", "Win32_System_Com", "Win32_UI_Shell", "Win32_Security_Authentication_Identity"] } objc2-foundation = { version = "0.3", default-features = false, features = ["NSLocale", "NSPathUtilities", "NSString", "NSURL", "NSDictionary", "NSValue", "NSError", "NSRunLoop", "NSDate", "NSUserDefaults"] } objc2-av-foundation = { version = "0.3", default-features = false, features = ["std", "AVPlayer", "AVPlayerItem", "AVPlayerItemOutput", "AVAsset", "objc2-core-media", "objc2-core-video", "objc2-core-foundation"] } objc2-core-media = { version = "0.3", default-features = false, features = ["std", "CMTime"] } diff --git a/agents.md b/agents.md index 26926a4..8f47a9e 100644 --- a/agents.md +++ b/agents.md @@ -194,7 +194,7 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely - 2026-10-02:组件库 7380afb 起:对话框与面板的 Enter 提交改走字段的 key-down 动作(macOS IME 天然守卫,不再提前提交);面板族文案经组件 `i18n::text`(内建 en/zh-CN,应用目录可覆盖);核心控件带 role/aria;对话框带角色、名称与说明,Cancel/Close 与禁用说明本地化。同仓库 `~/Documents/GitHub/Ely-GPUI-Components` 修复并推送(2026-10-09 起推到 git.zacharyzhang.com,HTTPS,令牌在本机钥匙串)。2026-10-04 组件库 b6cb94c:`NumberInput::on_commit` 只在 Enter、失焦、步进、拖动时触发,键入不触发;改文档的数值框用它,否则每个键入数字都成一条内核命令与一步撤销。同批带入上游的 Checkbox、Switch、Select 等 role/aria 与 RenderSurface。c0f17ee:`ToggleButton::disabled`(不可按、无 Tab 停靠、说明不可用,提示可写原因)。967d1e4:`ContextMenu::manual`(按属主编号的请求在某点打开,右键不开),供行在按下之后才到的菜单,如内核给的拼写建议。 - 2026-10-06:审查者为探针复制的源码树须用自己的 `CARGO_TARGET_DIR`(`scripts/review.sh` 写明);写完结论前删掉这些副本与其构建目录(曾积到 /tmp 近 900 GB,磁盘写满、链接失败):曾编进本仓库 `target/`,ely-lok 里烘焙的源码路径指向已删的副本,单测找不到 worker。 - 2026-10-03:每条任务由 `scripts/review.sh` 在 herdr 分栏里交给 codex(gpt-6.1-sol,effort high)审查,最多三轮。此前各轮用 gpt-6-astra xhigh。2026-10-10(用户定):`--yolo`、`service_tier="fast"`、effort xhigh,每条最多五轮;herdr 直接启动 codex 程序,不经 shell 别名。 -- 2026-10-01:macOS 的 LOK 用自建 LibreOffice 26.2.6.3 加 `patches/libreoffice/0001-lok-macos-tile-painting.patch`(已验证出图块)。2026-10-03 加 `0002-lok-dispatch-to-document-frame.patch`:`postUnoCommand` 原发往桌面的活动框架,另一文档关闭后命令落空或落到别的文档;改发本文档的框架。2026-10-04 加 `0003-lok-char-style-and-spacing-states.patch`:LOK 原不发当前字符样式与段前段后,属性栏无从同步;补发 `.uno:CharStyle`(界面名,无字符样式为空)与 `.uno:ULSpacing`(`上,下`,1/100 mm);混合选区的状态发 `{"commandName","dontcare":true}`,`ely_lok::MIXED` 表示,段落样式跨段不一致也算混合(原样报首段),字符样式带来的字体、字号、粗斜体等在选区内不一致也算混合(原样只看直接格式)。`.uno:ULSpacing` 缺的成员归零,故两值同发;属性栏记住已发未回显的两值状态(段距、页边距),连续编辑以它为底。同日加 `0004-lok-panel-states.patch`:补发页面尺寸、页面项(横向、版式、页码格式)、页边距、分栏、页眉页脚开关与版式、四种锚点与六种环绕(真假或禁用)、标题行重复;页面项缺的成员同样归零,故转向时页面项与尺寸都发。`0005-jsdialog-skip-disposed-dialogs.patch`:对话框守卫关闭对话框后,JSDialog 空闲任务仍为它生成整窗更新,读已释放窗口崩溃(实测崩溃报告),补丁让已释放的对话框不再生成消息。`0006-page-size-takes-the-page-item.patch`:`.uno:AttributePageSize` 也收页面项,转向是一次改动、一步撤销。`0007-lok-aqua-anyinput-without-peeking.patch`:worker 的请求经主 GCD 队列派发,而 Aqua 的 `AnyInput` 用 `nextEventMatchingMask` 窥探时会转动 run loop、在内核空闲任务中途执行请求(实测:表格删列与排版空闲任务交错,`SwLayIdle::DoIdleJob_` 段错误,约三成全量测试复现);补丁让 LOK 下的 `AnyInput` 改问客户端,worker 以 `registerAnyInputCallback` 报告是否有待处理请求,请求只在顶层让出时执行(补丁后连续十次全绿)。`0008-lok-toc-caption-formula-without-dialogs.patch`:目录、题注、公式在内核只有对话框入口;补丁给 `.uno:InsertMultiIndex` 加 `Title`(默认目录)、`.uno:InsertCaptionDialog` 加 `Category`(程序名)与 `Text`(照内核题注对话框的选项)、`.uno:InsertObjectStarMath` 加 `Formula`(文字转公式,选中公式时整体替换,一步撤销),并加 `getCommandValues(".uno:Formula")` 读选中公式。插入目录后光标落在目录内(内核原样)。`0009-lok-word-count-values.patch`:`getCommandValues(".uno:WordCount")` 给文档与选区的字数、字符数;内核的 `.uno:StateWordCount` 状态是英文串,只作重读的信号。构建加 `--with-myspell-dicts`,随包词典取自 LibreOffice 的 dictionaries 子模块(core 钉的 9925407,来源与许可见各目录 README 与 license),装在 `Resources/extensions`。构建后只留 `dict-en`(25 MB,英语拼写、断词、同义词):界面语言是英文与简体中文,中文无 Hunspell 词典;全部 65 个(473 MB)时 LOK 首启 5.4 秒、再启 2.3 秒,只留英文为 1.1 秒与 0.7 秒(PERF-01 要求 3 秒内可操作)。词典语种已由用户定:英文。`0010-lok-outline-values.patch`:`getCommandValues(".uno:Outline")` 给各标题的文字、级别、位置(twips)与是否当前(光标所在处按文档顺序之前最近的标题,`GetOutlinePos`;分栏时位置比不出先后),`.uno:Bookmarks` 的每项加位置(版面不显示的书签无位置);导航栏的跳转都按位置。`0011-lok-font-lists-present-when-a-member-is-used.patch`:LOK 把以 `;` 连写的字体列表整串当作字体名,列表中有字体已用上也报缺失(实测报出界面字体的回退列表);补丁让列表中任一成员用上即不算缺失,文档里写的列表全缺时照报。`0012-lok-calc-cursor-references-merge.patch`:Calc 在回调注册后补发单元格光标;`.uno:SheetReferences?sheet=N` 给引用某表的公式格数、命名区域数、第一个公式格,以及用 INDIRECT 的公式与名称数(这类引用运行时才知指向,删除说明里单独列出);合并带 `MoveContents` 时不弹对话框,并修上游“多个数据格的 API 合并什么也不做”。worker 以 `SAL_LOK_OPTIONS=sc_print_twips_msgs`(macOS 另加 `unipoll`)运行,Calc 的光标、选区与 `.uno:SheetGeometryData` 同为文档 twips;视图把可见区经 `setClientVisibleArea` 报给内核。`0013-lok-author-option.patch`:`lo_setOption("author", 名)` 设内核用户全名,新批注与修订以此署名。`0014-lok-calc-formula-entry-and-names.patch`:公式语法错误不弹询问、补全不切走编辑、提交清引用标记、名称的可撤销定义删除与列表判定、自动计算状态、xlsx/xls 的手动计算往返。`0015-aqua-no-wait-with-a-due-timer.patch`:Aqua 在直接超时待处理时不阻塞等原生事件(请求块里起的 0 毫秒定时器原会让调度器停到下一个请求)。`0005` 另加存活标记:发送消息的客户端关掉对话框后不再读已释放成员。worker 记下每个文档载入时的内核视图,每次请求先切到该视图(Calc 按当前视图读写,多工作簿时否则会错表)。切表经视图的有序输入队列。视图记录跳转所需的最远点,滚动与图块以内核文档尺寸和它的较大者为界(Calc 空白区随可见区扩展)。视图按内核报的冻结行列(`.uno:FreezePanesColumn/Row`)分格:冻结部分不随滚动,其余照滚,图块按格裁剪,命中与表头同一换算(`view/panes.rs`)。文档占用 Tab 与 Shift-Tab(在 LokView 键位上下文里优先于焦点切换):Writer 插制表符或跳表格,Calc 跳单元格。worker 崩溃时打印本机栈(`worker/fatal.rs`),客户端报出退出信号。官方 macOS 包的 `paintTile` 不出图,上游 master 的修复也不够。Skia 必须开。工作进程在主线程以 unipoll 模式运行,请求经主 GCD 队列派发,环境设 `SAL_FORCE_HIDPI_SCALING=1` 与 `SAL_SKIA=raster`。证据见 `docs/engineering-risks.md` R-01(本机笔记,不入库)。 +- 2026-10-01:macOS 的 LOK 用自建 LibreOffice 26.2.6.3 加 `patches/libreoffice/0001-lok-macos-tile-painting.patch`(已验证出图块)。2026-10-03 加 `0002-lok-dispatch-to-document-frame.patch`:`postUnoCommand` 原发往桌面的活动框架,另一文档关闭后命令落空或落到别的文档;改发本文档的框架。2026-10-04 加 `0003-lok-char-style-and-spacing-states.patch`:LOK 原不发当前字符样式与段前段后,属性栏无从同步;补发 `.uno:CharStyle`(界面名,无字符样式为空)与 `.uno:ULSpacing`(`上,下`,1/100 mm);混合选区的状态发 `{"commandName","dontcare":true}`,`ely_lok::MIXED` 表示,段落样式跨段不一致也算混合(原样报首段),字符样式带来的字体、字号、粗斜体等在选区内不一致也算混合(原样只看直接格式)。`.uno:ULSpacing` 缺的成员归零,故两值同发;属性栏记住已发未回显的两值状态(段距、页边距),连续编辑以它为底。同日加 `0004-lok-panel-states.patch`:补发页面尺寸、页面项(横向、版式、页码格式)、页边距、分栏、页眉页脚开关与版式、四种锚点与六种环绕(真假或禁用)、标题行重复;页面项缺的成员同样归零,故转向时页面项与尺寸都发。`0005-jsdialog-skip-disposed-dialogs.patch`:对话框守卫关闭对话框后,JSDialog 空闲任务仍为它生成整窗更新,读已释放窗口崩溃(实测崩溃报告),补丁让已释放的对话框不再生成消息。`0006-page-size-takes-the-page-item.patch`:`.uno:AttributePageSize` 也收页面项,转向是一次改动、一步撤销。`0007-lok-aqua-anyinput-without-peeking.patch`:worker 的请求经主 GCD 队列派发,而 Aqua 的 `AnyInput` 用 `nextEventMatchingMask` 窥探时会转动 run loop、在内核空闲任务中途执行请求(实测:表格删列与排版空闲任务交错,`SwLayIdle::DoIdleJob_` 段错误,约三成全量测试复现);补丁让 LOK 下的 `AnyInput` 改问客户端,worker 以 `registerAnyInputCallback` 报告是否有待处理请求,请求只在顶层让出时执行(补丁后连续十次全绿)。`0008-lok-toc-caption-formula-without-dialogs.patch`:目录、题注、公式在内核只有对话框入口;补丁给 `.uno:InsertMultiIndex` 加 `Title`(默认目录)、`.uno:InsertCaptionDialog` 加 `Category`(程序名)与 `Text`(照内核题注对话框的选项)、`.uno:InsertObjectStarMath` 加 `Formula`(文字转公式,选中公式时整体替换,一步撤销),并加 `getCommandValues(".uno:Formula")` 读选中公式。插入目录后光标落在目录内(内核原样)。`0009-lok-word-count-values.patch`:`getCommandValues(".uno:WordCount")` 给文档与选区的字数、字符数;内核的 `.uno:StateWordCount` 状态是英文串,只作重读的信号。构建加 `--with-myspell-dicts`,随包词典取自 LibreOffice 的 dictionaries 子模块(core 钉的 9925407,来源与许可见各目录 README 与 license),装在 `Resources/extensions`。构建后只留 `dict-en`(25 MB,英语拼写、断词、同义词):界面语言是英文与简体中文,中文无 Hunspell 词典;全部 65 个(473 MB)时 LOK 首启 5.4 秒、再启 2.3 秒,只留英文为 1.1 秒与 0.7 秒(PERF-01 要求 3 秒内可操作)。词典语种已由用户定:英文。`0010-lok-outline-values.patch`:`getCommandValues(".uno:Outline")` 给各标题的文字、级别、位置(twips)与是否当前(光标所在处按文档顺序之前最近的标题,`GetOutlinePos`;分栏时位置比不出先后),`.uno:Bookmarks` 的每项加位置(版面不显示的书签无位置);导航栏的跳转都按位置。`0011-lok-font-lists-present-when-a-member-is-used.patch`:LOK 把以 `;` 连写的字体列表整串当作字体名,列表中有字体已用上也报缺失(实测报出界面字体的回退列表);补丁让列表中任一成员用上即不算缺失,文档里写的列表全缺时照报。`0012-lok-calc-cursor-references-merge.patch`:Calc 在回调注册后补发单元格光标;`.uno:SheetReferences?sheet=N` 给引用某表的公式格数、命名区域数、第一个公式格,以及用 INDIRECT 的公式与名称数(这类引用运行时才知指向,删除说明里单独列出);合并带 `MoveContents` 时不弹对话框,并修上游“多个数据格的 API 合并什么也不做”。worker 以 `SAL_LOK_OPTIONS=sc_print_twips_msgs`(另加 `unipoll`)运行,Calc 的光标、选区与 `.uno:SheetGeometryData` 同为文档 twips;视图把可见区经 `setClientVisibleArea` 报给内核。`0013-lok-author-option.patch`:`lo_setOption("author", 名)` 设内核用户全名,新批注与修订以此署名。`0014-lok-calc-formula-entry-and-names.patch`:公式语法错误不弹询问、补全不切走编辑、提交清引用标记、名称的可撤销定义删除与列表判定、自动计算状态、xlsx/xls 的手动计算往返。`0015-aqua-no-wait-with-a-due-timer.patch`:Aqua 在直接超时待处理时不阻塞等原生事件(请求块里起的 0 毫秒定时器原会让调度器停到下一个请求)。`0005` 另加存活标记:发送消息的客户端关掉对话框后不再读已释放成员。worker 记下每个文档载入时的内核视图,每次请求先切到该视图(Calc 按当前视图读写,多工作簿时否则会错表)。切表经视图的有序输入队列。视图记录跳转所需的最远点,滚动与图块以内核文档尺寸和它的较大者为界(Calc 空白区随可见区扩展)。视图按内核报的冻结行列(`.uno:FreezePanesColumn/Row`)分格:冻结部分不随滚动,其余照滚,图块按格裁剪,命中与表头同一换算(`view/panes.rs`)。文档占用 Tab 与 Shift-Tab(在 LokView 键位上下文里优先于焦点切换):Writer 插制表符或跳表格,Calc 跳单元格。worker 崩溃时打印本机栈(`worker/fatal.rs`),客户端报出退出信号。官方 macOS 包的 `paintTile` 不出图,上游 master 的修复也不够。Skia 必须开。工作进程在主线程以 unipoll 模式运行,请求经主 GCD 队列派发,环境设 `SAL_FORCE_HIDPI_SCALING=1` 与 `SAL_SKIA=raster`。证据见 `docs/engineering-risks.md` R-01(本机笔记,不入库)。 - 2026-10-04:LOK 命令与对话框。界面只经 `ely_lok::commands::COMMANDS` 发 UNO 命令,worker 拒绝表外命令;内核弹出的对话框(`LOK_CALLBACK_WINDOW` created 或 JSDialog)在回调里即刻关闭并经 `LokError` 报给应用,需要的对话框由 Ely 自建。起因:Calc 的 Backspace 弹同步模态“删除内容”,曾卡死 worker。 - 2026-10-04:ElyDoc 查找替换、拼写、比较、邮件合并(DOC-12 至 14)。查找经 `.uno:ExecuteSearch`:查全部计数并高亮,下一处按全部的矩形定当前序号;替换一处后重新计数,无当前项时“替换”先选下一处。拼写建议来自右键时内核给的上下文菜单,只取拼写部分,`ContextMenu::manual` 在插入点下打开。比较把当前文档存成工作副本、开成新标签、对它发 `.uno:CompareDocuments`,两个源文件都不动。邮件合并的字段在正文里写成 `«列名»`(同 Word 的显示,已定),预览与生成都是对模板副本整体替换;生成逐行写 DOCX 或 PDF 到所选文件夹,任一目标已存在即整体拒绝,从不发邮件。 - 2026-10-04:ElyDoc 输出与导航(DOC-15、16)。导出 PDF 经 worker 的 `EXPORT_PDF` 请求,滤镜选项为 JSON(页码范围、批注印在页边、标出修订);打印先导出同一 PDF,macOS 用 PDFKit 的 `NSPrintOperation` 弹系统打印面板(`ely_platform::print`),其他平台报错,未实现。加密文档经 LOK 的 `DOCUMENT_PASSWORD` 回调询问密码,错的密码再问一次,取消即不打开。左栏导航默认打开(大纲、页面、书签),跳转前记下所在处,“返回”逐级回到原处;上一跳的输入未落到内核、或落下后光标尚未移动时,以其目标为所在处,连续跳转不丢位置;落下之后即按实际光标。当前标题取内核的标记,当前页按光标所在页。导出 PDF 与另存为的目标按文件身份(符号链接与硬链接)比对打开着的文档:导出遇之即拒绝,另存为遇本文件时只许原格式。缺字体时页面上方给出提示条。QA-10 与 Microsoft Office 互开未验证:本机无 Office。 @@ -282,4 +282,5 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely - 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 的内核与 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`)。 - 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 地址取。 diff --git a/apps/ely-mark/src/page.rs b/apps/ely-mark/src/page.rs index 8420213..39aefa9 100644 --- a/apps/ely-mark/src/page.rs +++ b/apps/ely-mark/src/page.rs @@ -9,13 +9,12 @@ use serde_json::{Value, json}; use crate::view::Heading; -/// The app's Resources, else this build's `engines/content`. +/// Beside the app, its Resources, else this build's. pub fn content() -> Result { - let bundled = std::env::current_exe()? - .parent() - .and_then(|dir| Some(dir.parent()?.join("Resources/content"))); + let exe = std::env::current_exe()?; + let dir = exe.parent().unwrap_or(Path::new(".")); let built = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../engines/content/ely-mark"); - for dir in bundled.into_iter().chain([built]) { + for dir in [dir.join("content"), dir.join("../Resources/content"), built] { if dir.join("index.html").is_file() { log::info!("mark: page from {}", dir.display()); return Ok(dir); diff --git a/crates/ely-lok/src/client.rs b/crates/ely-lok/src/client.rs index f95e2a2..070e49b 100644 --- a/crates/ely-lok/src/client.rs +++ b/crates/ely-lok/src/client.rs @@ -150,14 +150,14 @@ impl Worker { command .arg(install) .arg(profile_dir) - .env("SAL_FORCE_HIDPI_SCALING", "1") - .env("SAL_SKIA", "raster"); - // Calc reports in document twips, as its geometry does. - let options = match cfg!(target_os = "macos") { - true => "unipoll:sc_print_twips_msgs", - false => "sc_print_twips_msgs", + .env("SAL_FORCE_HIDPI_SCALING", "1"); + // Linux tiles via cairo are BGRA; Skia there is RGBA. + match cfg!(target_os = "linux") { + true => command.env("SAL_DISABLESKIA", "1"), + false => command.env("SAL_SKIA", "raster"), }; - command.env("SAL_LOK_OPTIONS", options); + // Calc reports in document twips; unipoll lends LOK's loop. + command.env("SAL_LOK_OPTIONS", "unipoll:sc_print_twips_msgs"); if dev { command.arg("--dev"); } @@ -370,7 +370,9 @@ impl Worker { // Nothing reaches a worker that has not passed its checks. self.ready()?; self.asked += 1; - ipc::write_frame(&mut self.stdin, request, self.asked, payload)?; + if let Err(error) = ipc::write_frame(&mut self.stdin, request, self.asked, payload) { + return Err(self.broken(request, error)); + } let (reply, id, body) = match self.replies.recv() { Ok(Ok(frame)) => frame, Ok(Err(error)) => return Err(self.broken(request, error)), diff --git a/crates/ely-lok/src/client/install.rs b/crates/ely-lok/src/client/install.rs index 746d315..71ecaea 100644 --- a/crates/ely-lok/src/client/install.rs +++ b/crates/ely-lok/src/client/install.rs @@ -22,6 +22,12 @@ pub fn templates(kind: &str) -> io::Result> { Ok(found) } +/// The kernel's folder beside the app, and in this build. +#[cfg(target_os = "macos")] +const LAYOUT: (&str, &str) = ("../Frameworks", "instdir/LibreOffice.app/Contents/Frameworks"); +#[cfg(not(target_os = "macos"))] +const LAYOUT: (&str, &str) = ("libreoffice/program", "instdir/program"); + /// The LOK build: `ELY_LOK_DIR`, the app bundle's, or engines/. pub fn install_dir() -> io::Result { if let Ok(dir) = std::env::var("ELY_LOK_DIR") { @@ -35,17 +41,15 @@ pub fn install_dir() -> io::Result { )) }; } + let (beside, built) = LAYOUT; + let library = crate::ffi::KERNEL; let exe = std::env::current_exe()?; - let bundled = exe - .parent() - .unwrap_or(Path::new(".")) - .join("../Frameworks"); - if cfg!(target_os = "macos") && bundled.join("libmergedlo.dylib").try_exists()? { + let bundled = exe.parent().unwrap_or(Path::new(".")).join(beside); + if bundled.join(library).try_exists()? { return Ok(bundled); } - let dev = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../engines/lo-src/instdir/LibreOffice.app/Contents/Frameworks"); - if dev.try_exists()? { + let dev = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../engines/lo-src").join(built); + if dev.join(library).try_exists()? { return Ok(dev); } Err(io::Error::new( diff --git a/crates/ely-lok/src/client/tests.rs b/crates/ely-lok/src/client/tests.rs index 48c989a..e887c17 100644 --- a/crates/ely-lok/src/client/tests.rs +++ b/crates/ely-lok/src/client/tests.rs @@ -157,20 +157,20 @@ fn the_worker_round_trips_a_document() { let source = dir.path().join("note.fodt"); write(&source, FODT); - let url = format!("file://{}", source.display()); + let url = crate::file_url(&source); let doc = worker.load(&url).expect("the document loads"); let (width, height) = doc.size(&mut worker).expect("the size"); assert!(width > 0 || height > 0, "a nonzero document size"); let odt = dir.path().join("note.odt"); - doc.save_as(&mut worker, &format!("file://{}", odt.display()), "odt") + doc.save_as(&mut worker, &crate::file_url(&odt), "odt") .expect("the odt save"); assert!(odt.try_exists().expect("the odt exists")); let doc2 = worker - .load(&format!("file://{}", odt.display())) + .load(&crate::file_url(&odt)) .expect("the odt reloads"); let pdf = dir.path().join("note.pdf"); - doc2.save_as(&mut worker, &format!("file://{}", pdf.display()), "pdf") + doc2.save_as(&mut worker, &crate::file_url(&pdf), "pdf") .expect("the pdf save"); let head = std::fs::read(&pdf).expect("the pdf reads"); assert!(head.starts_with(b"%PDF"), "a real PDF comes out"); @@ -187,7 +187,7 @@ fn a_missing_file_is_an_error_never_a_success() { let dir = tempfile::tempdir().expect("a temp dir"); let mut worker = Worker::spawn(&install, &dir.path().join("profile")).expect("the worker"); let missing = dir.path().join("gone.odt"); - let outcome = worker.load(&format!("file://{}", missing.display())); + let outcome = worker.load(&crate::file_url(&missing)); assert!(outcome.is_err(), "LOK-09: the failure is an error"); } @@ -209,7 +209,12 @@ fn a_failed_check_refuses_every_later_request() { /// Initializes the profile fully, the CLI way (registers Basic). fn seed_basic(install: &Path, profile: &Path) { + #[cfg(target_os = "macos")] let soffice = install.parent().expect("the app dir").join("MacOS/soffice"); + #[cfg(target_os = "linux")] + let soffice = install.join("soffice.bin"); + #[cfg(windows)] + let soffice = install.join("soffice.exe"); let url = ipc::file_url_of(profile); let status = std::process::Command::new(&soffice) .args(["--headless", "--norestore", "--terminate_after_init"]) @@ -249,7 +254,7 @@ fn a_macro_never_writes_its_marker() { let source = dir.path().join("macro.fodt"); std::fs::write(&source, macro_fodt(&marker)).expect("the macro fodt writes"); let doc = worker - .load(&format!("file://{}", source.display())) + .load(&crate::file_url(&source)) .expect("the macro document loads"); std::thread::sleep(std::time::Duration::from_millis(800)); assert!( @@ -313,7 +318,7 @@ fn shutdown_leaves_no_locks_or_temp() { let source = dir.path().join("note.fodt"); write(&source, FODT); let doc = worker - .load(&format!("file://{}", source.display())) + .load(&crate::file_url(&source)) .expect("the document loads"); let lock = dir.path().join(".~lock.note.fodt#"); worker.close_doc(doc).expect("the document closes"); @@ -347,7 +352,7 @@ fn each_load_is_its_own_session() { let mut worker = Worker::spawn(&install, &dir.path().join("profile")).expect("the worker spawns"); let source = dir.path().join("note.fodt"); write(&source, FODT); - let url = format!("file://{}", source.display()); + let url = crate::file_url(&source); // Freed native documents reuse addresses; sessions never repeat. let mut old = Vec::new(); for _ in 0..4 { @@ -375,7 +380,7 @@ fn a_document_belongs_to_its_worker() { let dir = tempfile::tempdir().expect("a temp dir"); let source = dir.path().join("note.fodt"); write(&source, FODT); - let url = format!("file://{}", source.display()); + let url = crate::file_url(&source); let mut first = Worker::spawn(&install, &dir.path().join("one")).expect("a worker"); let mut second = Worker::spawn(&install, &dir.path().join("two")).expect("another worker"); let theirs = first.load(&url).expect("loads"); diff --git a/crates/ely-lok/src/client/tests/commands.rs b/crates/ely-lok/src/client/tests/commands.rs index 03dcfe5..02ef6da 100644 --- a/crates/ely-lok/src/client/tests/commands.rs +++ b/crates/ely-lok/src/client/tests/commands.rs @@ -240,7 +240,7 @@ fn a_kernel_dialog_is_closed_and_reported() { let source = dir.path().join("in.fods"); write(&source, FODS); let doc = worker - .load(&format!("file://{}", source.display())) + .load(&crate::file_url(&source)) .expect("the sheet loads"); let base = poll_until(&mut worker, &doc, &Events::default(), SETTLE, |_| false); // Calc's Backspace opens Delete Contents. @@ -436,7 +436,7 @@ fn an_owned_save_clears_the_modified_state() { state(seen, ".uno:ModifiedStatus") == Some("true") }); let copy = dir.path().join("copy.docx"); - doc.save_owned(&mut worker, &format!("file://{}", copy.display()), "docx") + doc.save_owned(&mut worker, &crate::file_url(©), "docx") .expect("the owned save"); let saved = poll_until(&mut worker, &doc, &base, SETTLE, |seen| { state(seen, ".uno:ModifiedStatus") == Some("false") diff --git a/crates/ely-lok/src/client/tests/formula.rs b/crates/ely-lok/src/client/tests/formula.rs index ba60553..eb7f182 100644 --- a/crates/ely-lok/src/client/tests/formula.rs +++ b/crates/ely-lok/src/client/tests/formula.rs @@ -12,7 +12,7 @@ fn open() -> (tempfile::TempDir, Worker, Doc, Events) { let source = dir.path().join("in.fods"); write(&source, FODS); let doc = worker - .load(&format!("file://{}", source.display())) + .load(&crate::file_url(&source)) .expect("the sheet loads"); let seen = poll_until(&mut worker, &doc, &Events::default(), SETTLE, |seen| { seen.cell.cursor.is_some() @@ -29,7 +29,8 @@ fn type_text(worker: &mut Worker, doc: &Doc, text: &str) { /// The cells of `range`, as the kernel copies them. fn values(worker: &mut Worker, doc: &Doc, range: &str) -> String { worker.run(doc, "cell.goto", &[range]).expect("the range"); - doc.selection_text(worker).expect("the text") + // Windows ends copied lines with CRLF. + doc.selection_text(worker).expect("the text").replace("\r\n", "\n") } #[test] diff --git a/crates/ely-lok/src/client/tests/input.rs b/crates/ely-lok/src/client/tests/input.rs index 0e4916e..d6d054c 100644 --- a/crates/ely-lok/src/client/tests/input.rs +++ b/crates/ely-lok/src/client/tests/input.rs @@ -24,7 +24,7 @@ pub(super) fn open(body: &str) -> (tempfile::TempDir, Worker, Doc) { let source = dir.path().join("in.fodt"); write(&source, body); let doc = worker - .load(&format!("file://{}", source.display())) + .load(&crate::file_url(&source)) .expect("the document loads"); (dir, worker, doc) } @@ -32,7 +32,7 @@ pub(super) fn open(body: &str) -> (tempfile::TempDir, Worker, Doc) { /// The document saved as flat XML. pub(super) fn saved(dir: &tempfile::TempDir, worker: &mut Worker, doc: &Doc, name: &str) -> String { let out = dir.path().join(name); - doc.save_as(worker, &format!("file://{}", out.display()), "fodt") + doc.save_as(worker, &crate::file_url(&out), "fodt") .expect("the save works"); std::fs::read_to_string(&out).expect("the save reads") } @@ -210,7 +210,7 @@ fn copy_gives_text_and_html_and_paste_takes_html() { let target = dir.path().join("b.fodt"); write(&target, FODT); let other = worker - .load(&format!("file://{}", target.display())) + .load(&crate::file_url(&target)) .expect("b loads"); click(&mut worker, &other, 150, 150); worker @@ -312,7 +312,7 @@ fn a_calc_cut_finishes_without_a_dialog() { let source = root.join("in.fods"); write(&source, FODS); let doc = worker - .load(&format!("file://{}", source.display())) + .load(&crate::file_url(&source)) .expect("the sheet loads"); worker .post_key(&doc, 0, 1027 | 0x1000) @@ -320,7 +320,7 @@ fn a_calc_cut_finishes_without_a_dialog() { let copied = worker.copy(&doc).expect("copy runs"); worker.cut(&doc).expect("the cut runs"); let out = root.join("out.fods"); - doc.save_as(&mut worker, &format!("file://{}", out.display()), "fods") + doc.save_as(&mut worker, &crate::file_url(&out), "fods") .expect("the save works"); let xml = std::fs::read_to_string(&out).expect("the save reads"); done.send((copied.len(), xml.contains("Hello"))) @@ -367,7 +367,7 @@ fn a_table_survives_copy_and_paste() { let target = dir.path().join("b.fodt"); write(&target, FODT); let other = worker - .load(&format!("file://{}", target.display())) + .load(&crate::file_url(&target)) .expect("b loads"); click(&mut worker, &other, 150, 150); worker diff --git a/crates/ely-lok/src/client/tests/latency.rs b/crates/ely-lok/src/client/tests/latency.rs index 7aab2c6..4aa17c5 100644 --- a/crates/ely-lok/src/client/tests/latency.rs +++ b/crates/ely-lok/src/client/tests/latency.rs @@ -13,7 +13,7 @@ fn keys_never_wait_behind_the_event_watch() { let source = dir.path().join("in.fods"); write(&source, FODS); let doc = worker - .load(&format!("file://{}", source.display())) + .load(&crate::file_url(&source)) .unwrap(); let feed = worker.feed(); let shared = std::sync::Arc::new(std::sync::Mutex::new(worker)); diff --git a/crates/ely-lok/src/client/tests/output.rs b/crates/ely-lok/src/client/tests/output.rs index 9543896..0d0b3b0 100644 --- a/crates/ely-lok/src/client/tests/output.rs +++ b/crates/ely-lok/src/client/tests/output.rs @@ -44,8 +44,7 @@ fn a_locked_document_asks_for_its_password() { for password in ["", "wrong"] { let error = worker .unlock(&url, password) - .err() - .expect("a locked load fails"); + .expect_err("a locked load fails"); assert_eq!( error.kind(), std::io::ErrorKind::PermissionDenied, diff --git a/crates/ely-lok/src/client/tests/paint.rs b/crates/ely-lok/src/client/tests/paint.rs index f34e5da..3736788 100644 --- a/crates/ely-lok/src/client/tests/paint.rs +++ b/crates/ely-lok/src/client/tests/paint.rs @@ -16,7 +16,7 @@ fn a_tile_paints_real_pixels() { let source = dir.path().join("note.fodt"); write(&source, FODT); let doc = worker - .load(&format!("file://{}", source.display())) + .load(&crate::file_url(&source)) .expect("the document loads"); let tile = doc .paint_tile(&mut worker, (0, 0, 960, 960), (64, 64)) @@ -51,7 +51,7 @@ fn an_edit_bumps_the_generation_and_invalidates() { let source = dir.path().join("note.fodt"); write(&source, FODT); let doc = worker - .load(&format!("file://{}", source.display())) + .load(&crate::file_url(&source)) .expect("the document loads"); let base = poll_until(&mut worker, &doc, &Events::default(), QUIET, |_| false); let before = doc @@ -95,7 +95,7 @@ fn uno_commands_invalidate_and_repaint() { let source = dir.path().join("note.fodt"); write(&source, FODT); let doc = worker - .load(&format!("file://{}", source.display())) + .load(&crate::file_url(&source)) .expect("the document loads"); let base = poll_until(&mut worker, &doc, &Events::default(), QUIET, |_| false); let before = doc @@ -137,7 +137,7 @@ fn a_red_span_paints_red_bytes() { let source = dir.path().join("red.html"); write(&source, RED_HTML); let doc = worker - .load(&format!("file://{}", source.display())) + .load(&crate::file_url(&source)) .expect("the document loads"); let tile = doc .paint_tile(&mut worker, (0, 0, 2000, 1000), (64, 64)) @@ -162,12 +162,12 @@ fn a_key_press_edits_the_document() { let source = dir.path().join("note.fodt"); write(&source, FODT); let doc = worker - .load(&format!("file://{}", source.display())) + .load(&crate::file_url(&source)) .expect("the document loads"); click(&mut worker, &doc, 150, 150); worker.post_key(&doc, 0, 1280).expect("Return posts"); let out = dir.path().join("after.fodt"); - doc.save_as(&mut worker, &format!("file://{}", out.display()), "fodt") + doc.save_as(&mut worker, &crate::file_url(&out), "fodt") .expect("the save works"); let before = std::fs::read_to_string(&source).expect("the source reads"); let after = std::fs::read_to_string(&out).expect("the save reads"); @@ -187,7 +187,7 @@ fn selection_only_requests_keep_the_generation() { let source = dir.path().join("note.fodt"); write(&source, FODT); let doc = worker - .load(&format!("file://{}", source.display())) + .load(&crate::file_url(&source)) .expect("the document loads"); let base = poll_until(&mut worker, &doc, &Events::default(), QUIET, |_| false); click(&mut worker, &doc, 150, 150); @@ -211,7 +211,7 @@ fn bad_tile_geometry_is_an_error_and_the_worker_lives() { let source = dir.path().join("note.fodt"); write(&source, FODT); let doc = worker - .load(&format!("file://{}", source.display())) + .load(&crate::file_url(&source)) .expect("the document loads"); for (x, y, w, h, pw, ph) in [ (0, 0, 0, 960, 64, 64), @@ -243,7 +243,7 @@ fn a_long_wait_is_clamped_and_requests_flow() { let source = dir.path().join("note.fodt"); write(&source, FODT); let doc = worker - .load(&format!("file://{}", source.display())) + .load(&crate::file_url(&source)) .expect("the document loads"); let started = std::time::Instant::now(); let batch = worker @@ -270,7 +270,7 @@ fn polling_times_out_with_the_same_generation() { let source = dir.path().join("note.fodt"); write(&source, FODT); let doc = worker - .load(&format!("file://{}", source.display())) + .load(&crate::file_url(&source)) .expect("the document loads"); let base = poll_until(&mut worker, &doc, &Events::default(), QUIET, |_| false); let again = poll_until(&mut worker, &doc, &base, QUIET, |_| false); @@ -291,16 +291,16 @@ fn uno_commands_reach_their_own_document() { write(&first, FODT); write(&second, FODT); let a = worker - .load(&format!("file://{}", first.display())) + .load(&crate::file_url(&first)) .expect("a loads"); let b = worker - .load(&format!("file://{}", second.display())) + .load(&crate::file_url(&second)) .expect("b loads"); worker.run(&a, "edit.select_all", &[]).expect("select all"); worker.cut(&a).expect("the cut runs"); let saved = |doc: &Doc, worker: &mut Worker, name: &str| { let out = dir.path().join(name); - doc.save_as(worker, &format!("file://{}", out.display()), "fodt") + doc.save_as(worker, &crate::file_url(&out), "fodt") .expect("the save works"); std::fs::read_to_string(&out).expect("the save reads") }; @@ -327,7 +327,7 @@ fn writer_reports_its_page_rects() { let source = dir.path().join("note.fodt"); write(&source, FODT); let doc = worker - .load(&format!("file://{}", source.display())) + .load(&crate::file_url(&source)) .expect("the document loads"); let pages = doc.pages(&mut worker).expect("the page rects"); let (width, height) = doc.size(&mut worker).expect("the size"); @@ -361,7 +361,7 @@ fn the_page_follows_a_dark_theme_and_back() { let source = dir.path().join(name); write(&source, text); let doc = worker - .load(&format!("file://{}", source.display())) + .load(&crate::file_url(&source)) .expect("the document loads"); let area = (1440, 1440, 3840, 3840); let seen = poll_until(&mut worker, &doc, &Events::default(), QUIET, |_| false); diff --git a/crates/ely-lok/src/client/tests/print.rs b/crates/ely-lok/src/client/tests/print.rs index 09e9de9..68746b6 100644 --- a/crates/ely-lok/src/client/tests/print.rs +++ b/crates/ely-lok/src/client/tests/print.rs @@ -154,12 +154,13 @@ fn text_exports_write_the_shown_sheet_as_values() { let out = dir.path().join("out.csv"); doc.export_text(&mut worker, &crate::file_url(&out), ';') .expect("exports S1"); - let first = std::fs::read_to_string(&out).expect("reads"); + // Windows writes CSV lines with CRLF, as RFC 4180 does. + let first = std::fs::read_to_string(&out).expect("reads").replace("\r\n", "\n"); assert_eq!(first, "Name;Total\nSum;3\n", "values, not formulas"); doc.show_sheet(&mut worker, 1).expect("S2"); doc.export_text(&mut worker, &crate::file_url(&out), ';') .expect("exports S2"); - let second = std::fs::read_to_string(&out).expect("reads"); + let second = std::fs::read_to_string(&out).expect("reads").replace("\r\n", "\n"); assert_eq!(second, "Item;Note\nPen;\"a;b\"\nInk;2\n", "only S2"); } @@ -284,11 +285,16 @@ fn excel_caches_recompute_and_unknown_functions_show_errors() { let end = xml[at..].find("").expect("row one") + at + "".len(); std::fs::write(&sheet, format!("{}{row}{}", &xml[..at], &xml[end..])).expect("writes"); let excel = dir.path().join("excel.xlsx"); - let zip = std::process::Command::new("zip") + // Python's zipfile, as ooxml.py uses: no zip on Windows. + let pack = "import os, sys, zipfile +with zipfile.ZipFile(sys.argv[1], 'w', zipfile.ZIP_DEFLATED) as z: + for d, _, fs in os.walk('.'): + for f in fs: + z.write(os.path.join(d, f))"; + let zip = std::process::Command::new("python3") .current_dir(&unpacked) - .args(["-q", "-r"]) + .args(["-c", pack]) .arg(&excel) - .arg(".") .status() .expect("zip runs"); assert!(zip.success()); diff --git a/crates/ely-lok/src/client/tests/rules.rs b/crates/ely-lok/src/client/tests/rules.rs index 5142284..03f8f8c 100644 --- a/crates/ely-lok/src/client/tests/rules.rs +++ b/crates/ely-lok/src/client/tests/rules.rs @@ -50,7 +50,8 @@ fn open() -> (tempfile::TempDir, Worker, Doc, Events) { /// The cells of `range`, as the kernel copies them. fn cells(worker: &mut Worker, doc: &Doc, range: &str) -> String { worker.run(doc, "cell.goto", &[range]).expect("the range"); - doc.selection_text(worker).expect("the text") + // Windows ends copied lines with CRLF. + doc.selection_text(worker).expect("the text").replace("\r\n", "\n") } /// The latest report of `name`. @@ -112,7 +113,7 @@ fn number_formats_alignment_and_fills_apply() { // The cell properties of the document's own cell formats. let saved = |worker: &mut Worker| { let out = _dir.path().join("borders.fods"); - doc.save_as(worker, &format!("file://{}", out.display()), "fods") + doc.save_as(worker, &crate::file_url(&out), "fods") .expect("the save"); let xml = std::fs::read_to_string(&out).expect("the saved book"); let start = xml.find("").expect("cell formats"); diff --git a/crates/ely-lok/src/client/tests/sheet.rs b/crates/ely-lok/src/client/tests/sheet.rs index 8f3b1ab..32d6f4c 100644 --- a/crates/ely-lok/src/client/tests/sheet.rs +++ b/crates/ely-lok/src/client/tests/sheet.rs @@ -37,7 +37,7 @@ fn open_sheet() -> (tempfile::TempDir, Worker, Doc) { let source = dir.path().join("in.fods"); write(&source, FODS); let doc = worker - .load(&format!("file://{}", source.display())) + .load(&crate::file_url(&source)) .expect("the sheet loads"); (dir, worker, doc) } @@ -174,7 +174,7 @@ pub(super) fn open_book(text: &str) -> (tempfile::TempDir, Worker, Doc) { let source = dir.path().join("book.fods"); write(&source, text); let doc = worker - .load(&format!("file://{}", source.display())) + .load(&crate::file_url(&source)) .expect("the book loads"); doc.visible_area(&mut worker, (0, 0, 15000, 10000), (256, 3840)) .expect("the area"); diff --git a/crates/ely-lok/src/ffi.rs b/crates/ely-lok/src/ffi.rs index 6e7e6b7..781c2a3 100644 --- a/crates/ely-lok/src/ffi.rs +++ b/crates/ely-lok/src/ffi.rs @@ -185,7 +185,6 @@ impl Lok { call!(class, 10, F, self.kit, url_c.as_ptr()) } - #[cfg(target_os = "macos")] /// Answers the kernel's "input waiting?" without a run-loop peek. pub fn register_any_input(&self, waiting: extern "C" fn(*mut c_void, i32) -> bool) { static DATA: u8 = 0; @@ -196,29 +195,30 @@ impl Lok { call!(class, 24, F, self.kit, waiting, data); } - #[cfg(target_os = "macos")] - /// Runs LOK's loop until kit exit (R-01). - pub fn run_loop(&self) { - type PollCb = Option i32>; - type WakeCb = Option; - extern "C" fn poll(_: *mut c_void, _: i32) -> i32 { - 0 - } - extern "C" fn wake(_: *mut c_void) {} + /// Runs LOK's loop until kit exit; unipoll calls `poll`. + pub fn run_loop(&self, poll: Option, wake: Option) { + // VCL calls neither hook while their data is null. + static DATA: u8 = 0; + let data = std::ptr::addr_of!(DATA).cast_mut().cast(); let class = unsafe { (*self.kit).class }; - type F = unsafe extern "C" fn(*mut Kit, PollCb, WakeCb, *mut c_void); - call!( - class, - 12, - F, - self.kit, - Some(poll), - Some(wake), - std::ptr::null_mut() - ); + type F = unsafe extern "C" fn(*mut Kit, Option, Option, *mut c_void); + call!(class, 12, F, self.kit, poll, wake, data); } } +/// The kernel library: every build here merges its libraries. +#[cfg(target_os = "macos")] +pub const KERNEL: &str = "libmergedlo.dylib"; +#[cfg(target_os = "linux")] +pub const KERNEL: &str = "libmergedlo.so"; +#[cfg(windows)] +pub const KERNEL: &str = "mergedlo.dll"; + +/// Waits up to its microseconds; above zero, work was done. +pub type Poll = extern "C" fn(*mut c_void, i32) -> i32; +/// The kernel has work for its loop. +pub type Wake = extern "C" fn(*mut c_void); + impl Drop for Lok { fn drop(&mut self) { if self.kit.is_null() { @@ -244,37 +244,17 @@ struct Library { #[cfg(unix)] impl Library { - /// dlopens the LOK library, merged candidates in order. + /// dlopens the merged kernel from `install`. fn open(install: &Path) -> io::Result { use std::os::unix::ffi::OsStrExt; - let mut last = "no candidate".to_string(); - for name in [ - "libsofficeapp.dylib", - "libmergedlo.dylib", - "libsofficeapp.so", - "libmergedlo.so", - ] { - let path = install.join(name); - if !path.try_exists()? { - continue; - } - let c = CString::new(path.as_os_str().as_bytes()) - .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "the path holds a NUL"))?; - let handle = unsafe { libc::dlopen(c.as_ptr(), libc::RTLD_LAZY | libc::RTLD_GLOBAL) }; - if handle.is_null() { - last = dl_error(); - log::warn!("lok: dlopen {} failed: {last}", path.display()); - continue; - } - return Ok(Self { handle }); + let path = install.join(KERNEL); + let c = CString::new(path.as_os_str().as_bytes()) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "the path holds a NUL"))?; + let handle = unsafe { libc::dlopen(c.as_ptr(), libc::RTLD_LAZY | libc::RTLD_GLOBAL) }; + if handle.is_null() { + return Err(io::Error::other(format!("dlopen {}: {}", path.display(), dl_error()))); } - Err(io::Error::new( - io::ErrorKind::NotFound, - format!( - "no loadable LOK library under {}: {last}", - install.display() - ), - )) + Ok(Self { handle }) } /// dlsyms `name` from the library. @@ -308,36 +288,29 @@ struct Library { #[cfg(windows)] impl Library { - /// Loads `/sofficeapp.dll`, merged fallback. + /// Loads the merged kernel from `install`. fn open(install: &Path) -> io::Result { use std::os::windows::ffi::OsStrExt; use windows_sys::Win32::System::LibraryLoader::{ - LOAD_LIBRARY_SEARCH_DEFAULT_DIRS, LoadLibraryExW, + LOAD_LIBRARY_SEARCH_DEFAULT_DIRS, LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR, LoadLibraryExW, }; - for name in ["sofficeapp.dll", "mergedlo.dll"] { - let path = install.join(name); - if !path.try_exists()? { - continue; - } - let wide: Vec = path.as_os_str().encode_wide().chain(Some(0)).collect(); - let handle = unsafe { - LoadLibraryExW( - wide.as_ptr(), - std::ptr::null_mut(), - LOAD_LIBRARY_SEARCH_DEFAULT_DIRS, - ) - }; - if handle.is_null() { - return Err(io::Error::last_os_error()); - } - return Ok(Self { - handle: handle as isize, - }); + let path = install.join(KERNEL); + let wide: Vec = path.as_os_str().encode_wide().chain(Some(0)).collect(); + let handle = unsafe { + LoadLibraryExW( + wide.as_ptr(), + std::ptr::null_mut(), + // Its dependencies sit beside it, in program/. + LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS, + ) + }; + if handle.is_null() { + let error = io::Error::last_os_error(); + return Err(io::Error::other(format!("LoadLibrary {}: {error}", path.display()))); } - Err(io::Error::new( - io::ErrorKind::NotFound, - format!("no LOK library under {}", install.display()), - )) + Ok(Self { + handle: handle as isize, + }) } /// Gets `name` from the library. diff --git a/crates/ely-lok/src/ffi/document.rs b/crates/ely-lok/src/ffi/document.rs index e73918d..f2b01cf 100644 --- a/crates/ely-lok/src/ffi/document.rs +++ b/crates/ely-lok/src/ffi/document.rs @@ -80,25 +80,6 @@ impl Document { call!(class, 13, F, self.doc, callback, data); } - /// Paints the document into `buffer` at `width` x `height` (R-01). - pub fn paint_tile(&self, buffer: &mut [u8], width: i32, height: i32) { - let class = unsafe { (*self.doc).class }; - type F = unsafe extern "C" fn(*mut Doc, *mut u8, i32, i32, i32, i32, i32, i32); - call!( - class, - 9, - F, - self.doc, - buffer.as_mut_ptr(), - width, - height, - 0, - 0, - width * 15, - height * 15 - ); - } - /// Runs a UNO command with JSON `args` (LOK-08). pub fn post_uno(&self, command: &str, args: Option<&str>) -> io::Result<()> { self.dispatch(command, args, false) diff --git a/crates/ely-lok/src/handshake.rs b/crates/ely-lok/src/handshake.rs index b3c68a6..6ad10e8 100644 --- a/crates/ely-lok/src/handshake.rs +++ b/crates/ely-lok/src/handshake.rs @@ -3,12 +3,10 @@ use std::{io, path::Path}; use crate::client::{BUILD_ID, Worker}; -#[cfg(target_os = "macos")] use crate::ipc::{self, Pack, Unpack, tag}; -/// A tiny flat-XML document for the tile self-check. -#[cfg(target_os = "macos")] -const VERIFY_DOC: &str = "verify"; +/// Red text: a BGRA tile shows red in byte two. +const VERIFY_DOC: &str = "verify"; impl Worker { /// Rejects builds that are not the pinned one (LOK-01). @@ -16,13 +14,11 @@ impl Worker { if !build.contains(BUILD_ID) { return self.reject(format!("not the pinned build {BUILD_ID}: {build}")); } - #[cfg(target_os = "macos")] if !self.tile_self_check(profile_dir)? { return self.reject(format!( - "the pinned build {BUILD_ID} does not paint tiles (unpatched)" + "the pinned build {BUILD_ID} paints no BGRA red (unpatched, or another order)" )); } - let _ = profile_dir; Ok(()) } @@ -53,15 +49,14 @@ impl Worker { Ok(build) } - /// Paints one tile; the bytes it gained. - #[cfg(target_os = "macos")] + /// Paints the red text; whether BGRA red came out. pub(crate) fn tile_self_check(&mut self, profile_dir: &Path) -> io::Result { let doc = profile_dir.join("lok-verify.fodt"); std::fs::write(&doc, VERIFY_DOC)?; let url = ipc::file_url_of(&doc.canonicalize()?); let payload = self.request(tag::VERIFY, &Pack::new().str(&url).bytes())?; - let gained = Unpack::new(&payload).u64()?; - Ok(gained > 0) + let red = Unpack::new(&payload).u64()?; + Ok(red > 0) } } diff --git a/crates/ely-lok/src/ipc.rs b/crates/ely-lok/src/ipc.rs index da69c0c..4dc2cea 100644 --- a/crates/ely-lok/src/ipc.rs +++ b/crates/ely-lok/src/ipc.rs @@ -92,6 +92,9 @@ pub mod tag { pub const ERR: u8 = 101; } +/// How a refusal for a changed document begins. +pub const STALE: &str = "stale: "; + #[cfg(test)] mod tests { #[test] @@ -103,6 +106,3 @@ mod tests { assert!(super::path_of_file_url("file:///bad%zz").is_err()); } } - -/// How a refusal for a changed document begins. -pub const STALE: &str = "stale: "; diff --git a/crates/ely-lok/src/view/input/tests.rs b/crates/ely-lok/src/view/input/tests.rs index 67511e8..f06c2e8 100644 --- a/crates/ely-lok/src/view/input/tests.rs +++ b/crates/ely-lok/src/view/input/tests.rs @@ -81,7 +81,7 @@ fn a_failed_input_still_reports_the_revision_it_reached() { let mut worker = Worker::spawn(&install, &dir.path().join("profile")).expect("the worker"); let source = dir.path().join("book.fods"); std::fs::write(&source, crate::client::tests::input::FODS).expect("the fixture writes"); - let doc = worker.load(&format!("file://{}", source.display())).expect("the book loads"); + let doc = worker.load(&crate::file_url(&source)).expect("the book loads"); let worker = Mutex::new(worker); // Nothing merged to split: the kernel refuses. let input = Input::Run { commands: vec![("cells.split", Vec::new())], at: None }; diff --git a/crates/ely-lok/src/view/tests.rs b/crates/ely-lok/src/view/tests.rs index 46ecb74..20aa306 100644 --- a/crates/ely-lok/src/view/tests.rs +++ b/crates/ely-lok/src/view/tests.rs @@ -50,7 +50,7 @@ pub(super) fn live_view_of( let source = dir.path().join(name); std::fs::write(&source, body).expect("the fixture writes"); let doc = worker - .load(&format!("file://{}", source.display())) + .load(&crate::file_url(&source)) .expect("the document loads"); std::mem::forget(dir); let shared = Arc::new(Mutex::new(worker)); diff --git a/crates/ely-lok/src/view/tests/clip.rs b/crates/ely-lok/src/view/tests/clip.rs index f979daa..7fa569b 100644 --- a/crates/ely-lok/src/view/tests/clip.rs +++ b/crates/ely-lok/src/view/tests/clip.rs @@ -39,7 +39,7 @@ fn saved(view: &Entity, cx: &mut TestAppContext) -> String { let view = view.read(app); let mut worker = view.worker.lock().expect("the worker lock"); view.doc - .save_as(&mut worker, &format!("file://{}", out.display()), "fods") + .save_as(&mut worker, &crate::file_url(&out), "fods") .expect("the save works"); }); std::fs::read_to_string(&out).expect("the save reads") diff --git a/crates/ely-lok/src/view/tests/input.rs b/crates/ely-lok/src/view/tests/input.rs index 23a46fb..ca01001 100644 --- a/crates/ely-lok/src/view/tests/input.rs +++ b/crates/ely-lok/src/view/tests/input.rs @@ -20,7 +20,7 @@ fn saved(view: &Entity, cx: &mut TestAppContext) -> Vec { let view = view.read(app); let mut worker = view.worker.lock().expect("the worker lock"); view.doc - .save_as(&mut worker, &format!("file://{}", out.display()), "fodt") + .save_as(&mut worker, &crate::file_url(&out), "fodt") .expect("the save works"); }); paragraphs(&std::fs::read_to_string(&out).expect("the save reads")) @@ -263,7 +263,7 @@ fn the_clipboard_moves_html_tables_and_images() { let view = view.read(app); let mut worker = view.worker.lock().expect("the worker lock"); view.doc - .save_as(&mut worker, &format!("file://{}", out.display()), "fodt") + .save_as(&mut worker, &crate::file_url(&out), "fodt") .expect("the save works"); }); let xml = std::fs::read_to_string(&out).expect("the save reads"); diff --git a/crates/ely-lok/src/worker.rs b/crates/ely-lok/src/worker.rs index 1264967..999ee37 100644 --- a/crates/ely-lok/src/worker.rs +++ b/crates/ely-lok/src/worker.rs @@ -16,6 +16,14 @@ mod revision; mod slides; mod teardown; mod text; +#[cfg(windows)] +mod messages; +#[cfg(windows)] +use messages::serve; +#[cfg(target_os = "linux")] +mod unipoll; +#[cfg(target_os = "linux")] +use unipoll::serve; /// English kernel, desktop hints; macros stay off (LOK-11). const LOAD_OPTIONS: &str = "Language=en-US,DeviceFormFactor=desktop"; @@ -49,7 +57,12 @@ fn serve(lok: Lok, input: impl io::Read + Send + 'static) -> io::Result<()> { } std::thread::spawn(move || reader(input)); std::thread::spawn(events::push); - lok.run_loop(); + // Aqua never polls: GCD brings the requests. + extern "C" fn poll(_: *mut std::ffi::c_void, _: i32) -> i32 { + 0 + } + extern "C" fn wake(_: *mut std::ffi::c_void) {} + lok.run_loop(Some(poll), Some(wake)); Ok(()) } @@ -146,23 +159,6 @@ mod gcd { use _dispatch_main_q as _raw; } -#[cfg(not(target_os = "macos"))] -fn serve(lok: Lok, mut input: impl io::Read) -> io::Result<()> { - let lok = Box::leak(Box::new(lok)); - pin(lok); - lok.register_callback(password::on_kit); - std::thread::spawn(events::push); - loop { - match ipc::read_frame(&mut input)? { - Some((tag, id, payload)) => answer(lok, tag, id, &payload), - None => { - log::info!("lok-worker: stdin closed"); - teardown::teardown(0); - } - } - } -} - /// Handles one frame and writes its answer. fn answer(lok: &Lok, tag: u8, id: u64, payload: &[u8]) { let out = OUT.get().expect("the output is set"); @@ -375,15 +371,16 @@ fn docs() -> &'static Mutex> { DOCS.get_or_init(|| Mutex::new(std::collections::HashMap::new())) } -/// Loads and paints; the bytes gained (R-01). +/// Loads and paints red text; its BGRA red pixels (R-01). fn verify(lok: &Lok, url: &str) -> io::Result { let doc = lok.load(url, LOAD_OPTIONS)?; let mut tile = vec![0xABu8; 64 * 64 * 4]; - doc.paint_tile(&mut tile, 64, 64); + // Past the 2 cm margin, where the text sits. + doc.paint_rect(&mut tile, (64, 64), (0, 0, 3000, 3000)); drop(doc); - let gained = tile.iter().filter(|byte| **byte != 0xAB).count() as u64; - log::info!("lok-worker: tile self-check gained {gained} bytes"); - Ok(gained) + let red = tile.chunks_exact(4).filter(|px| px[2] > 200 && px[0] < 80 && px[1] < 80).count(); + log::info!("lok-worker: tile self-check found {red} red pixels"); + Ok(red as u64) } /// Borrows a live document for a read call. diff --git a/crates/ely-lok/src/worker/messages.rs b/crates/ely-lok/src/worker/messages.rs new file mode 100644 index 0000000..6342b83 --- /dev/null +++ b/crates/ely-lok/src/worker/messages.rs @@ -0,0 +1,133 @@ +//! Windows: requests ride the kernel's message loop to a window. + +use std::{ + ffi::c_void, + io, + sync::atomic::{AtomicPtr, AtomicUsize, Ordering}, +}; + +use windows_sys::Win32::{ + Foundation::{HWND, LPARAM, LRESULT, WPARAM}, + System::LibraryLoader::GetModuleHandleW, + UI::WindowsAndMessaging::{ + CreateWindowExW, DefWindowProcW, HWND_MESSAGE, PostMessageW, RegisterClassW, WM_APP, + WNDCLASSW, + }, +}; + +use super::{answer, events, lok, password, pin, quit, teardown}; +use crate::ffi::Lok; +use crate::ipc; + +const ASK: u32 = WM_APP + 1; +const CLOSED: u32 = WM_APP + 2; + +/// The message-only window the requests go to. +static WINDOW: AtomicPtr = AtomicPtr::new(std::ptr::null_mut()); + +/// Requests posted and not yet answered. +static PENDING: AtomicUsize = AtomicUsize::new(0); + +pub(super) fn serve(lok: Lok, input: impl io::Read + Send + 'static) -> io::Result<()> { + let lok = Box::leak(Box::new(lok)); + pin(lok); + lok.register_callback(password::on_kit); + lok.register_any_input(waiting); + WINDOW.store(window()?, Ordering::SeqCst); + std::thread::spawn(move || reader(input)); + std::thread::spawn(events::push); + // Unipoll needs hooks; the Windows loop never calls them. + extern "C" fn poll(_: *mut c_void, _: i32) -> i32 { + 0 + } + extern "C" fn wake(_: *mut c_void) {} + lok.run_loop(Some(poll), Some(wake)); + log::info!("lok-worker: the kernel's loop ended"); + teardown::teardown(0); +} + +/// A window on this thread that only takes messages. +fn window() -> io::Result { + let name: Vec = "ElyLokWorker\0".encode_utf16().collect(); + let instance = unsafe { GetModuleHandleW(std::ptr::null()) }; + let class = WNDCLASSW { + lpfnWndProc: Some(procedure), + hInstance: instance, + lpszClassName: name.as_ptr(), + ..unsafe { std::mem::zeroed() } + }; + if unsafe { RegisterClassW(&class) } == 0 { + return Err(io::Error::last_os_error()); + } + let made = unsafe { + CreateWindowExW( + 0, + name.as_ptr(), + std::ptr::null(), + 0, + 0, + 0, + 0, + 0, + HWND_MESSAGE, + std::ptr::null_mut(), + instance, + std::ptr::null(), + ) + }; + match made.is_null() { + true => Err(io::Error::last_os_error()), + false => Ok(made), + } +} + +fn reader(mut input: impl io::Read) { + loop { + let (message, request) = match ipc::read_frame(&mut input) { + Ok(Some(request)) => { + PENDING.fetch_add(1, Ordering::SeqCst); + (ASK, Box::into_raw(Box::new(request))) + } + Ok(None) => { + log::info!("lok-worker: stdin closed"); + (CLOSED, std::ptr::null_mut()) + } + Err(error) => { + log::warn!("lok-worker: frame read failed: {error}"); + quit(1); + } + }; + let window = WINDOW.load(Ordering::SeqCst); + if unsafe { PostMessageW(window, message, 0, request as LPARAM) } == 0 { + log::error!("lok-worker: a request did not post: {}", io::Error::last_os_error()); + quit(1); + } + if message == CLOSED { + return; + } + } +} + +/// Idle jobs yield to a waiting request. +extern "C" fn waiting(_: *mut c_void, _: i32) -> bool { + PENDING.load(Ordering::SeqCst) > 0 +} + +unsafe extern "system" fn procedure( + window: HWND, + message: u32, + wparam: WPARAM, + lparam: LPARAM, +) -> LRESULT { + match message { + ASK => { + let request = unsafe { Box::from_raw(lparam as *mut (u8, u64, Vec)) }; + let (tag, id, payload) = *request; + PENDING.fetch_sub(1, Ordering::SeqCst); + answer(lok(), tag, id, &payload); + 0 + } + CLOSED => teardown::teardown(0), + _ => unsafe { DefWindowProcW(window, message, wparam, lparam) }, + } +} diff --git a/crates/ely-lok/src/worker/unipoll.rs b/crates/ely-lok/src/worker/unipoll.rs new file mode 100644 index 0000000..86bfbc7 --- /dev/null +++ b/crates/ely-lok/src/worker/unipoll.rs @@ -0,0 +1,107 @@ +//! Linux: LOK runs its loop; requests arrive in its poll. + +use std::{ + ffi::c_void, + io, + sync::{ + Mutex, OnceLock, + atomic::{AtomicUsize, Ordering}, + mpsc::{Receiver, RecvTimeoutError, Sender, TryRecvError, channel}, + }, + time::Duration, +}; + +use super::{answer, events, lok, password, pin, quit, teardown}; +use crate::ffi::Lok; +use crate::ipc; + +/// A request, the kernel's wake, or the end. +enum Note { + Ask(u8, u64, Vec), + Woken, + Closed, +} + +type Notes = (Mutex>, Mutex>); + +static NOTES: OnceLock = OnceLock::new(); + +/// Requests read and not yet answered. +static PENDING: AtomicUsize = AtomicUsize::new(0); + +pub(super) fn serve(lok: Lok, input: impl io::Read + Send + 'static) -> io::Result<()> { + let lok = Box::leak(Box::new(lok)); + pin(lok); + lok.register_callback(password::on_kit); + lok.register_any_input(waiting); + let (send, receive) = channel(); + let reading = send.clone(); + NOTES + .set((Mutex::new(send), Mutex::new(receive))) + .map_err(|_| io::Error::other("the loop is served twice"))?; + std::thread::spawn(move || reader(input, reading)); + std::thread::spawn(events::push); + lok.run_loop(Some(poll), Some(wake)); + log::info!("lok-worker: the kernel's loop ended"); + teardown::teardown(0); +} + +fn reader(mut input: impl io::Read, notes: Sender) { + loop { + let note = match ipc::read_frame(&mut input) { + Ok(Some((tag, id, payload))) => { + PENDING.fetch_add(1, Ordering::SeqCst); + Note::Ask(tag, id, payload) + } + Ok(None) => { + log::info!("lok-worker: stdin closed"); + Note::Closed + } + Err(error) => { + log::warn!("lok-worker: frame read failed: {error}"); + quit(1); + } + }; + let closed = matches!(note, Note::Closed); + if notes.send(note).is_err() || closed { + return; + } + } +} + +/// Idle jobs yield to a waiting request. +extern "C" fn waiting(_: *mut c_void, _: i32) -> bool { + PENDING.load(Ordering::SeqCst) > 0 +} + +/// The kernel's wait: one request answered, or its own wake. +extern "C" fn poll(_: *mut c_void, timeout_us: i32) -> i32 { + let notes = &NOTES.get().expect("the loop is served").1; + let notes = notes.lock().expect("the notes lock"); + let note = match timeout_us { + 0 => notes.try_recv().map_err(|error| error == TryRecvError::Disconnected), + wait if wait < 0 => notes.recv().map_err(|_| true), + wait => notes + .recv_timeout(Duration::from_micros(wait as u64)) + .map_err(|error| error == RecvTimeoutError::Disconnected), + }; + drop(notes); + match note { + Ok(Note::Ask(tag, id, payload)) => { + PENDING.fetch_sub(1, Ordering::SeqCst); + answer(lok(), tag, id, &payload); + 1 + } + Ok(Note::Woken) => 1, + Ok(Note::Closed) | Err(true) => teardown::teardown(0), + Err(false) => 0, + } +} + +/// Another thread posted work for the kernel's loop. +extern "C" fn wake(_: *mut c_void) { + let notes = &NOTES.get().expect("the loop is served").0; + if notes.lock().expect("the notes lock").send(Note::Woken).is_err() { + log::info!("lok-worker: a wake after the loop ended"); + } +} diff --git a/packaging/licenses.sh b/packaging/licenses.sh index fadf8dd..929d736 100755 --- a/packaging/licenses.sh +++ b/packaging/licenses.sh @@ -25,10 +25,16 @@ case $slug in *) crates=() ;; esac python3 "$root/packaging/crates.py" "$slug" ${crates[@]+"${crates[@]}"} >"$out/rust-crates.txt" -lo=${ELY_LO_APP:-$root/engines/lo-src/instdir/LibreOffice.app}/Contents/Resources +case $(uname) in + Darwin) lo=${ELY_LO_APP:-$root/engines/lo-src/instdir/LibreOffice.app}/Contents/Resources ;; + *) lo=${ELY_LO_DIR:-$root/engines/lo-src/instdir} ;; +esac +# Windows builds name LibreOffice's licence license.txt. +lo_licence=$lo/LICENSE +[ -e "$lo_licence" ] || lo_licence=$lo/license.txt case $slug in ely-doc | ely-table | ely-show) - put LibreOffice "$lo/LICENSE" "$lo/LICENSE.html" "$lo/NOTICE" "$lo/CREDITS.fodt" + put LibreOffice "$lo_licence" "$lo/LICENSE.html" "$lo/NOTICE" "$lo/CREDITS.fodt" ;; ely-pdf) put MuPDF "$root/engines/mupdf-src/COPYING" @@ -53,16 +59,23 @@ case $slug in # shellcheck disable=SC2086 put "MuPDF/fonts/$name" $found done - put OpenSSL "$(pkg-config --variable=prefix libcrypto)/LICENSE.txt" + case $(uname) in + MINGW* | MSYS*) put OpenSSL "$root/engines/vcpkg/installed/x64-windows-static/share/openssl/copyright" ;; + *) put OpenSSL "$(pkg-config --variable=prefix libcrypto)/LICENSE.txt" ;; + esac put tessdata "${ELY_TESSDATA:-$root/engines/tessdata}/LICENSE" put GreatVibes "$root/crates/ely-pdf-engine/fonts/OFL.txt" ;; - ely-tree) - put Git "$root/engines/git-src/COPYING" "$root/engines/git-src/LGPL-2.1" - put git-lfs "$root/engines/git/share/doc/git-lfs/LICENSE.md" - ;; 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" ;; esac +# ElyTree carries Git; on Windows and Linux ElyMark does too. +if [ "$slug" = ely-tree ] || { [ "$slug" = ely-mark ] && [ "$(uname)" != Darwin ]; }; then + case $(uname) in + MINGW* | MSYS*) put Git "$root/engines/git/LICENSE.txt" ;; + *) put Git "$root/engines/git-src/COPYING" "$root/engines/git-src/LGPL-2.1" ;; + esac + put git-lfs "$root/engines/git/share/doc/git-lfs/LICENSE.md" +fi diff --git a/packaging/stage.sh b/packaging/stage.sh new file mode 100644 index 0000000..48d8435 --- /dev/null +++ b/packaging/stage.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Stages an app with its engines. Usage: stage.sh +set -euo pipefail + +root=$(cd "$(dirname "$0")/.." && pwd) +slug=${1:?app slug, such as ely-doc} +out=${2:?the folder to fill} +case $(uname) in + MINGW* | MSYS*) exe=.exe ;; + Linux) exe= ;; + *) + echo "stage.sh: Windows or Linux only" >&2 + exit 1 + ;; +esac +grep -q "^$slug " "$root/packaging/apps.tsv" || { + echo "stage.sh: unknown app $slug" >&2 + exit 1 +} +uses() { grep -q "^$1" "$root/apps/$slug/Cargo.toml"; } +need() { # path, what to run + [ -e "$1" ] || { + echo "stage.sh: no $1; run $2" >&2 + exit 1 + } +} +build() { cargo build --manifest-path "$root/Cargo.toml" --release "$@"; } + +rm -rf "$out" +mkdir -p "$out" +build -p "$slug" +cp "$root/target/release/$slug$exe" "$out/" +if uses ely-lok; then + lo=${ELY_LO_DIR:-$root/engines/lo-src/instdir} + need "$lo/program" scripts/build-libreoffice.sh + build -p ely-lok --bin ely-lok-worker + cp "$root/target/release/ely-lok-worker$exe" "$out/" + # The kernel in LibreOffice's layout, beside the app. + mkdir "$out/libreoffice" + cp -R "$lo/program" "$lo/share" "$lo/presets" "$out/libreoffice/" + cp "$lo/LICENSE.html" "$lo/NOTICE" "$lo/CREDITS.fodt" "$out/libreoffice/" + # Merged-library builds leave text placeholders. + while IFS= read -r stub; do + if [ "$(cat "$stub")" = "invalid - merged lib" ]; then + rm "$stub" + fi + done < <(find "$out/libreoffice/program" -type f -size -64c) + if [ -n "$exe" ]; then + # The kernel needs the VC++ runtime; it ships beside it. + crt=$(ls -d "/c/Program Files/Microsoft Visual Studio/2022/"*/VC/Redist/MSVC/*/x64/Microsoft.VC143.CRT | tail -n 1) + need "$crt" "the Visual Studio 2022 C++ tools" + cp "$crt"/*.dll "$out/libreoffice/program/" + fi +fi +if uses ely-pdf-engine; then + tessdata=${ELY_TESSDATA:-$root/engines/tessdata} + need "$tessdata/eng.traineddata" scripts/fetch-tessdata.sh + build -p ely-pdf-engine --bin ely-pdf-worker + cp "$root/target/release/ely-pdf-worker$exe" "$out/" + mkdir "$out/tessdata" + cp "$tessdata"/{eng,chi_sim,chi_tra}.traineddata "$out/tessdata/" +fi +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" diff --git a/patches/libreoffice/0042-svx-boost-json-needs-no-library.patch b/patches/libreoffice/0042-svx-boost-json-needs-no-library.patch new file mode 100644 index 0000000..6d42922 --- /dev/null +++ b/patches/libreoffice/0042-svx-boost-json-needs-no-library.patch @@ -0,0 +1,26 @@ +svx, sd: Boost.JSON needs no library to link + +diff --git a/svx/Library_svxcore.mk b/svx/Library_svxcore.mk +index 08daa4dd7..e20b9c529 100644 +--- a/svx/Library_svxcore.mk ++++ b/svx/Library_svxcore.mk +@@ -51,6 +51,7 @@ $(eval $(call gb_Library_set_precompiled_header,svxcore,svx/inc/pch/precompiled_ + + $(eval $(call gb_Library_add_defs,svxcore,\ + -DSVXCORE_DLLIMPLEMENTATION \ ++ -DBOOST_ALL_NO_LIB \ + )) + + $(eval $(call gb_Library_use_libraries,svxcore,\ +diff --git a/sd/Library_sd.mk b/sd/Library_sd.mk +index f29e8f280..7d04eb6be 100644 +--- a/sd/Library_sd.mk ++++ b/sd/Library_sd.mk +@@ -49,6 +49,7 @@ $(eval $(call gb_Library_set_include,sd,\ + + $(eval $(call gb_Library_add_defs,sd,\ + -DSD_DLLIMPLEMENTATION \ ++ -DBOOST_ALL_NO_LIB \ + -DSDUI_DLL_NAME=\"$(call gb_Library_get_runtime_filename,$(call gb_Library__get_name,sdui))\" \ + )) + diff --git a/patches/libreoffice/0043-lok-callbacks-flush-inline-everywhere.patch b/patches/libreoffice/0043-lok-callbacks-flush-inline-everywhere.patch new file mode 100644 index 0000000..ee9119e --- /dev/null +++ b/patches/libreoffice/0043-lok-callbacks-flush-inline-everywhere.patch @@ -0,0 +1,93 @@ +LOK: callbacks flush inline on every platform + +0001 made the callback queue flush inline on macOS, where Aqua never +runs posted user events. Elsewhere scheduleFlush posted a user event, +so a callback reached the client only after the next turn of the +main loop. Ely's worker posts a command and waits for its result and +states inside the same request, which runs inside that loop (unipoll +on Linux, a window message on Windows): the result never came. The +queue now flushes inline everywhere; the flush event, its link and +stop() are gone. + +diff --git a/desktop/source/lib/init.cxx b/desktop/source/lib/init.cxx +index 76a02127d..39b72fb1a 100644 +--- a/desktop/source/lib/init.cxx ++++ b/desktop/source/lib/init.cxx +@@ -1609,7 +1609,6 @@ extern "C" { + CallbackFlushHandler::CallbackFlushHandler(LibreOfficeKitDocument* pDocument, LibreOfficeKitCallback pCallback, void* pData) + : m_pDocument(pDocument), + m_pCallback(pCallback), +- m_pFlushEvent(nullptr), + m_pData(pData), + m_nDisableCallbacks(0) + { +@@ -1638,18 +1637,8 @@ CallbackFlushHandler::CallbackFlushHandler(LibreOfficeKitDocument* pDocument, Li + } + } + +-void CallbackFlushHandler::stop() +-{ +- if (m_pFlushEvent) +- { +- Application::RemoveUserEvent(m_pFlushEvent); +- m_pFlushEvent = nullptr; +- } +-} +- + CallbackFlushHandler::~CallbackFlushHandler() + { +- stop(); + } + + CallbackFlushHandler::queue_type2::iterator CallbackFlushHandler::toQueue2(CallbackFlushHandler::queue_type1::iterator pos) +@@ -2600,23 +2589,11 @@ void CallbackFlushHandler::invoke() + + m_queue1.clear(); + m_queue2.clear(); +- stop(); + } + + void CallbackFlushHandler::scheduleFlush() + { +-#if defined(MACOSX) +- // Aqua never runs posted user events: flush inline. +- invoke(); +-#else +- if (!m_pFlushEvent) +- m_pFlushEvent = Application::PostUserEvent(LINK(this, CallbackFlushHandler, FlushQueue)); +-#endif +-} +- +-IMPL_LINK_NOARG(CallbackFlushHandler, FlushQueue, void*, void) +-{ +- m_pFlushEvent = nullptr; ++ // Ely's worker waits on callbacks inside a request: flush inline. + invoke(); + } + +diff --git a/desktop/inc/lib/init.hxx b/desktop/inc/lib/init.hxx +index 51d690307..6c21850fc 100644 +--- a/desktop/inc/lib/init.hxx ++++ b/desktop/inc/lib/init.hxx +@@ -210,8 +210,6 @@ namespace desktop { + void enqueueUpdatedTypes(); + void enqueueUpdatedType( int type, const SfxViewShell* sourceViewShell, int viewId ); + +- void stop(); +- + /** we frequently want to scan the queue, and mostly when we do so, we only care about the element type + so we split the queue in 2 to make the scanning cache friendly. */ + queue_type1 m_queue1; +@@ -246,12 +244,9 @@ namespace desktop { + OString m_aViewRenderState; + int m_viewId = -1; // view id of the associated SfxViewShell + LibreOfficeKitCallback m_pCallback; +- ImplSVEvent* m_pFlushEvent; + void *m_pData; + int m_nDisableCallbacks; + std::recursive_mutex m_mutex; +- +- DECL_LINK(FlushQueue, void*, void); + }; + + struct DESKTOP_DLLPUBLIC LibLODocument_Impl : public _LibreOfficeKitDocument diff --git a/scripts/apply-lo-patches.sh b/scripts/apply-lo-patches.sh index a4e11e6..3364c55 100755 --- a/scripts/apply-lo-patches.sh +++ b/scripts/apply-lo-patches.sh @@ -15,6 +15,8 @@ holds() { rm -rf "$base" mkdir -p "$base" git -C "$base" init -q + # LF, as every tree here; Windows defaults convert. + git -C "$base" config core.autocrlf false for f in $files; do if git -C "$src" cat-file -e "HEAD:$f" 2>/dev/null; then mkdir -p "$base/$(dirname "$f")" diff --git a/scripts/build-libreoffice.sh b/scripts/build-libreoffice.sh index 5db0c36..4591c68 100755 --- a/scripts/build-libreoffice.sh +++ b/scripts/build-libreoffice.sh @@ -49,7 +49,6 @@ case $(uname) in # Collabora's LOKit set: headless, bundled libraries. options+=( --disable-gui - --enable-cairo-rgba --disable-dbus --disable-gio --disable-dconf @@ -73,6 +72,22 @@ case $(uname) in app=$src/instdir/share jobs=$(nproc) ;; + CYGWIN*) + # Unix-only switches; configure refuses them here. + kept=() + for option in "${options[@]}"; do + case $option in + --enable-bogus-pkg-config | --disable-cups | --disable-evolution2 | --disable-python) ;; + *) kept+=("$option") ;; + esac + done + options=("${kept[@]}" --with-visual-studio=2022 --disable-ccache --disable-cli --without-dotnet) + # Cygwin's own make, never Git Bash's; MSYSTEM misleads the build. + unset MSYSTEM MSYSTEM_PREFIX MINGW_PREFIX MSYSTEM_CHOST MSYSTEM_CARCH + PATH=/opt/lo/bin:$PATH + app=$src/instdir/share + jobs=$(nproc) + ;; *) echo "build-libreoffice.sh: no build for $(uname)" >&2 exit 1 diff --git a/tasks/BUGS.md b/tasks/BUGS.md index d46d2f2..7f44d2f 100644 --- a/tasks/BUGS.md +++ b/tasks/BUGS.md @@ -138,3 +138,12 @@ P0 次序:W-01、K-01、L-01、O-01、W-02、L-02、W-03、L-03、W-04、L-04 - [ ] W-04 Windows 打包:六个安装程序,带引擎、许可、文件类型;更新清单含 windows-x86_64 - [ ] CI-01 Gitea 工作流:打标签即在 macOS、Windows、Linux 运行器上构建、测试、打包并上传到发布 - [ ] REL-3 版本号 0.1.3,打标签触发运行器,三端产物与清单上传,官网指向新发布 + +## 0.1.3 之后(三端实测查出,非 P0) + +- [ ] P3-01 拼写建议:Windows 与 Linux 上右键不出拼写菜单(`tools::spelling_suggests_and_ignores`) +- [ ] P3-02 Windows:Calc 深色主题的图块为全黑(`paint::the_page_follows_a_dark_theme_and_back`) +- [ ] P3-03 Windows:放映图层大于一帧时分片缺行(`motion::a_layer_larger_than_one_frame_arrives_in_pieces`) +- [ ] P3-04 Windows:放映的页脚字段文字不在占位处(`playlist::field_text_lands_where_its_placeholder_is`) +- [ ] P3-05 Windows:辅助功能的段落选区不桥接(`commands::a11y_follows_the_focused_paragraph`) + diff --git a/tests/scripts/ooxml.py b/tests/scripts/ooxml.py index 91bbc80..7eafc08 100644 --- a/tests/scripts/ooxml.py +++ b/tests/scripts/ooxml.py @@ -4,6 +4,7 @@ import datetime import math import posixpath +import os import re import shutil import subprocess @@ -255,12 +256,14 @@ def selftest(case, path): missed.append(f"{part}: {pattern} found nothing to change") continue entries[part] = changed.encode() - with tempfile.NamedTemporaryFile(suffix=path[-5:]) as copy: - with zipfile.ZipFile(copy.name, "w") as archive: + # A folder, not an open file: Windows cannot reopen those. + with tempfile.TemporaryDirectory() as folder: + copy = os.path.join(folder, "mutated" + path[-5:]) + with zipfile.ZipFile(copy, "w") as archive: for name, body in entries.items(): archive.writestr(name, body) failures = [] - CASES[case](copy.name) + CASES[case](copy) if not failures: missed.append(f"{part}: {pattern} passed") failures = [f"a mutation went unnoticed: {what}" for what in missed]