diff --git a/Cargo.lock b/Cargo.lock index e4fe152..755160e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2095,6 +2095,7 @@ dependencies = [ "directories", "ely-ipc", "env_logger", + "getrandom 0.4.3", "image", "libc", "log", diff --git a/agents.md b/agents.md index d181781..1548f2b 100644 --- a/agents.md +++ b/agents.md @@ -159,7 +159,7 @@ tests/scripts/lag.sh ely-doc 真窗口键入,从调试日志算输入落地 tests/scripts/pdf-idle.sh 真窗口 ElyPDF 无文件启动时无 worker 子进程 tests/scripts/comments-rules.sh comments.py 对长注释与跨行注释报错 tests/scripts/bad-settings.sh 通用设置读不出时 ElyDoc 照样启动,文件不被覆盖 -tests/scripts/one-instance.sh 第二份 ElyDoc 被拒,第一份的工作目录不动 +tests/scripts/one-instance.sh 第二份 ElyDoc 把文件交给第一份后退出,第一份开出它、工作目录不动 tests/scripts/update.sh 装好的 ElyTree 从本机服务的新版下载、替换、重新打开(需辅助功能权限) tests/scripts/settings.sh 真窗口:ElyPDF 设置里选深色,ElyTree 即刻变深;作者名写入、不丢外观;ElyTree 拒绝非 Git 程序;设置文件读不出时提示且不覆盖;主窗口重开回到原处(需辅助功能与屏幕录制权限) tests/scripts/privacy.sh 真窗口 ElyDoc:键入的文字不进日志;worker 的日志进应用日志;设置里的诊断先列出所含内容,导出的文件去掉家目录与姓名、不含正文(需辅助功能权限) @@ -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,不报进程号);已有一份在跑时第二份不启动,报“已在运行(进程号)”。原先第二份启动即清空第一份的工作目录、把它的恢复点当作干净结束删掉、改写崩溃锁。macOS 经访达打开文件本就交给运行中的那份;`open -n` 与 Linux 每文件一个进程时第二份被拒,文件须在已开的窗口里打开(未做转交)。`tests/scripts/one-instance.sh` 核对。 +- 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-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-platform/Cargo.toml b/crates/ely-platform/Cargo.toml index 9fc3856..a7c18ce 100644 --- a/crates/ely-platform/Cargo.toml +++ b/crates/ely-platform/Cargo.toml @@ -12,6 +12,7 @@ arboard.workspace = true directories.workspace = true ely-ipc.workspace = true env_logger.workspace = true +getrandom.workspace = true image.workspace = true log.workspace = true semver.workspace = true diff --git a/crates/ely-platform/src/instance.rs b/crates/ely-platform/src/instance.rs index dfbc87e..c087e80 100644 --- a/crates/ely-platform/src/instance.rs +++ b/crates/ely-platform/src/instance.rs @@ -1,27 +1,56 @@ //! One running copy per app; two would wipe shared data. -use std::{fs::File, io, path::Path, sync::OnceLock}; +mod door; + +use std::{ + fs::File, + io, + path::{Path, PathBuf}, + sync::OnceLock, + time::Duration, +}; + +pub use door::Door; use crate::AppId; /// The lock this process holds while it runs. static HELD: OnceLock = OnceLock::new(); -/// Takes `app`'s instance lock; a running copy refuses this one. -pub fn hold(app: AppId) -> io::Result<()> { +/// How long a second copy waits for the door. +const WAIT: Duration = Duration::from_secs(10); + +/// What starting a copy found. +pub enum Start { + /// This copy runs; its door takes later copies' files. + First(Door), + /// A running copy took the files; this one ends. + Handed, +} + +/// Takes `app`'s lock, or hands `files` to its holder. +pub fn hold(app: AppId, files: &[PathBuf]) -> io::Result { let dir = crate::data_dir(app).map_err(io::Error::other)?; std::fs::create_dir_all(&dir)?; - take(app, &dir) + start(app, &dir, files) +} + +fn start(app: AppId, dir: &Path, files: &[PathBuf]) -> io::Result { + match take(app, dir) { + Ok(()) => Ok(Start::First(Door::open(dir)?)), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => match door::hand(dir, files, WAIT) { + Ok(()) => Ok(Start::Handed), + Err(handing) => Err(io::Error::new(handing.kind(), format!("{error} and did not take the files: {handing}"))), + }, + Err(error) => Err(error), + } } fn take(app: AppId, dir: &Path) -> io::Result<()> { let file = std::fs::OpenOptions::new().write(true).create(true).truncate(false).open(dir.join("instance.lock"))?; match lock(&file)? { None => HELD.set(file).map_err(|_| io::Error::other("this process took its instance lock twice")), - Some(holder) => Err(io::Error::new( - io::ErrorKind::AlreadyExists, - format!("{} is already running ({holder}); open files in its window", app.name()), - )), + Some(holder) => Err(io::Error::new(io::ErrorKind::AlreadyExists, format!("{} is already running ({holder})", app.name()))), } } @@ -101,6 +130,43 @@ mod tests { } } + #[test] + fn a_second_copy_hands_its_files_to_the_first() { + use std::io::BufRead; + let dir = tempfile::tempdir().expect("a temp dir"); + let exe = std::env::current_exe().expect("this test"); + let mut first = std::process::Command::new(&exe) + .args(["--exact", "instance::tests::first_copy", "--nocapture"]) + .env("ELY_FIRST_DIR", dir.path()) + .stdout(std::process::Stdio::piped()) + .spawn() + .expect("the first copy"); + let mut said = std::io::BufReader::new(first.stdout.take().expect("its output")).lines().map(|line| line.expect("a line")); + assert!(said.by_ref().any(|line| line.contains("HELD")), "the first copy did not start"); + let file = std::path::PathBuf::from("C:/notes/a b.md"); + let second = start(AppId::Doc, dir.path(), std::slice::from_ref(&file)).expect("handed over"); + assert!(matches!(second, Start::Handed)); + let got = said.find(|line| line.contains("GOT")); + assert_eq!(got.as_deref().and_then(|line| line.split("GOT ").nth(1)), Some(file.to_str().expect("text"))); + assert!(first.wait().expect("ends").success()); + } + + /// The first copy: holds, then reports one hand-over. + #[test] + fn first_copy() { + let Ok(dir) = std::env::var("ELY_FIRST_DIR") else { + return; + }; + let Ok(Start::First(door)) = start(AppId::Doc, Path::new(&dir), &[]) else { + 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"); + println!("HELD"); + let files = heard.recv_timeout(std::time::Duration::from_secs(20)).expect("a hand-over"); + println!("GOT {}", files[0].display()); + } + /// One copy, in a process of its own. #[test] fn one_copy() { diff --git a/crates/ely-platform/src/instance/door.rs b/crates/ely-platform/src/instance/door.rs new file mode 100644 index 0000000..438e4be --- /dev/null +++ b/crates/ely-platform/src/instance/door.rs @@ -0,0 +1,280 @@ +//! The running copy's door: a second copy hands it files. + +use std::{ + ffi::OsString, + fs, + io::{self, Write}, + net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream}, + path::{Path, PathBuf}, + thread, + time::{Duration, Instant}, +}; + +const FILE: &str = "instance.door"; +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 PATIENCE: Duration = Duration::from_secs(5); + +/// The running copy's listener; its token keeps others out. +pub struct Door { + listener: TcpListener, + token: [u8; 32], +} + +impl Door { + /// Opens a local door; its address goes in `dir`. + pub(super) fn open(dir: &Path) -> io::Result { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?; + let mut token = [0; 32]; + getrandom::fill(&mut token).map_err(|_| io::Error::other("system randomness failed"))?; + let port = listener.local_addr()?.port(); + let text = format!("{port} {} {}\n", std::process::id(), hex(&token)); + // Whole or absent to a reader. + let fresh = dir.join("instance.door.new"); + write_private(&fresh, text.as_bytes())?; + fs::rename(&fresh, dir.join(FILE))?; + 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<()> { + 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}"); + } + } + })?; + 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"), + )); + } + } + } + log::info!("instance: another copy handed over {} files", files.len()); + deliver(files); + ely_ipc::write_frame(&mut stream, DONE, 0, &[]) + } +} + +/// Hands `files` to `dir`'s door, waiting up to `wait`. +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", + )); + } + }, + Err(_) if Instant::now() < deadline => {} + Err(error) => return Err(error), + } + thread::sleep(Duration::from_millis(100)); + } +} + +/// Connects to `dir`'s door; nothing sent, so retries are safe. +fn knock(dir: &Path) -> io::Result<(TcpStream, Vec)> { + let text = fs::read_to_string(dir.join(FILE))?; + let bad = || io::Error::new(io::ErrorKind::InvalidData, format!("an unreadable {FILE}")); + let mut words = text.split_whitespace(); + let port: u16 = words + .next() + .and_then(|word| word.parse().ok()) + .ok_or_else(bad)?; + let pid: u32 = words + .next() + .and_then(|word| word.parse().ok()) + .ok_or_else(bad)?; + let token = words.next().and_then(unhex).ok_or_else(bad)?; + let stream = + TcpStream::connect_timeout(&SocketAddr::from((Ipv4Addr::LOCALHOST, port)), 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), + (REFUSED, _) => Ok(false), + (tag, _) => Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("it answered frame {tag}"), + )), + } +} + +fn next(stream: &mut TcpStream) -> io::Result<(u8, Vec)> { + match ely_ipc::read_frame(stream)? { + Some((tag, _, payload)) => Ok((tag, payload)), + None => Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "the other copy hung up", + )), + } +} + +/// Lets the running copy bring its window to the front. +#[cfg(windows)] +fn lend_foreground(pid: u32) { + // Refused when not in front; then nothing to lend. + unsafe { windows_sys::Win32::UI::WindowsAndMessaging::AllowSetForegroundWindow(pid) }; +} + +#[cfg(not(windows))] +fn lend_foreground(_: u32) {} + +fn write_private(path: &Path, bytes: &[u8]) -> io::Result<()> { + let mut options = fs::OpenOptions::new(); + options.write(true).create(true).truncate(true); + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); + options.open(path)?.write_all(bytes) +} + +/// Equal tokens, compared in constant time. +fn same(a: &[u8], b: &[u8]) -> bool { + a.len() == b.len() && a.iter().zip(b).fold(0, |diff, (x, y)| diff | (x ^ y)) == 0 +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn unhex(text: &str) -> Option> { + let pairs = text.as_bytes().chunks(2); + pairs + .map(|pair| u8::from_str_radix(std::str::from_utf8(pair).ok()?, 16).ok()) + .collect() +} + +#[cfg(unix)] +fn bytes(path: &Path) -> Vec { + std::os::unix::ffi::OsStrExt::as_bytes(path.as_os_str()).to_vec() +} + +#[cfg(unix)] +fn path(bytes: Vec) -> io::Result { + Ok(::from_vec(bytes).into()) +} + +/// UTF-16 units, little-endian, so any Windows name survives. +#[cfg(windows)] +fn bytes(path: &Path) -> Vec { + std::os::windows::ffi::OsStrExt::encode_wide(path.as_os_str()) + .flat_map(u16::to_le_bytes) + .collect() +} + +#[cfg(windows)] +fn path(bytes: Vec) -> io::Result { + if !bytes.len().is_multiple_of(2) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "a cut UTF-16 path", + )); + } + let wide: Vec = bytes + .chunks_exact(2) + .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) + .collect(); + Ok(::from_wide(&wide).into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn opened(dir: &Path) -> std::sync::mpsc::Receiver> { + let (sender, heard) = std::sync::mpsc::channel(); + let door = Door::open(dir).expect("a door"); + door.serve(move |files| sender.send(files).expect("heard")) + .expect("served"); + heard + } + + #[test] + fn a_hand_over_brings_every_path_whole() { + let dir = tempfile::tempdir().expect("a temp dir"); + let heard = opened(dir.path()); + let files = vec![ + PathBuf::from("C:/a b/笔记 #1.md"), + PathBuf::from("/tmp/x.md"), + ]; + hand(dir.path(), &files, Duration::from_secs(2)).expect("handed"); + assert_eq!(heard.recv_timeout(Duration::from_secs(2)), Ok(files)); + hand(dir.path(), &[], Duration::from_secs(2)).expect("an empty hand-over raises"); + assert_eq!(heard.recv_timeout(Duration::from_secs(2)), Ok(vec![])); + } + + #[test] + fn a_wrong_token_hands_nothing_over() { + 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), + ); + assert_eq!( + refused.map_err(|error| error.kind()), + Err(io::ErrorKind::PermissionDenied) + ); + assert!(heard.try_recv().is_err(), "nothing reached the copy"); + } + + #[test] + fn no_door_fails_after_the_wait() { + let dir = tempfile::tempdir().expect("a temp dir"); + let started = Instant::now(); + let missing = hand(dir.path(), &[], Duration::from_millis(300)); + assert_eq!( + missing.map_err(|error| error.kind()), + Err(io::ErrorKind::NotFound) + ); + assert!(started.elapsed() >= Duration::from_millis(300)); + } +} diff --git a/crates/ely-ui/src/opening.rs b/crates/ely-ui/src/opening.rs index 0548264..28d5ed6 100644 --- a/crates/ely-ui/src/opening.rs +++ b/crates/ely-ui/src/opening.rs @@ -1,41 +1,55 @@ -//! Files the system hands the app: arguments, Finder. +//! Files handed to the app: arguments, Finder, second copies. -use std::path::PathBuf; +use std::{io, path::PathBuf}; +use ely_platform::instance::Door; use futures::{StreamExt, channel::mpsc}; use gpui::{App, Application, Global}; -struct Asked(Option>); +/// A request to the running app. +pub(crate) enum Ask { + Open(PathBuf), + /// A second copy started: come to the front. + Raise, +} + +struct Asked(Option>); impl Global for Asked {} -/// Queues launch arguments and every later open request. -pub(crate) fn listen(application: &Application) -> mpsc::UnboundedReceiver { +/// 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(); - // Relative to where the app was started. - for path in std::env::args_os().skip(1) { - match std::path::absolute(&path) { - Ok(path) => { - sender.unbounded_send(path).ok(); - } - Err(error) => log::error!("run: a launch argument has no place: {error}"), - } + for path in files { + sender.unbounded_send(Ask::Open(path)).ok(); } + let handed = sender.clone(); + door.serve(move |files| { + for path in files { + handed.unbounded_send(Ask::Open(path)).ok(); + } + handed.unbounded_send(Ask::Raise).ok(); + })?; 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(path).ok(); + sender.unbounded_send(Ask::Open(path)).ok(); } None => log::warn!("run: not a file URL, ignored"), } } }); - asked + Ok(asked) } -pub(crate) fn keep(asked: mpsc::UnboundedReceiver, cx: &mut App) { +pub(crate) fn keep(asked: mpsc::UnboundedReceiver, cx: &mut App) { cx.set_global(Asked(Some(asked))); } @@ -49,7 +63,9 @@ pub fn install_no_launch_files(cx: &mut App) { 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"); - std::iter::from_fn(|| asked.try_recv().ok()).collect() + 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() } /// Hands each file asked from now on to `open`. @@ -60,13 +76,28 @@ 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(path) = asked.next().await { - cx.update(|cx| open(path, cx)); + while let Some(ask) = asked.next().await { + cx.update(|cx| match ask { + Ask::Open(path) => open(path, cx), + Ask::Raise => raise(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}"); + } +} + /// A `file://` URL's path, percent escapes decoded. fn file_path(url: &str) -> Option { let rest = url.strip_prefix("file://")?; diff --git a/crates/ely-ui/src/run.rs b/crates/ely-ui/src/run.rs index 130d853..d9d331a 100644 --- a/crates/ely-ui/src/run.rs +++ b/crates/ely-ui/src/run.rs @@ -2,7 +2,7 @@ use std::time::Duration; use ely_gpui_component::Assets; use ely_gpui_component::theme::ActiveTheme; -use ely_platform::{AppId, env, graphics, logging, startup}; +use ely_platform::{AppId, env, graphics, instance::Start, logging, startup}; use gpui::{ App, Bounds, Entity, Render, TitlebarOptions, Window, WindowBounds, WindowDecorations, WindowOptions, px, size, @@ -19,10 +19,13 @@ pub fn run( if let Some(code) = crate::updates::installer(app) { std::process::exit(code); } + let files = crate::opening::launch_args().unwrap_or_else(|error| startup::fail(app, &format!("a launch argument has no place: {error}"))); // Before the log: a second copy must not trim it. - if let Err(error) = ely_platform::instance::hold(app) { - startup::fail(app, &format!("{error}")); - } + let door = match ely_platform::instance::hold(app, &files) { + Ok(Start::First(door)) => door, + Ok(Start::Handed) => std::process::exit(0), + Err(error) => startup::fail(app, &format!("{error}")), + }; if let Err(error) = logging::init(app) { startup::fail(app, &format!("logging did not start: {error:#}")); } @@ -33,7 +36,7 @@ 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); + 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}"))); application.run(move |cx: &mut App| { crate::opening::keep(asked, cx); open(app, smoke, root, cx) diff --git a/tasks/TASKS.md b/tasks/TASKS.md index ba5693d..261a908 100644 --- a/tasks/TASKS.md +++ b/tasks/TASKS.md @@ -242,6 +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.11 验收:真窗口逐项操作,每项功能可用(不求与 Typora 一一相同);帮助、PRD、`agents.md` 随之;逐条核对表 ## R5 ElyDoc 照 Word 的右键与表格(用户 2026-10-13 报:右键没反应;表格不能加行列、不能调大小;三项方案经用户确认) diff --git a/tasks/reviews/R4.10-r1.md b/tasks/reviews/R4.10-r1.md new file mode 100644 index 0000000..76b3dfd --- /dev/null +++ b/tasks/reviews/R4.10-r1.md @@ -0,0 +1,11 @@ +FAIL + +1. `C:/Users/zhang/Documents/Github/Ely-GPUI-Components/src/documents/document/point.rs:174` — [P2] A preserved selection makes object actions target its beginning instead of the right-clicked object. In an open block, select both `[one](https://a.test) [two](https://b.test)` and right-click `two`: `DocumentTarget.link` is `https://a.test`. The equivalent two-image probe returns `a.png` when pressing `b.png`; Delete Image also reads the selection's start. Preserve the selection for clipboard operations, but resolve the press independently and make object-specific actions use that target. Both target probes fail on this commit; evidence: `.work/R4.10/review-r1/component-probes.log`. + +2. `C:/Users/zhang/Documents/Github/Ely-GPUI-Components/src/documents/document/point.rs:213` — [P2] Open-block link detection ignores the HTML anchor semantics used by rendering. For `[inside](https://outer.test)`, right-clicking the rendered word correctly targets `inner.test`; open the block and right-click the same word with no selection, and the target becomes `outer.test`. Open Link and Copy Link Address consequently use the wrong address. Resolve the target with the same inline HTML and anchor rules as the rendered document. The probe checks both states and fails specifically on the open-block address: `.work/R4.10/review-r1/html-target.log`. + +3. `apps/ely-mark/src/view/menu.rs:63` — [P2] A consumed menu request is replayed when its tab is mounted again. Open a context menu in file A, dismiss it with Escape, open file B, then return to A. The old menu reappears without a right press and takes focus; Ctrl+Shift+C leaves the clipboard unchanged until another Escape. `offer` survives while ContextMenu's keyed request-consumption state does not. Clear or consume the request across dismissal/tab deactivation, or retain its consumption state with the view. The probe confirms the menu is drawn and Escape restores copying; the control without an earlier menu passes. Evidence: `.work/R4.10/review-r1/menu-remount.log` and `office-probes.log`. + +4. `apps/ely-mark/src/export/copy.rs:73` — [P2] Copy as HTML Code and Copy as Plain Text (`:87`) perform the entire Markdown conversion synchronously on the UI thread. With no selection in an 84,000-byte document containing 12,000 `**x**` paragraphs, direct timing inside the handlers measured 2.147 seconds for HTML and 2.086 seconds for plain text in the debug build, excluding document opening and window rendering. Input and painting cannot proceed during this work, contrary to PERF-02. Convert the captured text on the background executor and return the clipboard result or error to the UI thread. Evidence: `.work/R4.10/review-r1/office-probes.log`. + +5. `C:/Users/zhang/Documents/Github/Ely-GPUI-Components/src/documents/document/tests/secondary.rs:8` — [P3] The new comment, “A right press near the left of the open block's text.”, contains 11 whitespace-separated words, violating the explicit ten-word limit. Shorten it to at most ten words while keeping it on one line. The Office comment check does not scan this component source file; the count is recorded in `.work/R4.10/review-r1/summary.log`. diff --git a/tests/scripts/one-instance.sh b/tests/scripts/one-instance.sh index 75a7696..bae0b29 100755 --- a/tests/scripts/one-instance.sh +++ b/tests/scripts/one-instance.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# A second ElyDoc is refused; the first keeps its data. +# A second ElyDoc hands its file over and ends. set -euo pipefail root=$(cd "$(dirname "$0")/../.." && pwd) @@ -18,7 +18,7 @@ trap cleanup EXIT fail() { echo "FAIL: $*" >&2; exit 1; } cargo build -q -p ely-lok --bins -p ely-doc --manifest-path "$root/Cargo.toml" -run() { HOME="$work/home" ELY_LOG=info ELY_LOCALE=en ELY_NO_DIALOG=1 ELY_SMOKE=hold exec "$root/target/debug/ely-doc"; } +run() { HOME="$work/home" ELY_LOG=info ELY_LOCALE=en ELY_NO_DIALOG=1 ELY_SMOKE=hold exec "$root/target/debug/ely-doc" "$@"; } run >"$work/first" 2>&1 & pid=$! for _ in $(seq 90); do grep -q "lok: first tile drawn" "$work/first" && break; sleep 1; done @@ -26,10 +26,11 @@ grep -q "lok: first tile drawn" "$work/first" || fail "the first copy did not st data="$work/home/Library/Application Support/ElyOffice/ely-doc" [ -d "$data/work" ] || fail "no work folder" touch "$data/work/kept" -status=0 -(run) >"$work/second" 2>&1 || status=$? -[ "$status" != 0 ] || fail "the second copy started" -grep -q "already running (process $pid)" "$work/second" || fail "the refusal: $(tail -3 "$work/second")" +cp "$root/tests/fixtures/office/t.docx" "$work/handed.docx" +(run "$work/handed.docx") >"$work/second" 2>&1 || fail "the second copy failed: $(tail -3 "$work/second")" +for _ in $(seq 30); do grep -q "opened handed.docx as tab" "$work/first" && break; sleep 1; done +grep -q "another copy handed over 1 files" "$work/first" || fail "no hand-over: $(tail -5 "$work/first")" +grep -q "opened handed.docx as tab" "$work/first" || fail "the first copy did not open it: $(tail -5 "$work/first")" [ -e "$data/work/kept" ] || fail "the second copy wiped the first's work folder" kill -0 "$pid" || fail "the first copy died" -echo "ok: a second ElyDoc is refused and the first keeps its data" +echo "ok: a second ElyDoc hands its file to the first, which keeps its data"