From 466afe20a065f99f179723dfb7d36c0801f3a6d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 9 Oct 2026 01:23:06 -0400 Subject: [PATCH] No LibreOffice in Open With: built bundle claims nothing, no image is mounted (R2.15) --- agents.md | 6 ++++- crates/ely-lok/src/client/tests.rs | 24 ++++-------------- scripts/build-libreoffice.sh | 2 ++ scripts/extract-official-lo.sh | 17 +++++++++++++ scripts/inert-lo.sh | 17 +++++++++++++ tasks/TASKS.md | 1 + tests/engines/lok.sh | 40 ++++++------------------------ tests/scripts/lo-unregistered.sh | 25 +++++++++++++++++++ 8 files changed, 80 insertions(+), 52 deletions(-) create mode 100755 scripts/extract-official-lo.sh create mode 100755 scripts/inert-lo.sh create mode 100755 tests/scripts/lo-unregistered.sh diff --git a/agents.md b/agents.md index 51d302e..723819f 100644 --- a/agents.md +++ b/agents.md @@ -139,7 +139,10 @@ packaging/icons/build.sh 由 assets/icons 的母版渲染 icns、png、ico 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) +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(不挂载,无包,“未打补丁即拒绝”的单测用) +tests/scripts/lo-unregistered.sh “打开方式”里没有 LibreOffice,构建出的包不声明任何文件类型 tests/scripts/lokview.sh 真窗口里的 LokView:输入法、键入、点击、拖动,AX 树里的段落文本;ELY_CLIPBOARD_TEST=1 再测复制粘贴(会覆盖系统剪贴板) tests/scripts/doc-shell.sh 真窗口 ElyDoc:空白标签进 AX 树,启动后 Cmd+N 打开新建对话框(需辅助功能权限) tests/scripts/doc-props.sh 真窗口 ElyDoc 属性栏:段前段后连续键入、回车、失焦,读 AX 值与命令数(需辅助功能权限) @@ -257,3 +260,4 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely - 2026-10-06:ElyPDF 文字、图片与对象编辑(C11.3,PDF-03、04,§11.3)。字体(用户定):文档自带字体(能写新字时)或 MuPDF 内置的 Helvetica、Times、Courier(不嵌入,WinAnsi)与 Noto 中日韩(不嵌入,UTF-16 的 Adobe CJK 字体),不扫描系统字体;自带字体以 CID 字体(Identity-H,按字形号写)写入。对象(用户定):图片与表单 XObject。文字块以 MuPDF 的 stext 为准(同一读法,块序号稳定);可编辑的块须横排,且每个字形都在页面自身的内容流里(以不落盘的过滤试跑核对,表单里的字不可改)。改字是一步:用内容过滤的 `text_filter` 去掉该块的字形(按字形原点在用户空间 0.5 点内且字符相符,不靠白块遮盖),再在原首行基线处按框宽排版写新字(空格处折行,太长的词断开,行距沿用原块);所有检查(块文字未变、横排、字形都在页面内容里、字体画得出每个字、排版)都在开步之前,拒绝时文档与重做都不动;画不出某字即告知是哪个字,界面请用户换字体;换了字体、字号改变或新文字越出原块的框时先出预览(在快照副本上改并画出该区域,文档与撤销历史都不动),确认后才写。行距取首行与下一条不同基线的距离(同一基线的几段不算行),没有即字号的 1.2 倍。新文字在点击处起框。新增内容第一次写入时把页面原内容包进 `q … Q`(以 `%Ely` 标记,只包一次)。图片与表单:清点处理器按 `q`、`Q`、`cm` 跟踪,列出页面顶层的每次 `Do` 与其页面框;移动、缩放(右下角柄,保持比例、左上不动)以 `X = C·T·C⁻¹` 包住该次绘制重写内容流,删除即不写该次 `Do`,替换图片是新图片资源、按比例放进原框中央;添加图片落在当前页中央(像素按点、缩到页面一半以内)。MuPDF 的字体与图片资源缓存不随撤销与放弃回退(实测:预览后再写,字体引用指向已不存在的对象),每次放弃、撤销、重做后清空。编辑工具在“编辑”菜单(编辑文字和图片、添加文字、添加图片…),Esc 退出(键位上下文在窗口根上,页面与面板里都管用);替换、添加与删除图片和表单、拖动移动与缩放、写字与预览的确认都绑定发起的标签与视图版本,执行前视图须未变且无排队的编辑,否则提示不执行;任何编辑落地即关掉此前的预览;图层读取在回来时总清掉进行中的标记,编辑之前发出的读取作废后重读;右侧编辑面板:文字、字体、字号、颜色、框宽,应用、删除文字;对象:替换图片…、删除。真窗口(macOS)改过一次字:同字体同位置替换,标签为已编辑。 - 2026-10-06:ElyPDF 页面组织(C11.2,PDF-02)。worker 打开即开 MuPDF journal;插入空白页、从文件插入(整份 PDF 嫁接)、删除、移动、旋转(90 度倍数,写页面的 `/Rotate`)、裁剪(按所见的上右下左边距设 `/CropBox`,全零即去掉)、撤销与重做都是一步,回复带历史标记(worker 按 MuPDF 的撤销位置记每步是编辑还是保存,标记是最后一个已做的编辑;撤销重做跳过保存步)。删除时指向已删页的书签保留但失去跳转,链接移除,表单字段去掉已删页上的控件;页码标签由 MuPDF 的删除与插入随之平移。坏的编辑(删光、页码乱序、非 90 度、边距超出页面)拒绝并回滚整步。保存(Save、Save As 走外壳)先 `pdf_save_snapshot` 写出(不动内存中的文档与撤销历史,靠补丁 0001),再打开该副本以 `garbage=2` 紧缩写到工作目录(已删页的对象不留在文件里),加密文件以用户输入的密码重开副本、照原加密写出(密码只在 worker 内存);外壳读回字节、原子写入。提取(选中页写新文件,经 `ely_files::export`,不覆盖打开着的文件,被别的标签持有即拒绝)在快照副本上删去其余页再紧缩写出,原文档的撤销与重做都不动;拆分按每份页数写到所选文件夹(`名称-序号.pdf`,先以 `ely_files::claim_all` 占下全部目标,任一已存在即一个不写;原 ElyShow 的同名函数移入 ely-files 共用)。合并即从文件插入到末尾再另存;插入的页带着自己的批注、链接与表单字段(指向源文件自身页的链接改指插入后的页,字段并入表单,重名的加序号,如 `field0 2`(名称按需分配,长名也终止),源表单的默认外观 `/DA` 与默认对齐 `/Q` 给到没有自己值的字段树根,源表单的字体资源并入目标 `/DR`,同名而不同的字体改名为 `名称_2` 并改写字段外观里的引用(按 PDF 名称语法解 `#xx` 转义,写出时照样转义);源文件的书签不并入)。删除时表单控件按被删页的 `/Annots` 认定,不只看可省略的 `/P`。裁剪边距以所见为准:先按视图旋转、再按页面 `/Rotate` 换到页面自身方向,并按 `/UserUnit` 换算。编辑失败也通知外壳队列已动,等着的保存、关闭不会挂住。结构编辑后区间点选的起点作废。应用:每个视图一条编辑队列,一次一条、按序执行,落地后重读页数、尺寸与书签,清掉图块、文字、缩略图、找到的词与选择,视图版本加一,此前发出的读取回来即丢;查找在编辑后重查。修改标记是当前历史与载入或上次保存时的历史不同,撤销回到保存点即为已保存(外壳新增 `Engine::saved` 与 `Session::reverted`:引擎回报未修改时 Dirty 回到 Saved,保存中、冲突、失败不变)。左栏是页面缩略图(`side.rs`;有书签时上方分段切换到书签):点选、Shift 连选、Cmd 增减,右键菜单,拖到某页之前即移动(移动后仍选中),Delete 删除;无选中时作用于当前页。“页面”菜单与“编辑”菜单的撤销(Cmd+Z)、重做(Shift+Cmd+Z)。裁剪对话框以毫米输入。MuPDF 的构建改为在 `engines/mupdf-src`(reference 只读)打 `patches/mupdf` 后编译。 - 2026-10-06:ElyPDF 阅读(C11.1,PDF-01)。MuPDF 只链进 `ely-pdf-worker`(`crates/ely-pdf-engine`,build.rs 把 C 垫片 `shim/ely_mupdf.c` 与 `libmupdf.a`、`libmupdf-third.a` 只链给这个二进制),应用不链 MuPDF。build.rs 以协议、垫片与 worker 源码的散列为版本戳,应用启动 worker 先握手(`HELLO`),戳不同即拒绝(单测不重建 worker,旧二进制曾被误用)。worker 在打开第一个文件时才启动(PERF-01),`tests/scripts/pdf-idle.sh` 查空白窗口无子进程。垫片每个调用都在 `fz_try`/`fz_catch` 里,返回码加最后一条错误信息,MuPDF 的跳转从不越过 Rust。worker 与应用之间是 `ely-ipc` 的帧(从 ely-lok 抽出,两者共用):打开(锁定即报)、解锁、页数、页框、渲染一块(设备像素、缩放、旋转、深色)、按阅读顺序的字符与外框、页内搜索(每处命中及其全部四边形,跨行短语算一处,数量不设上限)、书签(JSON)、关闭;文件读不出只报错,worker 照常工作。查看器:打开时只读页数、前 100 页尺寸与书签,其余尺寸后台每批 100 页补齐;视图只排屏上的页(连续、单页、双页;适合宽度、适合页面、实际大小与缩放档;视图旋转 90°),按 512 设备像素一块请求,一次一块、由近及远,只留屏上的块(横纵两向),后台回来的块不在屏上即弃;画不出的块记下不再请求,换文档或重开前不重试;深色外观下页面按亮度反相(MuPDF `fz_invert_pixmap_luminance`,色相保留)。文字选择按字符、可跨页,复制按行换行,未读的页先读;查找每批 20 页在后台进行,命中逐个跳转并标出,Esc 关闭(组件库 617a8d6);书签栏用 `lists::Outline`,点即跳到页与位置。切换视图、缩放、旋转后仍停在正在读的那页。加密文件打开时问密码(外壳的密码框,见上一条),错了再问,取消即不打开。没有页的文件报错并关闭,不开标签。双页时同一行在视口内的两页都可为当前页;当前页只在横纵两向都可见的页里定,跳转在目标页左缘不在视口时横向移过去。图块按视口半开区间取。首次按下时文字未读,读到后照原按下处起选;查找结果按查找实例与所属标签核对,只在该标签为当前时跳转。书签为空时不留左栏。启动参数里的文件直接打开。 +- 2026-10-09(用户定:“打开方式”里不得出现 LibreOffice):系统会登记磁盘上能找到的 .app、挂载镜像里的 .app 与运行过的包,构建目录里的 `LibreOffice.app` 与测试挂载的官方镜像曾因此成为 xlsx 等的默认程序。现构建后由 `scripts/inert-lo.sh` 去掉其全部文件与 URL 声明、快速查看与聚焦插件,注销它,并在 `engines/` 放 `.metadata_never_index`;Ely 只取其 Frameworks 与 Resources。任何脚本与测试都不挂载 LibreOffice 镜像:LOK 探测用本构建的内核,单测所需的官方内核由 `scripts/extract-official-lo.sh` 以 7zz 解成无包的文件夹。`tests/scripts/lo-unregistered.sh` 以 NSWorkspace 核对 docx、xlsx、pptx、odt、csv 的“打开方式”里没有 LibreOffice。 diff --git a/crates/ely-lok/src/client/tests.rs b/crates/ely-lok/src/client/tests.rs index 6071b2e..87689e8 100644 --- a/crates/ely-lok/src/client/tests.rs +++ b/crates/ely-lok/src/client/tests.rs @@ -282,30 +282,16 @@ fn a_production_worker_refuses_run_macro() { #[cfg(target_os = "macos")] #[test] fn an_unpatched_build_is_rejected() { - let dmg = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../engines/LibreOffice_26.2.6_MacOS_aarch64.dmg"); - if !dmg.try_exists().expect("the dmg check") { - eprintln!("SKIP: no official dmg"); + // A plain folder: a mounted .app would be registered. + let frameworks = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../engines/lo-official/Contents/Frameworks"); + if !frameworks.try_exists().expect("the folder check") { + eprintln!("SKIP: run scripts/extract-official-lo.sh"); return; } - let mount = tempfile::tempdir().expect("a temp dir"); - let attached = std::process::Command::new("hdiutil") - .args(["attach", "-nobrowse", "-readonly", "-mountpoint"]) - .arg(mount.path()) - .arg(&dmg) - .status() - .expect("hdiutil runs"); - assert!(attached.success(), "the official dmg mounts"); - let frameworks = mount.path().join("LibreOffice.app/Contents/Frameworks"); let dir = tempfile::tempdir().expect("a temp dir"); let outcome = Worker::spawn(&frameworks, &dir.path().join("profile")); assert!(outcome.is_err(), "the unpatched build must be rejected"); - let detached = std::process::Command::new("hdiutil") - .args(["detach", "-quiet"]) - .arg(mount.path()) - .status() - .expect("hdiutil detaches"); - assert!(detached.success()); } #[test] diff --git a/scripts/build-libreoffice.sh b/scripts/build-libreoffice.sh index cf017f3..5d7f7c9 100755 --- a/scripts/build-libreoffice.sh +++ b/scripts/build-libreoffice.sh @@ -83,3 +83,5 @@ done echo "build-libreoffice.sh: dictionaries kept: $(ls "$extensions" | tr '\n' ' ')" # Ely's kernel defaults: Excel A1 formulas. cp "$root/packaging/libreoffice/ely.xcd" "$src/instdir/LibreOffice.app/Contents/Resources/registry/" + +"$root/scripts/inert-lo.sh" "$src/instdir/LibreOffice.app" diff --git a/scripts/extract-official-lo.sh b/scripts/extract-official-lo.sh new file mode 100755 index 0000000..362fae6 --- /dev/null +++ b/scripts/extract-official-lo.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Unpacks the official kernel's libraries, never mounting the image. +set -euo pipefail +root=$(cd "$(dirname "$0")/.." && pwd) +dmg=$root/engines/LibreOffice_26.2.6_MacOS_aarch64.dmg +out=$root/engines/lo-official +command -v 7zz >/dev/null || { echo "extract-official-lo.sh: needs 7zz (brew install sevenzip)" >&2; exit 1; } +[ -f "$dmg" ] || { echo "extract-official-lo.sh: no $dmg" >&2; exit 1; } +[ "$(stat -f %z "$dmg")" = 297798926 ] || { echo "extract-official-lo.sh: $dmg is not the pinned image" >&2; exit 1; } +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +7zz x -y -snld -o"$work" "$dmg" "LibreOffice/LibreOffice.app/Contents/Frameworks/*" "LibreOffice/LibreOffice.app/Contents/Resources/*" >/dev/null +rm -rf "$out" +mkdir -p "$out" +# A plain folder, not a bundle: nothing for macOS to register. +mv "$work/LibreOffice/LibreOffice.app/Contents" "$out/Contents" +echo "extract-official-lo.sh: $out/Contents/Frameworks" diff --git a/scripts/inert-lo.sh b/scripts/inert-lo.sh new file mode 100755 index 0000000..9ad4bc6 --- /dev/null +++ b/scripts/inert-lo.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# The built LibreOffice bundle holds libraries, nothing to register. +set -euo pipefail +app=${1:?usage: scripts/inert-lo.sh path/to/LibreOffice.app} +plist=$app/Contents/Info.plist +[ -f "$plist" ] || { echo "inert-lo.sh: no $plist" >&2; exit 1; } +lsregister=/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister +for key in CFBundleDocumentTypes CFBundleURLTypes UTExportedTypeDeclarations UTImportedTypeDeclarations; do + if /usr/libexec/PlistBuddy -c "Print :$key" "$plist" >/dev/null 2>&1; then + /usr/libexec/PlistBuddy -c "Delete :$key" "$plist" + fi +done +rm -rf "$app/Contents/PlugIns" "$app/Contents/Library/Spotlight" +"$lsregister" -u "$app" +# Spotlight would find the bundle and register it again. +touch "$(cd "$app/../../.." && pwd)/.metadata_never_index" +echo "inert-lo.sh: $app claims no files and is unregistered" diff --git a/tasks/TASKS.md b/tasks/TASKS.md index 55a15b3..b64954e 100644 --- a/tasks/TASKS.md +++ b/tasks/TASKS.md @@ -192,3 +192,4 @@ - [ ] R2.12 ElyTree:差异按文件类型着色,Markdown 不按代码高亮 - [ ] R2.13 ElyMark:紧凑写法的 GFM 表格(无对齐空格)在可视模式显示为表格 - [ ] R2.14 ElyMark:任务列表的勾选框与正文同大、对齐首行 +- [ ] R2.15 “打开方式”里不得出现 LibreOffice(用户 2026-10-09 加急,先于 R2.2 收尾):构建出的 LibreOffice.app 去掉文档类型与快速查看插件并注销;任何测试不挂载官方镜像(官方内核解到无包的文件夹);`tests/scripts/lo-unregistered.sh` 核对 diff --git a/tests/engines/lok.sh b/tests/engines/lok.sh index ca83e60..d34c825 100755 --- a/tests/engines/lok.sh +++ b/tests/engines/lok.sh @@ -4,27 +4,11 @@ set -euo pipefail root=$(cd "$(dirname "$0")/../.." && pwd) here=$root/tests/engines -expect=${ELY_LOK_EXPECT_TILE:-untouched} -frameworks=${ELY_LOK_DIR:-} +expect=${ELY_LOK_EXPECT_TILE:-written} +# Never mount a LibreOffice image: macOS would register it. +frameworks=${ELY_LOK_DIR:-$root/engines/lo-src/instdir/LibreOffice.app/Contents/Frameworks} work=$(mktemp -d) -mount= -cleanup() { - if [ -n "$mount" ]; then - for _ in 1 2 3 4 5 6 7 8 9 10; do - if hdiutil detach "$mount" -quiet 2>/dev/null; then - mount= - break - fi - sleep 1 - done - [ -z "$mount" ] || { - echo "FAIL: cannot detach $mount; left in place" >&2 - exit 1 - } - fi - rm -rf "$work" -} -trap cleanup EXIT +trap 'rm -rf "$work"' EXIT fail() { echo "FAIL: $*" >&2 exit 1 @@ -36,18 +20,10 @@ case $expect in written | untouched) ;; *) fail "ELY_LOK_EXPECT_TILE is written echo "SKIP lok: macOS only" exit 0 } -if [ -z "$frameworks" ]; then - dmg=$(ls "$root"/engines/LibreOffice_*_MacOS_aarch64.dmg 2>/dev/null | head -n 1 || true) - [ -n "$dmg" ] || { - echo "SKIP lok: set ELY_LOK_DIR, or put the LibreOffice dmg in engines/" - exit 0 - } - [ "$(stat -f %z "$dmg")" = 297798926 ] || fail "$dmg is not the pinned 26.2.6 aarch64 image" - mount=$work/mnt - mkdir "$mount" - hdiutil attach -nobrowse -readonly -mountpoint "$mount" "$dmg" >/dev/null - frameworks=$mount/LibreOffice.app/Contents/Frameworks -fi +[ -f "$frameworks/libmergedlo.dylib" ] || { + echo "SKIP lok: no kernel at $frameworks; run scripts/build-libreoffice.sh" + exit 0 +} exports=$(nm -gU "$frameworks/libmergedlo.dylib") for symbol in _libreofficekit_hook _libreofficekit_hook_2 _lok_preinit; do diff --git a/tests/scripts/lo-unregistered.sh b/tests/scripts/lo-unregistered.sh new file mode 100755 index 0000000..fb899d1 --- /dev/null +++ b/tests/scripts/lo-unregistered.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# No LibreOffice app may appear in Open With. +set -euo pipefail +root=$(cd "$(dirname "$0")/../.." && pwd) +[ "$(uname)" = Darwin ] || { echo "SKIP lo-unregistered: macOS only"; exit 0; } +fail() { echo "FAIL: $*" >&2; exit 1; } +app=$root/engines/lo-src/instdir/LibreOffice.app +if [ -d "$app" ]; then + for key in CFBundleDocumentTypes CFBundleURLTypes UTExportedTypeDeclarations UTImportedTypeDeclarations; do + ! /usr/libexec/PlistBuddy -c "Print :$key" "$app/Contents/Info.plist" >/dev/null 2>&1 || fail "$app claims $key" + done + [ ! -e "$app/Contents/PlugIns" ] || fail "$app keeps its Quick Look plug-ins" +fi +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +for ext in docx xlsx pptx odt csv; do : >"$work/sample.$ext"; done +cat >"$work/open.swift" <<'SWIFT' +import AppKit +for path in CommandLine.arguments.dropFirst() { + for app in NSWorkspace.shared.urlsForApplications(toOpen: URL(fileURLWithPath: path)) { print(app.path) } +} +SWIFT +found=$(swift "$work/open.swift" "$work"/sample.* | grep -i libreoffice || true) +[ -z "$found" ] || fail "Open With lists LibreOffice: $found" +echo "ok: no LibreOffice in Open With"