diff --git a/agents.md b/agents.md index ad053e6..296a90c 100644 --- a/agents.md +++ b/agents.md @@ -170,6 +170,7 @@ tests/scripts/pdf-recovery.sh 真窗口 ElyPDF:加锁文件的封存恢复点 tests/scripts/autosave.sh 真窗口 ElyDoc:自动保存开关、停手即写、外部改动不覆盖(需辅助功能权限) tests/scripts/print.sh 真窗口 ElyDoc:打印用文档自己的纸张,取消不动文档(需辅助功能权限) tests/scripts/show-fullscreen.sh 真窗口 ElyShow:F5 放映的窗口盖住整块屏幕(菜单栏与程序坞之上,需辅助功能权限) +tests/scripts/dock-macos.sh dist 里 ElyDoc、ElyTable、ElyShow 各启动数次:LOK worker 从未登记为程序坞应用,图块照出 tests/scripts/windows-installer.sh 假应用的安装程序:所选目录原有的文件经两次安装与卸载都在,旧版按注册表清理,文件被占用或程序在运行时什么也不改,写不进时放回旧版(缺 makensis 时 SKIP) tests/scripts/types.sh packaging/types.tsv 的文件类型在 Linux 桌面项、Windows 安装程序与 macOS 文档类型里一致,从不设为默认 tests/scripts/layout.sh 真窗口:六个应用在 640、800、1100 宽下无元素越出窗口左右,留截图(需辅助功能与屏幕录制权限) @@ -303,5 +304,5 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely - 2026-10-15(用户报:ElyMark 代码块后的“你好↵测试”离开输入框后成了“你好 测试”):单个换行照 Typora 的默认(编辑时保留,导出与打印不保留):组件库 93efa7f 起 `MarkdownReader::new`(屏上:可视、预览、右键与命令的读法)把软换行读作换行,`MarkdownReader::joined`(输出)照 GFM 读作空格;标题的大纲与目录文字照旧一行。ElyMark 的 HTML、PDF、富文本复制与纯文本导出用 `joined`(R6.7)。组件库 c31d092 与 26af4be:画出成一个换行的 CRLF 对回其起点(原先按在 CRLF 行末键入落在 CR 与 LF 之间),行内代码照留两字则逐字对回。 - 2026-10-15(用户报:ElyMark 源码模式“测”后的插入点画在“测”的中间):组件库 `CodeEditor` 原按“列 × m 的宽”放插入点、选区、按下处与输入法框,中日韩字与表情约宽两格。组件库 9fb607b 起按 gpui 排好的这一行定位(`editor::measure`,含着色带来的字重),块状与下划线插入点取所在字的宽;标尺与缩进参考线仍按列(R6.8)。上下移动仍按字数记目标列。cd61653:按下处取最近的字首或行末(gpui 的 `closest_index_for_x` 越过末字字首即给行末);上次绘制得出的括号与配对位置只属当时的文字(`Buffer` 每次改动换一个戳),输入法改字后、下次绘制前的测量不用它们(原先把新字切进不同字体段,DirectWrite 崩溃)。 - 2026-10-15(用户报:ElyMark 三、四级标题不显眼):组件库 5954132 起标题字号随正文按 Typora 的倍数(一至六级 2.25、1.75、1.5、1.25、1、1),一律加粗,六级为次要色,行高为字号的 1.3 倍(原为固定的 24、20、16、14 像素,正文 13,三、四级只是中等字重)(R6.9)。90bd37b:粗体与标题同一字重(原粗体为半粗,标题里的粗体反而更细,正文粗体随之由半粗改为粗,同 Typora);标题行高按自身字号取 1.3 倍。Windows 真窗口核对(`.work/r69`):可视模式各级标题分明、“你好↵测试”两行,源码模式“测”后的插入点落在两字之间。 -- 2026-10-15(用户报:macOS 打开一个文档,程序坞里有两个同样的图标):LOK worker 在应用包的 `Contents/MacOS` 里启动 AppKit,系统按包的 Info.plist 把它也登记为常规应用(`lsappinfo` 里应用与 worker 都是 Foreground),程序坞多一个图标;内核自己从不设激活策略。worker 在内核启动前(`Lok::hook` 打开内核之后、调钩子之前)载入同一目录的 Aqua 插件 `libvclplug_osxlo.dylib`,取内核自己的 `VCL_NSApplication`,设为辅助应用(`NSApplicationActivationPolicyAccessory`:无程序坞图标、无菜单栏,仍可建屏外窗口),内核随后复用同一实例;类不在或设不上即报错,不启动(`ffi/accessory.rs`)。实测(Mac mini):worker 从未是 Foreground,登记即为 UIElement,图块照出(R6.12)。 +- 2026-10-15(用户报:macOS 打开一个文档,程序坞里有两个同样的图标):LOK worker 在应用包的 `Contents/MacOS` 里启动 AppKit,系统按包的 Info.plist 把它也登记为常规应用(`lsappinfo` 里应用与 worker 都是 Foreground),程序坞多一个图标;内核自己从不设激活策略。worker 在内核启动 AppKit 之前(`Lok::hook` 打开内核之后、调钩子之前)把本进程主包信息里的 `LSUIElement` 设为真,AppKit 登记时即为辅助应用(无程序坞图标、无菜单栏,仍可建屏外窗口);钩子之后核对激活策略是辅助,不是即报错、不启动(`ffi/accessory.rs`)。原先在登记之后才改策略,审查实测 15 次启动有 11 次先登记为 Foreground 约 30 毫秒(R6.12 第 1 轮)。`tests/scripts/dock-macos.sh` 连续启动三个应用,从 worker 启动起采样其类型到首块图块,见 Foreground 即失败(R6.12)。 - 2026-10-15(用户报:ElyShow 编辑区滚轮与上下键不换页,三端):内核在未选中对象时把上下键当作滚动窗口(`fupoor.cxx`),Ely 的画布整页适合、无处可滚,滚轮同样。`LokView::turn_parts`(ElyShow 首次适合时调用)之后:整页可见时滚轮换页(`view/turn.rs`:每格一页;触控板一次手势只换一页,惯性不算,静止 250 毫秒或新手势再计),未选中对象、未编辑文字时上下键换页;视图发 `Turn`,ElyShow 从视图所示的页起算(连续换页累加),经缩略图条同一路径 `show_part`,到首末页即停;后台读回的当前页只在视图已无待落地输入时采用,排队的换页不被先前的读取拉回(R6.13)。 diff --git a/crates/ely-lok/src/ffi.rs b/crates/ely-lok/src/ffi.rs index 017eec0..752cb5d 100644 --- a/crates/ely-lok/src/ffi.rs +++ b/crates/ely-lok/src/ffi.rs @@ -84,7 +84,7 @@ impl Lok { let lib = Library::open(install)?; // Before the kit starts, so no Dock icon shows. #[cfg(target_os = "macos")] - accessory::accessory(install)?; + accessory::hide()?; let hook: Hook2 = unsafe { lib.symbol(b"libreofficekit_hook_2\0") .or_else(|_| lib.symbol(b"libreofficekit_hook\0")) @@ -102,6 +102,8 @@ impl Lok { "lok hook failed for {install_str}" ))); } + #[cfg(target_os = "macos")] + accessory::check()?; log::info!("lok: hooked {}", install_str); Ok(Self { kit, _lib: lib }) } diff --git a/crates/ely-lok/src/ffi/accessory.rs b/crates/ely-lok/src/ffi/accessory.rs index 7cfe911..932ad21 100644 --- a/crates/ely-lok/src/ffi/accessory.rs +++ b/crates/ely-lok/src/ffi/accessory.rs @@ -1,10 +1,9 @@ //! The worker stays out of the Dock (macOS). -use std::ffi::{CString, c_char, c_void}; +use std::ffi::{c_char, c_void}; use std::io; -use std::os::unix::ffi::OsStrExt as _; -use std::path::Path; +#[link(name = "Foundation", kind = "framework")] #[link(name = "objc")] unsafe extern "C" { fn objc_getClass(name: *const c_char) -> *mut c_void; @@ -12,36 +11,59 @@ unsafe extern "C" { fn objc_msgSend(); } -/// The kernel's Aqua plugin; it defines `VCL_NSApplication`. -const PLUGIN: &str = "libvclplug_osxlo.dylib"; /// NSApplicationActivationPolicyAccessory: no Dock icon, no menu bar. const ACCESSORY: isize = 1; -/// Makes the kernel's application an accessory before the kit starts. -pub fn accessory(install: &Path) -> io::Result<()> { - // Loaded for good; the kernel later opens the same image. - let path = install.join(PLUGIN); - let c = CString::new(path.as_os_str().as_bytes()).map_err(|_| io::Error::other("the path holds a NUL"))?; - if unsafe { libc::dlopen(c.as_ptr(), libc::RTLD_LAZY | libc::RTLD_GLOBAL) }.is_null() { - return Err(io::Error::other(format!("dlopen {}: {}", path.display(), super::dl_error()))); - } - // The kernel's own class, so it reuses this instance. - let class = unsafe { objc_getClass(c"VCL_NSApplication".as_ptr()) }; +type Id = *mut c_void; + +fn send() -> unsafe extern "C" fn() { + objc_msgSend as unsafe extern "C" fn() +} + +fn class(name: &std::ffi::CStr) -> io::Result { + let class = unsafe { objc_getClass(name.as_ptr()) }; if class.is_null() { - return Err(io::Error::other(format!("{PLUGIN} has no VCL_NSApplication class"))); + return Err(io::Error::other(format!("no {} class", name.to_string_lossy()))); } - type Shared = unsafe extern "C" fn(*mut c_void, *mut c_void) -> *mut c_void; - type Policy = unsafe extern "C" fn(*mut c_void, *mut c_void, isize) -> i8; - let send = objc_msgSend as unsafe extern "C" fn(); - let shared: Shared = unsafe { std::mem::transmute(send) }; - let policy: Policy = unsafe { std::mem::transmute(send) }; - let app = unsafe { shared(class, sel_registerName(c"sharedApplication".as_ptr())) }; - if app.is_null() { - return Err(io::Error::other("the kernel's application did not start")); + Ok(class) +} + +fn get(object: Id, selector: &std::ffi::CStr) -> io::Result { + type F = unsafe extern "C" fn(Id, Id) -> Id; + let f: F = unsafe { std::mem::transmute(send()) }; + let result = unsafe { f(object, sel_registerName(selector.as_ptr())) }; + if result.is_null() { + return Err(io::Error::other(format!("{} gave nothing", selector.to_string_lossy()))); } - let set = unsafe { policy(app, sel_registerName(c"setActivationPolicy:".as_ptr()), ACCESSORY) }; - if set == 0 { - return Err(io::Error::other("macOS refused the accessory policy")); + Ok(result) +} + +/// Marks this process a UI element before the kit registers it. +pub fn hide() -> io::Result<()> { + // The kit reads the bundle's LSUIElement when it registers. + let info = get(get(class(c"NSBundle")?, c"mainBundle")?, c"infoDictionary")?; + type Text = unsafe extern "C" fn(Id, Id, *const c_char) -> Id; + type Flag = unsafe extern "C" fn(Id, Id, i8) -> Id; + type Set = unsafe extern "C" fn(Id, Id, Id, Id); + let text: Text = unsafe { std::mem::transmute(send()) }; + let flag: Flag = unsafe { std::mem::transmute(send()) }; + let set: Set = unsafe { std::mem::transmute(send()) }; + unsafe { + let key = text(class(c"NSString")?, sel_registerName(c"stringWithUTF8String:".as_ptr()), c"LSUIElement".as_ptr()); + let yes = flag(class(c"NSNumber")?, sel_registerName(c"numberWithBool:".as_ptr()), 1); + set(info, sel_registerName(c"setObject:forKey:".as_ptr()), yes, key); + } + Ok(()) +} + +/// Fails unless the kernel's application came up an accessory. +pub fn check() -> io::Result<()> { + type Policy = unsafe extern "C" fn(Id, Id) -> isize; + let policy: Policy = unsafe { std::mem::transmute(send()) }; + let app = get(class(c"NSApplication")?, c"sharedApplication")?; + let found = unsafe { policy(app, sel_registerName(c"activationPolicy".as_ptr())) }; + if found != ACCESSORY { + return Err(io::Error::other(format!("the worker's activation policy is {found}, not accessory"))); } log::info!("lok: the worker is an accessory app"); Ok(()) diff --git a/tasks/TASKS.md b/tasks/TASKS.md index 1201608..863bf3a 100644 --- a/tasks/TASKS.md +++ b/tasks/TASKS.md @@ -270,6 +270,6 @@ - [x] R6.9 ElyMark 三、四级标题不显眼(用户报):各级字号随正文按 Typora 的倍数(2.25、1.75、1.5、1.25、1、1),一律加粗,六级为次要色(组件库渲染) - [x] R6.11 Windows 升级后旧文件仍在(R6.10 本机重装查出):安装程序只覆盖不删除,旧版 ElyMark 的 `git` 与 CEF 文件留在安装目录;安装程序记下所装的文件,升级与卸载只移去这些;文件被占用即什么也不改,失败时放回旧版。第 3 轮后(最后一轮,修完不再审查):备份按清单次序编号,近 260 字的路径照样升级与卸载;卸载先查清单本身,删不掉即放回全部 - [x] R6.10 重发 0.1.5(用户定:仍为 0.1.5):删去 v0.1.5 的全部文件,标签移到修复的提交,三端全部包重建并上传,本机重装(标签在 53630ce;推标签即起发布流程,其 Windows 与 Linux 任务建出并上传,与本机同时起的 Windows 构建撞上,本机那份未上传;macOS 任务未上传,dmg 由 Mac mini 本机建、公证、钉票;44 个文件,同名的源码包只留 Linux 的一份,见 P3-16;下载回的 ElyMark 安装程序与清单的散列与构建相同;本机六个应用 0.1.5 经新安装程序升级,ElyMark 目录无 `git`,冒烟启动均正常退出、日志无警告) -- [ ] R6.12 macOS 打开一个文档,程序坞里 ElyDoc、ElyTable、ElyShow 各出现两个图标(用户 2026-10-15 报):LOK worker 在应用包内启动 AppKit,系统按包的 Info.plist 把它也登记为常规应用(`lsappinfo` 两个 Foreground);worker 在内核启动前载入内核的 Aqua 插件,把内核自己的 `VCL_NSApplication` 设为辅助应用(无程序坞图标、无菜单栏,仍可建屏外窗口) +- [ ] R6.12 macOS 打开一个文档,程序坞里 ElyDoc、ElyTable、ElyShow 各出现两个图标(用户 2026-10-15 报):LOK worker 在应用包内启动 AppKit,系统按包的 Info.plist 把它也登记为常规应用(`lsappinfo` 两个 Foreground);worker 在内核启动 AppKit 前把本进程主包信息的 `LSUIElement` 设为真,登记即为辅助应用,钩子后核对策略(第 1 轮:原先登记后才改,15 次有 11 次先为 Foreground);`tests/scripts/dock-macos.sh` 在 Mac mini 上 30 次启动全为 UIElement,旧 worker 即失败 - [ ] R6.13 ElyShow 编辑区的滚轮与上下键不换页(用户报,三端;放映时正常):内核在未选中对象时把上下键当作滚动窗口,Ely 的画布整页适合、无处可滚;整页可见时滚轮换页(每格一页,触控板一次手势一页),未选中对象、未编辑文字时上下键换页,同缩略图条的路径,到首末页即停 - [ ] R6.14 重发 0.1.5(用户定:仍为 0.1.5,全部重建并重传):删去 v0.1.5 的全部文件,标签移到 R6.12、R6.13 的提交,三端全部包重建并上传,本机重装 diff --git a/tasks/reviews/R6.12-r1.md b/tasks/reviews/R6.12-r1.md new file mode 100644 index 0000000..9626c65 --- /dev/null +++ b/tasks/reviews/R6.12-r1.md @@ -0,0 +1,3 @@ +FAIL + +- crates/ely-lok/src/ffi/accessory.rs:38 — The worker still registers briefly as a foreground application: `sharedApplication` initializes AppKit under the parent bundle's regular identity before line 42 sets the accessory policy. On macOS 27.0.1 arm64, I built this revision and launched the worker under the shipped Doc/Table/Show bundle identities five times each. Eleven of fifteen launches reported `Foreground` before `UIElement`; one ElyTable launch reported those states at 0.1602 s and 0.1892 s respectively. All launches still loaded documents and painted tiles (`.work/R6.12-r1/dock-repeat.log`). This leaves a startup window for the second Dock entry and disproves the claim that the worker registers as UIElement from the outset; bundled apps default to the Dock-visible regular policy ([Apple documentation](https://developer.apple.com/documentation/appkit/nsapplication/activationpolicy-swift.enum/regular)). Establish the worker's agent identity before AppKit first registers it, preserving the main application's regular identity, and add a bundled-startup regression test that rejects any `Foreground` observation while checking document loading and rendering. diff --git a/tests/scripts/dock-macos.sh b/tests/scripts/dock-macos.sh new file mode 100755 index 0000000..78d5e3b --- /dev/null +++ b/tests/scripts/dock-macos.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# dock-macos.sh: the LOK worker never registers as a Dock app. +set -euo pipefail +[ "$(uname)" = Darwin ] || { echo "SKIP: macOS only"; exit 0; } +runs=${ELY_DOCK_RUNS:-5} +apps=("$@") +[ ${#apps[@]} -gt 0 ] || apps=(dist/ElyDoc.app dist/ElyTable.app dist/ElyShow.app) + +# One launch: samples the worker's type until a tile shows. +launch() { + local bin=$1 log=$2 start pid worker="" types="" asn i=0 + start=$(( $( [ -f "$log" ] && wc -l < "$log" || echo 0 ) + 1 )) + ELY_NO_DIALOG=1 ELY_SMOKE=hold ELY_LOG=debug "$bin" >/dev/null 2>&1 & + pid=$! + local end=$((SECONDS + 60)) shown=0 + while [ $SECONDS -lt $end ] && kill -0 "$pid" 2>/dev/null; do + [ -n "$worker" ] || worker=$(pgrep -P "$pid" -x ely-lok-worker || true) + if [ -n "$worker" ]; then + asn=$(lsappinfo find "pid=$worker" || true) + [ -z "$asn" ] || types+=" $(lsappinfo info -only applicationtype "$asn" | grep -o 'type="[A-Za-z]*"' || true)" + fi + i=$((i + 1)) + if [ $((i % 20)) -eq 0 ] && tail -n +"$start" "$log" 2>/dev/null | grep -q 'lok: tile ('; then shown=1; break; fi + done + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + local seen + seen=$(tr ' ' '\n' <<<"$types" | grep . | sort | uniq -c | tr -s ' \n' ' ') + echo "$(basename "$bin"): worker $worker:$seen" + [ "$shown" = 1 ] || { echo "FAIL: no tile shown"; return 1; } + [ -n "$worker" ] || { echo "FAIL: no worker started"; return 1; } + case $types in *Foreground*) echo "FAIL: the worker was a Dock app"; return 1 ;; esac + case $types in *UIElement*) ;; *) echo "FAIL: the worker's type was never seen"; return 1 ;; esac + tail -n +"$start" "$log" | grep -q 'lok: the worker is an accessory app' || { echo "FAIL: no accessory log"; return 1; } +} + +for app in "${apps[@]}"; do + [ -d "$app" ] || { echo "FAIL: $app is missing"; exit 1; } + bin=$(find "$app/Contents/MacOS" -maxdepth 1 -type f -name 'ely-*' ! -name 'ely-lok-worker' | head -1) + [ -n "$bin" ] || { echo "FAIL: $app has no app binary"; exit 1; } + log="$HOME/Library/Application Support/ElyOffice/$(basename "$bin")/ely.log" + for _ in $(seq 1 "$runs"); do launch "$bin" "$log"; done +done +echo "PASS: no LOK worker registered as a Dock app"