diff --git a/agents.md b/agents.md index 1548f2b..49001b4 100644 --- a/agents.md +++ b/agents.md @@ -265,7 +265,7 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely - 2026-10-09:文档的缩放锚点(R2.7)。窄于视口的文档居中,缩放原按视口坐标求锚点、漏了居中的偏移,锚点随缩放漂移;现锚点先减去缩放前的偏移,缩放后按新的偏移(`LokView::slack`,绘制同用)换回。按键与菜单的缩放(无指针)另保持首页左缘在视口内;指针与捏合的缩放保持指针下的点不动(文档边界以外即止)。 - 2026-10-09:退出(缺陷清单 X-01)。Cmd+Q 与应用菜单的“退出”由应用级动作接收:主窗口的宿主在 `Closing::own_window` 里登记自己的退出流程(`ely_ui::commands::route_quit`),任何窗口(含设置窗口)发出的退出都经它逐个关闭标签、询问未保存的;宿主的窗口已不在时才直接退出;没有宿主的应用(ElyTree)直接退出。`Host::quitting` 在退出开始时运行(ElyMark 先记下打开的标签)。原先只有主窗口根上截退出,设置窗口在前时 Cmd+Q 不问即退,并把这次运行记为干净结束、删掉恢复点。 - 2026-10-09:作者名与读不出的设置(缺陷清单 X-02)。签名用的名字取启动时读入的通用设置(`ely_ui::settings::current`;文件读不出时为默认值,设置窗口另以 Banner 写明),`ely_platform::user::named(作者名)` 为空即用账户名;不再在引擎启动时重读文件(原先 `settings.json` 读不出时 Doc、Table、Show 以致命错误退出)。LOK worker 记下设定的作者名,引擎重启照用它。`tests/scripts/bad-settings.sh` 核对读不出的设置下 ElyDoc 照样启动、文件不被覆盖。 -- 2026-10-09:每个应用只跑一份(缺陷清单 X-03)。`ely_ui::run` 在写日志之前取应用数据目录的 `instance.lock`(`ely_platform::instance::hold`;Unix 为 fcntl 记录锁,进程结束即放,被拒时以 `F_GETLK` 向系统问出持锁的进程号,不另存进程号文件,同时启动的几份也报得准;Windows 为 LockFileEx,不报进程号);原先第二份启动即清空第一份的工作目录、把它的恢复点当作干净结束删掉、改写崩溃锁。2026-10-14 起(用户报:已开着时再打开文件弹“已在运行”而不是新标签,各应用同此)第二份把文件交给运行中的那份后退出:持锁的那份在 127.0.0.1 开一个门(`instance::Door`,随机端口),端口、进程号与 32 字节随机令牌写在数据目录的 `instance.door`(先写临时文件再改名,Unix 为 0600);第二份先按当前目录把启动参数变成绝对路径,连上后发令牌与各路径(`ely-ipc` 的帧,Unix 为原字节,Windows 为 UTF-16),收到回执即以 0 退出、不弹框;门未开、连不上或令牌不符(旧的门文件)时 10 秒内重试,路径发出后不重试(不会开两次),仍不成即照旧报“已在运行……未收下文件”及原因。Windows 上第二份先 `AllowSetForegroundWindow` 让持锁的那份能到前台。收到的文件进 `ely_ui::on_open` 的同一队列(各应用不改),随后把最先开的窗口(主窗口)带到前台;不带文件的再次启动只带到前台。macOS 经访达打开文件本就交给运行中的那份。`instance` 的单测以两个进程核对交接与伪造令牌被拒;`tests/scripts/one-instance.sh`(macOS)核对第二份 ElyDoc 交出文件、第一份开出它且工作目录不动,本次未在 macOS 上跑;Windows 上以真窗口的 ElyMark 核对(第二份 132 毫秒退出,第一份多一个标签)。 +- 2026-10-09:每个应用只跑一份(缺陷清单 X-03)。`ely_ui::run` 在写日志之前取应用数据目录的 `instance.lock`(`ely_platform::instance::hold`;Unix 为 fcntl 记录锁,进程结束即放,被拒时以 `F_GETLK` 向系统问出持锁的进程号,不另存进程号文件,同时启动的几份也报得准;Windows 为 LockFileEx,不报进程号);原先第二份启动即清空第一份的工作目录、把它的恢复点当作干净结束删掉、改写崩溃锁。2026-10-14 起(用户报:已开着时再打开文件弹“已在运行”而不是新标签,各应用同此)第二份把文件交给运行中的那份后退出:持锁的那份在 127.0.0.1 开一个门(`instance::Door`,随机端口),端口、进程号与 32 字节随机令牌写在数据目录的 `instance.door`(先写临时文件再改名,Unix 为 0600);第二份先按当前目录把启动参数变成绝对路径,连上后先发令牌,门答“开”或“拒”之后才发各路径(`ely-ipc` 的帧,Unix 为原字节,Windows 为 UTF-16),收到回执即以 0 退出、不弹框;门未开、连不上、令牌被拒(旧的门文件)或答“开”之前断开时 10 秒内重试,路径发出后不重试(不会开两次),仍不成即照旧报“已在运行……未收下文件”及原因。门为每个来者各开一个线程,一个来者从连上到答完共 5 秒(零星补字不续期),令牌前的帧至多 64 字节、路径帧至多 128 KiB(`ely_ipc::read_capped`,超出即在分配前拒绝)。Windows 上第二份先 `AllowSetForegroundWindow` 让持锁的那份能到前台。每个应用的启动(`ely_ui::run`,`blank` 的 ElyMail、ElyAgent 同)在主窗口开出后装上交接的接收者(`opening::home`):先把主窗口带到前台(最小化的先复原),再把文件放进 `ely_ui::on_open` 的同一队列(各应用不改),然后才回执;主窗口已不在即不回执,第二份照报未收下。不带文件的再次启动只带到前台。主窗口关闭即退出应用(只剩设置窗口时进程原会一直持锁,却开不了文件)。macOS 经访达打开文件本就交给运行中的那份。`instance` 的单测以两个进程核对交接与伪造令牌被拒;`tests/scripts/one-instance.sh`(macOS)核对第二份 ElyDoc 交出文件、第一份开出它且工作目录不动,本次未在 macOS 上跑;Windows 上以真窗口的 ElyMark 核对(第二份 132 毫秒退出,第一份多一个标签)。 - 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`);`%TEMP%` 下的程序被系统的应用控制拦下,构建与探针都不放那里;cmake 与 ninja 须在 PATH;LibreOffice 的 configure 只认 VS 自带的 CMake(组件 `VC.CMake.Project`)。仓库与 `reference/` 一律 LF(`core.autocrlf false`、`core.eol lf`;`fetch-references.sh` 对每个上游照设)。Linux:WSL 的 Ubuntu 26.04;静态 libcrypto 另需其 pkg-config 列出的库(jitterentropy、zstd、z)。LibreOffice 在 Linux 照 Collabora 的 LOKit 配置构建(无图形后端、cairo RGBA、自带各库)。 - 2026-10-10:Windows 与 Linux 的内核与 worker(W-01、K-01,用户定:0.1.3 先三端发布)。内核只认合并库(`ffi::KERNEL`:`libmergedlo.dylib`、`libmergedlo.so`、`mergedlo.dll`;`sofficeapp` 在合并构建里是文本占位);Windows 载入时一并搜索该 DLL 所在的 `program/`。查找次序照旧,Windows 与 Linux 的包内位置是应用旁的 `libreoffice/program`,开发构建为 `engines/lo-src/instdir/program`。worker 的主循环:macOS 照旧以 GCD;Linux 以 unipoll,`runLoop` 的轮询回调里每次答一个请求,内核的唤醒回调往同一队列放信号(`worker/unipoll.rs`;回调的数据指针须非空,VCL 见空即不调用);Windows 也以 unipoll:LOK 不另起主线程,worker 的主线程跑 `runLoop`(Windows 的消息循环),请求经只收消息的窗口投到这条线程上执行(`worker/messages.rs`),同 macOS 的 GCD;原先 LOK 自起主线程、worker 在另一线程作答,载入表格即死锁。Windows 的循环不调轮询回调,但须传非空的回调,`isUnipoll` 只看它。补丁 `0043-lok-callbacks-flush-inline-everywhere.patch`:回调队列在各平台都即时冲出(原只 macOS),否则请求里等的命令结果与状态要等下一轮主循环才到。`0042-svx-boost-json-needs-no-library.patch`:svxcore 与 sd 定义 `BOOST_ALL_NO_LIB`,MSVC 不再按 Boost 的自动链接索要 `libboost_json`、`libboost_container`。Linux 的内核以 cairo 画图块(worker 设 `SAL_DISABLESKIA`;构建去掉 `--enable-cairo-rgba`,自带的 cairo 要清掉重建),字节为 BGRA,同 macOS 与 Windows;`getTileMode` 只看 cairo 开关,不可信。worker 启动核对在三端都画一段红字,按 BGRA 找不到红色即拒绝。`scripts/apply-lo-patches.sh` 的临时库设 `core.autocrlf false`(Windows 默认转换使任何前缀都对不上)。Windows 的 LibreOffice 在 Cygwin 下构建(`build-libreoffice.sh` 的 CYGWIN 分支:去掉 Unix 专用开关,加 VS 2022,用 `/opt/lo/bin` 的 make,清掉 Git Bash 带进来的 `MSYSTEM`);构建目录与 Cygwin 排除在 Defender 扫描之外(否则并行 configure 删不掉 `conftest.dir`)。英文词典扩展里的 Lightproof(Python 写的语法检查)在构建后删去:macOS 与 Linux 的内核不带 Python,它本不生效;Windows 必带内置 Python(Cygwin 下 configure 不许去掉),第二次用同一配置启动时初始化它,worker 挂起。worker 以 `SAL_NOLOCK_PROFILE` 运行,`ely.xcd` 关掉 LibreOffice 的文档锁(`Common/Misc/UseLocking`):被强杀的 worker 留下的锁会让下次启动或载入弹出交互而挂起;每个应用只有一个 worker 用自己的配置,冲突由 Ely 自己核对。三端发布(CI-01):`.gitea/workflows/release.yml` 在打标签时于三台运行器上执行 `scripts/ci.sh`:macOS 在 Mac mini 的常驻克隆里借用开发克隆 `~/Documents/GitHub/ElyOfficeSuite` 的引擎(`ELY_SHARE`;2026-10-11 起:从零构建 LibreOffice 超过 act_runner 默认的 3 小时,v0.1.3 的 Mac 任务被杀、无 dmg),Windows 在本机运行器上把 `engines` 与 `reference` 接到开发克隆,Linux 由同一台 Windows 运行器经 WSL 构建(服务器上的 Linux 运行器是一次性容器,建不了内核);各自打包后上传到该标签的发布。Windows 为 NSIS 安装程序,Linux 为 AppImage、deb、rpm 与 Arch 包(引擎构建与打包都经 `packaging/linux/container.sh` 进 Ubuntu 26.04 容器,WSL 里须有 docker;`ELY_SHARE` 的目录一并挂进容器),都由 `packaging/stage.sh` 分装引擎;每个平台都上传本提交的源码包(`packaging/source.sh`);更新清单只含 macOS(Windows 与 Linux 的自动安装未做)。 diff --git a/crates/ely-ipc/src/lib.rs b/crates/ely-ipc/src/lib.rs index e4e1be5..cc79edd 100644 --- a/crates/ely-ipc/src/lib.rs +++ b/crates/ely-ipc/src/lib.rs @@ -53,6 +53,11 @@ pub fn check_reply(asked: u64, got: u64) -> io::Result<()> { /// Reads (tag, id, payload); None at a clean stream end. pub fn read_frame(input: &mut impl Read) -> io::Result)>> { + read_capped(input, MAX_FRAME) +} + +/// As `read_frame`, refusing payloads over `cap` before allocating. +pub fn read_capped(input: &mut impl Read, cap: usize) -> io::Result)>> { let mut len = [0u8; 4]; // Only a stream ending between frames ends cleanly. let mut got = 0; @@ -66,10 +71,10 @@ pub fn read_frame(input: &mut impl Read) -> io::Result) } } let len = u32::from_le_bytes(len) as usize; - if len > MAX_FRAME { + if len > cap { return Err(io::Error::new( io::ErrorKind::InvalidData, - "frame over the cap", + format!("a {len} byte frame is over the {cap} byte cap"), )); } let mut tag = [0u8; 1]; diff --git a/crates/ely-platform/src/instance.rs b/crates/ely-platform/src/instance.rs index c087e80..7903091 100644 --- a/crates/ely-platform/src/instance.rs +++ b/crates/ely-platform/src/instance.rs @@ -161,7 +161,8 @@ mod tests { panic!("the first copy did not hold"); }; let (sender, heard) = std::sync::mpsc::channel(); - door.serve(move |files| sender.send(files).expect("heard")).expect("served"); + let sender = std::sync::Mutex::new(sender); + door.serve(move |files| sender.lock().expect("a sender").send(files).is_ok()).expect("served"); println!("HELD"); let files = heard.recv_timeout(std::time::Duration::from_secs(20)).expect("a hand-over"); println!("GOT {}", files[0].display()); diff --git a/crates/ely-platform/src/instance/door.rs b/crates/ely-platform/src/instance/door.rs index 438e4be..c2a05b6 100644 --- a/crates/ely-platform/src/instance/door.rs +++ b/crates/ely-platform/src/instance/door.rs @@ -3,9 +3,10 @@ use std::{ ffi::OsString, fs, - io::{self, Write}, + io::{self, Read, Write}, net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream}, path::{Path, PathBuf}, + sync::Arc, thread, time::{Duration, Instant}, }; @@ -15,8 +16,13 @@ const TOKEN: u8 = 1; const PATH: u8 = 2; const DONE: u8 = 3; const REFUSED: u8 = 4; -/// How long a stalled caller may hold the door. +const OPEN: u8 = 5; +/// How long one caller may take, start to end. const PATIENCE: Duration = Duration::from_secs(5); +/// The largest frame before the token is shown. +const SHOWN: usize = 64; +/// The largest path frame: a whole Windows path. +const LONGEST: usize = 128 * 1024; /// The running copy's listener; its token keeps others out. pub struct Door { @@ -39,46 +45,93 @@ impl Door { Ok(Door { listener, token }) } - /// Hands each caller's files to `deliver`, on its own thread. - pub fn serve(self, deliver: impl Fn(Vec) + Send + 'static) -> io::Result<()> { + /// Hands each caller's files to `deliver`; false means not taken. + pub fn serve( + self, + deliver: impl Fn(Vec) -> bool + Send + Sync + 'static, + ) -> io::Result<()> { + let deliver = Arc::new(deliver); thread::Builder::new() .name("instance door".into()) .spawn(move || { for stream in self.listener.incoming() { - if let Err(error) = stream.and_then(|stream| self.answer(stream, &deliver)) { - log::warn!("instance: a hand-over failed: {error}"); + let (token, deliver) = (self.token, deliver.clone()); + // A stalled caller must not hold up the next. + let answered = stream.and_then(|stream| { + thread::Builder::new() + .name("instance caller".into()) + .spawn(move || { + if let Err(error) = answer(&stream, &token, &*deliver) { + log::warn!("instance: a hand-over failed: {error}"); + } + }) + .map(drop) + }); + if let Err(error) = answered { + log::warn!("instance: a caller was not answered: {error}"); } } })?; Ok(()) } +} - fn answer(&self, mut stream: TcpStream, deliver: &impl Fn(Vec)) -> io::Result<()> { - stream.set_read_timeout(Some(PATIENCE))?; - let (tag, token) = next(&mut stream)?; - if tag != TOKEN || !same(&token, &self.token) { - ely_ipc::write_frame(&mut stream, REFUSED, 0, &[])?; - return Err(io::Error::new( - io::ErrorKind::PermissionDenied, - "a caller without the token", - )); - } - let mut files = Vec::new(); - loop { - match next(&mut stream)? { - (PATH, bytes) => files.push(path(bytes)?), - (DONE, _) => break, - (tag, _) => { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - format!("frame {tag} is not a hand-over's"), - )); - } +fn answer( + stream: &TcpStream, + token: &[u8], + deliver: &dyn Fn(Vec) -> bool, +) -> io::Result<()> { + let mut timed = Timed { + stream, + until: Instant::now() + PATIENCE, + }; + let (tag, shown) = next(&mut timed, SHOWN)?; + if tag != TOKEN || !same(&shown, token) { + ely_ipc::write_frame(&mut &*stream, REFUSED, 0, &[])?; + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "a caller without the token", + )); + } + ely_ipc::write_frame(&mut &*stream, OPEN, 0, &[])?; + let mut files = Vec::new(); + loop { + match next(&mut timed, LONGEST)? { + (PATH, bytes) => files.push(path(bytes)?), + (DONE, _) => break, + (tag, _) => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("frame {tag} is not a hand-over's"), + )); } } - log::info!("instance: another copy handed over {} files", files.len()); - deliver(files); - ely_ipc::write_frame(&mut stream, DONE, 0, &[]) + } + log::info!("instance: another copy handed over {} files", files.len()); + // No receipt: the caller reports the files untaken. + if !deliver(files) { + return Err(io::Error::other("the app is quitting and took no files")); + } + ely_ipc::write_frame(&mut &*stream, DONE, 0, &[]) +} + +/// A stream whose reads all end by one deadline. +struct Timed<'a> { + stream: &'a TcpStream, + until: Instant, +} + +impl Read for Timed<'_> { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + let left = self.until.saturating_duration_since(Instant::now()); + if left.is_zero() { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "a caller held the door too long", + )); + } + self.stream.set_read_timeout(Some(left))?; + (&mut &*self.stream).read(buf) } } @@ -86,18 +139,17 @@ impl Door { pub(super) fn hand(dir: &Path, files: &[PathBuf], wait: Duration) -> io::Result<()> { let deadline = Instant::now() + wait; loop { - match knock(dir) { - Ok((stream, token)) => match give(stream, &token, files)? { - true => return Ok(()), - // An old door file; the new one is coming. - false if Instant::now() < deadline => {} - false => { - return Err(io::Error::new( - io::ErrorKind::PermissionDenied, - "it refused the door's token", - )); - } - }, + // Nothing handed yet, so every failure here may retry. + match knock(dir).and_then(|(stream, token)| Ok((admit(&stream, &token)?, stream))) { + Ok((true, stream)) => return give(&stream, files), + // An old door file; the new one is coming. + Ok((false, _)) if Instant::now() < deadline => {} + Ok((false, _)) => { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "it refused the door's token", + )); + } Err(_) if Instant::now() < deadline => {} Err(error) => return Err(error), } @@ -121,30 +173,44 @@ fn knock(dir: &Path) -> io::Result<(TcpStream, Vec)> { let token = words.next().and_then(unhex).ok_or_else(bad)?; let stream = TcpStream::connect_timeout(&SocketAddr::from((Ipv4Addr::LOCALHOST, port)), PATIENCE)?; + stream.set_read_timeout(Some(PATIENCE))?; lend_foreground(pid); Ok((stream, token)) } -/// Sends the files; false when the door refuses the token. -fn give(mut stream: TcpStream, token: &[u8], files: &[PathBuf]) -> io::Result { - stream.set_read_timeout(Some(PATIENCE))?; - ely_ipc::write_frame(&mut stream, TOKEN, 0, token)?; - for file in files { - ely_ipc::write_frame(&mut stream, PATH, 0, &bytes(file))?; - } - ely_ipc::write_frame(&mut stream, DONE, 0, &[])?; - match next(&mut stream)? { - (DONE, _) => Ok(true), +/// Shows the token; false when the door refuses it. +fn admit(stream: &TcpStream, token: &[u8]) -> io::Result { + ely_ipc::write_frame(&mut &*stream, TOKEN, 0, token)?; + match next(&mut &*stream, SHOWN)? { + (OPEN, _) => Ok(true), (REFUSED, _) => Ok(false), - (tag, _) => Err(io::Error::new( - io::ErrorKind::InvalidData, - format!("it answered frame {tag}"), - )), + (tag, _) => Err(unexpected(tag)), } } -fn next(stream: &mut TcpStream) -> io::Result<(u8, Vec)> { - match ely_ipc::read_frame(stream)? { +/// Sends the files once admitted; never retried. +fn give(stream: &TcpStream, files: &[PathBuf]) -> io::Result<()> { + for file in files { + ely_ipc::write_frame(&mut &*stream, PATH, 0, &bytes(file))?; + } + ely_ipc::write_frame(&mut &*stream, DONE, 0, &[])?; + // The app may answer after the door's own wait. + stream.set_read_timeout(Some(PATIENCE * 2))?; + match next(&mut &*stream, SHOWN)? { + (DONE, _) => Ok(()), + (tag, _) => Err(unexpected(tag)), + } +} + +fn unexpected(tag: u8) -> io::Error { + io::Error::new( + io::ErrorKind::InvalidData, + format!("it answered frame {tag}"), + ) +} + +fn next(stream: &mut impl Read, cap: usize) -> io::Result<(u8, Vec)> { + match ely_ipc::read_capped(stream, cap)? { Some((tag, _, payload)) => Ok((tag, payload)), None => Err(io::Error::new( io::ErrorKind::UnexpectedEof, @@ -226,12 +292,22 @@ mod tests { fn opened(dir: &Path) -> std::sync::mpsc::Receiver> { let (sender, heard) = std::sync::mpsc::channel(); + let sender = std::sync::Mutex::new(sender); let door = Door::open(dir).expect("a door"); - door.serve(move |files| sender.send(files).expect("heard")) + door.serve(move |files| sender.lock().expect("a sender").send(files).is_ok()) .expect("served"); heard } + /// Writes a door file whose token is wrong. + fn forge(dir: &Path) -> String { + let door = dir.join(FILE); + let text = fs::read_to_string(&door).expect("the door"); + let (head, _) = text.rsplit_once(' ').expect("a token"); + fs::write(&door, format!("{head} {}\n", hex(&[7; 32]))).expect("a forged door"); + text + } + #[test] fn a_hand_over_brings_every_path_whole() { let dir = tempfile::tempdir().expect("a temp dir"); @@ -248,22 +324,91 @@ mod tests { #[test] fn a_wrong_token_hands_nothing_over() { + for _ in 0..12 { + let dir = tempfile::tempdir().expect("a temp dir"); + let heard = opened(dir.path()); + forge(dir.path()); + let refused = hand( + dir.path(), + &[PathBuf::from("x.md")], + Duration::from_millis(300), + ); + assert_eq!( + refused.map_err(|error| error.kind()), + Err(io::ErrorKind::PermissionDenied) + ); + assert!(heard.try_recv().is_err(), "nothing reached the copy"); + } + } + + #[test] + fn an_old_door_file_is_waited_out() { + for _ in 0..5 { + let dir = tempfile::tempdir().expect("a temp dir"); + let heard = opened(dir.path()); + let real = forge(dir.path()); + let door = dir.path().join(FILE); + let fixer = thread::spawn(move || { + thread::sleep(Duration::from_millis(100)); + fs::write(door, real).expect("the real door"); + }); + let files = vec![PathBuf::from("x.md")]; + hand(dir.path(), &files, Duration::from_secs(3)) + .expect("handed once the door is right"); + fixer.join().expect("fixed"); + assert_eq!(heard.recv_timeout(Duration::from_secs(2)), Ok(files)); + } + } + + #[test] + fn a_dripping_caller_neither_blocks_nor_stays() { let dir = tempfile::tempdir().expect("a temp dir"); let heard = opened(dir.path()); - let door = dir.path().join(FILE); - let text = fs::read_to_string(&door).expect("the door"); - let (head, _) = text.rsplit_once(' ').expect("a token"); - fs::write(&door, format!("{head} {}\n", hex(&[7; 32]))).expect("a forged door"); - let refused = hand( - dir.path(), - &[PathBuf::from("x.md")], - Duration::from_millis(300), + let text = fs::read_to_string(dir.path().join(FILE)).expect("the door"); + let port: u16 = text + .split_whitespace() + .next() + .and_then(|word| word.parse().ok()) + .expect("a port"); + let mut drip = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).expect("connected"); + let started = Instant::now(); + let dripper = thread::spawn(move || { + // A token frame's header, a byte at a time. + for byte in [32u8, 0, 0, 0, TOKEN, 0, 0, 0, 0, 0, 0, 0, 0] + .into_iter() + .chain([0; 32]) + { + if drip.write_all(&[byte]).is_err() { + return; + } + thread::sleep(Duration::from_millis(300)); + } + }); + let files = vec![PathBuf::from("x.md")]; + hand(dir.path(), &files, Duration::from_secs(2)).expect("handed past the drip"); + assert_eq!(heard.recv_timeout(Duration::from_secs(2)), Ok(files)); + dripper.join().expect("dripped"); + let held = started.elapsed(); + assert!( + held < PATIENCE + Duration::from_secs(2), + "the drip was cut at the deadline, not after {held:?}" ); - assert_eq!( - refused.map_err(|error| error.kind()), - Err(io::ErrorKind::PermissionDenied) + } + + #[test] + fn files_the_app_did_not_take_are_reported() { + let dir = tempfile::tempdir().expect("a temp dir"); + Door::open(dir.path()) + .expect("a door") + .serve(|_| false) + .expect("served"); + let started = Instant::now(); + let untaken = hand(dir.path(), &[PathBuf::from("x.md")], Duration::from_secs(3)); + assert!(untaken.is_err(), "untaken files are no success"); + assert!( + started.elapsed() < Duration::from_secs(1), + "sent paths are not sent again" ); - assert!(heard.try_recv().is_err(), "nothing reached the copy"); } #[test] diff --git a/crates/ely-ui/src/opening.rs b/crates/ely-ui/src/opening.rs index 28d5ed6..192692a 100644 --- a/crates/ely-ui/src/opening.rs +++ b/crates/ely-ui/src/opening.rs @@ -1,71 +1,84 @@ //! Files handed to the app: arguments, Finder, second copies. -use std::{io, path::PathBuf}; +use std::{io, path::PathBuf, time::Duration}; use ely_platform::instance::Door; use futures::{StreamExt, channel::mpsc}; -use gpui::{App, Application, Global}; +use gpui::{AnyWindowHandle, App, Application, Global}; -/// A request to the running app. -pub(crate) enum Ask { - Open(PathBuf), - /// A second copy started: come to the front. - Raise, -} +/// How long a hand-over waits for the window. +const TAKING: Duration = Duration::from_secs(5); -struct Asked(Option>); +struct Asked(Option>); impl Global for Asked {} +/// A second copy's files; `taken` answers once they are queued. +pub(crate) struct Hand { + files: Vec, + taken: std::sync::mpsc::Sender<()>, +} + +/// What the system and other copies hand the running app. +pub(crate) struct Handed { + asked: mpsc::UnboundedReceiver, + hands: Hands, +} + +/// Second copies' hand-overs, for the main window. +pub(crate) struct Hands { + hands: mpsc::UnboundedReceiver, + queue: mpsc::UnboundedSender, +} + /// Launch arguments, relative to where the app was started. pub(crate) fn launch_args() -> io::Result> { std::env::args_os().skip(1).map(std::path::absolute).collect() } /// Queues `files`, then files the system or copies ask for. -pub(crate) fn listen(application: &Application, files: Vec, door: Door) -> io::Result> { - let (sender, asked) = mpsc::unbounded(); +pub(crate) fn listen(application: &Application, files: Vec, door: Door) -> io::Result { + let (queue, asked) = mpsc::unbounded(); for path in files { - sender.unbounded_send(Ask::Open(path)).ok(); + queue.unbounded_send(path).ok(); } - let handed = sender.clone(); + let (sender, hands) = mpsc::unbounded(); door.serve(move |files| { - for path in files { - handed.unbounded_send(Ask::Open(path)).ok(); - } - handed.unbounded_send(Ask::Raise).ok(); + let (taken, took) = std::sync::mpsc::channel(); + sender.unbounded_send(Hand { files, taken }).is_ok() && took.recv_timeout(TAKING).is_ok() })?; + let system = queue.clone(); application.on_open_urls(move |urls| { for url in urls { match file_path(&url) { Some(path) => { log::info!("run: the system asks to open a file"); - sender.unbounded_send(Ask::Open(path)).ok(); + system.unbounded_send(path).ok(); } None => log::warn!("run: not a file URL, ignored"), } } }); - Ok(asked) + Ok(Handed { asked, hands: Hands { hands, queue } }) } -pub(crate) fn keep(asked: mpsc::UnboundedReceiver, cx: &mut App) { - cx.set_global(Asked(Some(asked))); +/// Keeps the files for `on_open`; the hand-overs go to `home`. +pub(crate) fn keep(handed: Handed, cx: &mut App) -> Hands { + cx.set_global(Asked(Some(handed.asked))); + handed.hands } /// No files asked at launch, for tests. #[cfg(any(test, feature = "test-support"))] pub fn install_no_launch_files(cx: &mut App) { - keep(mpsc::unbounded().1, cx); + cx.set_global(Asked(Some(mpsc::unbounded().1))); } /// Files asked before the app started: arguments and Finder's. pub fn launch_files(cx: &mut App) -> Vec { let asked = cx.global_mut::().0.as_mut(); let asked = asked.expect("launch_files comes before on_open"); - let pending = std::iter::from_fn(|| asked.try_recv().ok()); - // The window opens in front anyway. - pending.filter_map(|ask| if let Ask::Open(path) = ask { Some(path) } else { None }).collect() + std::iter::from_fn(|| asked.try_recv().ok()).collect() } /// Hands each file asked from now on to `open`. @@ -76,26 +89,34 @@ pub fn on_open(cx: &mut App, mut open: impl FnMut(PathBuf, &mut App) + 'static) .take() .expect("on_open runs once per app"); cx.spawn(async move |cx| { - while let Some(ask) = asked.next().await { - cx.update(|cx| match ask { - Ask::Open(path) => open(path, cx), - Ask::Raise => raise(cx), - }); + while let Some(path) = asked.next().await { + cx.update(|cx| open(path, cx)); } }) .detach(); } -/// Brings the main window, the first opened, to the front. -fn raise(cx: &mut App) { - cx.activate(true); - let Some(window) = cx.windows().into_iter().next() else { - log::warn!("run: no window to bring to the front"); - return; - }; - if let Err(error) = window.update(cx, |_, window, _| window.activate_window()) { - log::warn!("run: the window could not come to the front: {error}"); - } +/// Brings `main` forward for each hand-over, then queues its files. +pub(crate) fn home(hands: Hands, main: AnyWindowHandle, cx: &mut App) { + let Hands { mut hands, queue } = hands; + cx.spawn(async move |cx| { + while let Some(hand) = hands.next().await { + let raised = cx.update(|cx| { + cx.activate(true); + main.update(cx, |_, window, _| window.activate_window()) + }); + // No answer: the other copy says the files went untaken. + if let Err(error) = raised { + log::warn!("run: the main window is gone, so a hand-over is refused: {error}"); + continue; + } + for path in hand.files { + queue.unbounded_send(path).ok(); + } + hand.taken.send(()).ok(); + } + }) + .detach(); } /// A `file://` URL's path, percent escapes decoded. @@ -143,3 +164,57 @@ mod tests { assert_eq!(file_path("file:///a/%4"), None); } } + +#[cfg(test)] +mod home_tests { + use super::*; + use gpui::{Context, IntoElement, Render, TestAppContext, Window}; + + struct Blank; + + impl Render for Blank { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + gpui::div() + } + } + + /// `home` on `main`; returns its hand-over and file queues. + fn homed(main: AnyWindowHandle, cx: &mut TestAppContext) -> (mpsc::UnboundedSender, mpsc::UnboundedReceiver) { + let (sender, hands) = mpsc::unbounded(); + let (queue, asked) = mpsc::unbounded(); + cx.update(|cx| home(Hands { hands, queue }, main, cx)); + (sender, asked) + } + + fn handed(sender: &mpsc::UnboundedSender) -> std::sync::mpsc::Receiver<()> { + let (taken, took) = std::sync::mpsc::channel(); + sender.unbounded_send(Hand { files: vec![PathBuf::from("x.md")], taken }).expect("sent"); + took + } + + #[gpui::test] + fn a_hand_over_raises_the_main_window_then_queues(cx: &mut TestAppContext) { + let main: AnyWindowHandle = cx.add_window(|_, _| Blank).into(); + let _settings = cx.add_window(|_, _| Blank); + let (sender, mut asked) = homed(main, cx); + assert_ne!(cx.update(|cx| cx.active_window()), Some(main), "another window is in front"); + let took = handed(&sender); + cx.run_until_parked(); + assert_eq!(cx.update(|cx| cx.active_window()), Some(main)); + assert_eq!(asked.try_recv().ok(), Some(PathBuf::from("x.md"))); + assert!(took.try_recv().is_ok(), "the other copy hears it was taken"); + } + + #[gpui::test] + fn a_hand_over_without_the_main_window_is_refused(cx: &mut TestAppContext) { + let main: AnyWindowHandle = cx.add_window(|_, _| Blank).into(); + let _settings = cx.add_window(|_, _| Blank); + let (sender, mut asked) = homed(main, cx); + main.update(cx, |_, window, _| window.remove_window()).expect("closed"); + cx.run_until_parked(); + let took = handed(&sender); + cx.run_until_parked(); + assert!(took.try_recv().is_err(), "no answer, so the other copy reports it"); + assert!(asked.try_recv().is_err(), "nothing queued for a gone window"); + } +} diff --git a/crates/ely-ui/src/run.rs b/crates/ely-ui/src/run.rs index d9d331a..81f4444 100644 --- a/crates/ely-ui/src/run.rs +++ b/crates/ely-ui/src/run.rs @@ -36,10 +36,10 @@ pub fn run( startup::no_window_restore(); let smoke = Smoke::from_env(); let application = gpui_platform::application().with_assets(Assets); - let asked = crate::opening::listen(&application, files, door).unwrap_or_else(|error| startup::fail(app, &format!("the door for other copies did not open: {error}"))); + let handed = crate::opening::listen(&application, files, door).unwrap_or_else(|error| startup::fail(app, &format!("the door for other copies did not open: {error}"))); application.run(move |cx: &mut App| { - crate::opening::keep(asked, cx); - open(app, smoke, root, cx) + let hands = crate::opening::keep(handed, cx); + open(app, smoke, root, hands, cx) }); } @@ -72,6 +72,7 @@ fn open( app: AppId, smoke: Smoke, root: impl FnOnce(AppId, &mut Window, &mut App) -> Entity + 'static, + hands: crate::opening::Hands, cx: &mut App, ) { if let Err(error) = ely_gpui_component::init(cx) { @@ -109,11 +110,14 @@ fn open( crate::settings::follow_frame(app, window, cx); root(app, window, cx) }); - if let Err(error) = opened { - startup::fail(app, &format!("the window did not open: {error:#}")); - } - cx.on_window_closed(|cx, _| { - if cx.windows().is_empty() { + let main: gpui::AnyWindowHandle = match opened { + Ok(window) => window.into(), + Err(error) => startup::fail(app, &format!("the window did not open: {error:#}")), + }; + crate::opening::home(hands, main, cx); + // Settings alone would hold the lock yet open nothing. + cx.on_window_closed(move |cx, closed| { + if closed == main.window_id() { cx.quit(); } }) diff --git a/tasks/TASKS.md b/tasks/TASKS.md index 261a908..e4a1149 100644 --- a/tasks/TASKS.md +++ b/tasks/TASKS.md @@ -242,7 +242,7 @@ - [~] R4.8 组件库:表格行列上下左右移动、整理表格源码;代码块取代码内容;与已有的增删行列同一套拒绝。组件 bcee87c 至 6b033f3(已推送):`TableEdit` 加上下移行(表头与首行互换,超出表头列数的行不作表头,到边拒绝为 `TableRefusal::Edge`)、左右移列、整理;表格按显示宽度补齐(`unicode-width`,Tab 记 4、控制字符记 0,照 Typora 的 `wcswidth`),写回表格自身的换行;表格编辑并入后台执行器;右键代码块时 `DocumentTarget` 带出代码文字。审查三轮未过(`tasks/reviews/R4.8-r1..r3.md`):r1 两项(纯 CR 文本里整理表格把前文当作容器前缀、行内 Tab 宽度不合 Typora);r2 一项(End 后插入表格拆开 CRLF);r3 一项(r2 的修法让行辅助函数返回光标之前的位置,End 后的块命令崩溃)。r3 已修:编辑器只按 LF 分行,光标可落在 CR 与 LF 之间;执行器与插入表格在入口把选区移到 CR 前,行辅助函数改为断言不收这种位置;两项回归在 r3 的源码上失败(`.work/R4.8-fix3/red.md`);轮数用尽,未再审。遗留:编辑器自身的 CRLF 模型不变,End 仍落在 CR 之后;表情宽度按 `unicode-width` 记 2 列(Typora 记 1) - [x] R4.9 ElyMark:“段落”“格式”菜单与 Typora 同项同键(Ctrl+1–6、Ctrl+0、Ctrl+=/-、Ctrl+B/I/U、Ctrl+Shift+`、Alt+Shift+5、Ctrl+K、Ctrl+Shift+I、Ctrl+\、Ctrl+T、Ctrl+Shift+K/M/Q/[/]);源码模式 Ctrl+/;字号 Ctrl+Shift+=/-/0;专注 F8、打字机 F9;表格菜单加移动行列;复制为 Markdown Ctrl+Shift+C、粘贴为纯文本 Ctrl+Shift+V。ElyMark 5392062、f84f990、09fcb0b,组件 6d90351、24ffc45、6c2d28c(已推送),pin 6c2d28c:键与菜单项取自 Typora 字节码与其中文菜单;表格为“段落”子菜单,加删除表格;拒绝成提示;菜单借打开的块的焦点,块与选区留着;复制为 Markdown、粘贴为纯文本只作用于文档。R1 FAIL 六项、R2 FAIL 四项已修,R3 PASS - [ ] R4.10 ElyMark:右键菜单照 Typora 按目标变化:剪贴板行(剪切、复制、粘贴、删除)、复制/粘贴为 ▸(Markdown、HTML 代码、纯文本、粘贴为纯文本)、格式行、段落 ▸ 或表格 ▸ 或代码项、插入 ▸、链接(打开、复制地址)、图片(打开所在位置、删除图片)、删除块;预览只读时只留复制类 -- [ ] R4.12 全部应用:已开着时再打开文件,交给运行中的那份开成新标签并带到前台,不再弹“已在运行”(用户报,2026-10-14);先于 R4.11 做 +- [ ] R4.12 全部应用:已开着时再打开文件,交给运行中的那份开成新标签并带到前台,不再弹“已在运行”(用户报,2026-10-14);先于 R4.11 做。第 1 轮 4 项:令牌被拒时连接重置使重试失效、慢速来者占住门、主窗口关后仍回执、ElyMail 与 ElyAgent 不到前台;已修,各有回归 - [ ] R4.11 验收:真窗口逐项操作,每项功能可用(不求与 Typora 一一相同);帮助、PRD、`agents.md` 随之;逐条核对表 ## R5 ElyDoc 照 Word 的右键与表格(用户 2026-10-13 报:右键没反应;表格不能加行列、不能调大小;三项方案经用户确认) diff --git a/tasks/reviews/R4.12-r1.md b/tasks/reviews/R4.12-r1.md new file mode 100644 index 0000000..ca05bc2 --- /dev/null +++ b/tasks/reviews/R4.12-r1.md @@ -0,0 +1,9 @@ +FAIL + +1. `crates/ely-platform/src/instance/door.rs:131`:鉴权尚未完成就发送所有 `PATH` 和 `DONE`,而接收端遇到错误令牌立即返回并关闭仍有未读数据的连接。连接重置会先于 `REFUSED` 被客户端读到,`hand` 第 90 行的 `?` 随即退出,旧门文件的重试失效。Windows 原有 `a_wrong_token_hands_nothing_over` 测试实跑失败,单独重复 12 次失败 5 次;探针把旧令牌在 100 毫秒后恢复为正确值,Windows 5/5、Linux 4/5 次交接仍提前失败,远未到 10 秒期限。修复方向:发送路径前完成明确的鉴权确认,让鉴权阶段的断连可以安全重试,保留路径发送后不重试的约束。证据:`.work/R4.12-r1/token-repeat.log`、`protocol-windows.log`、`protocol-linux.log`。 + +2. `crates/ely-ui/src/opening.rs:92`:剩下的第一个窗口未必是文档主窗口,接收文件的回调也可能仍指向已关闭的主窗口。Windows 真窗口复现:打开 ElyMark 设置,关闭文档主窗口,保留设置窗口,再启动第二份打开 `third.md`;第二份退出 0,第一份只显示设置窗口,文件未开成标签,日志报 `ely-office::open: window not found`。修复方向:主窗口关闭后不能继续把交接确认为成功并派给失效窗口;交接时重建有效主窗口及其打开回调,或让主窗口关闭时结束残留进程,使下一次启动能正常打开文件。证据:`.work/R4.12-r1/main-closed-results.log`。 + +3. `crates/ely-ui/src/opening.rs:78`:`Ask::Raise` 的唯一消费者依赖应用调用 `on_open`,但 ElyMail、ElyAgent 使用的 `ely_ui::blank` 从不调用它;这两个应用收到交接后只将请求积在队列中,却仍回复成功。Windows 真窗口分别将 ElyMail、ElyAgent 最小化,再无参数启动第二份:两次都退出 0,但原窗口仍最小化且未到前台。修复方向:在每个应用必经的启动路径安装前台请求的消费者,不让窗口激活依赖文件打开订阅。证据:`.work/R4.12-r1/window-results.log`。 + +4. `crates/ely-platform/src/instance/door.rs:57`:五秒 `read_timeout` 限制单次阻塞读取,不限制整个鉴权请求;第 47–48 行又在唯一接收线程上串行处理连接。没有令牌的本地进程可以发送一个未完成的 TOKEN 帧并持续少量补字节,长期占住接收线程,使正常实例交接超时。Windows、Linux 探针均在未鉴权连接每 500 毫秒发送一个字节、已占用超过六秒后发起正常交接,正常客户端再等约五秒即失败;关闭该连接后交接立即恢复。修复方向:给未鉴权请求设置不可被零星输入续期的整体截止时间,确保它不能持续阻塞后续合法交接,并加入对应回归。证据:`.work/R4.12-r1/protocol-windows.log`、`protocol-linux.log`。