From f5b91a289e8a66ec431df5c161525533d1a16a63 Mon Sep 17 00:00:00 2001 From: Jinhui Zheng Date: Wed, 30 Sep 2026 00:51:21 +0800 Subject: [PATCH] feat: add opt-in Windows CAPTCHA recovery keepalive --- .github/workflows/ci.yml | 14 + README.en.md | 2 + README.md | 2 + docs/keepalive.md | 46 ++ package.json | 3 +- src-tauri/Cargo.lock | 167 +++++ src-tauri/Cargo.toml | 3 + src-tauri/src/keepalive/log_reader.rs | 461 ++++++++++++++ src-tauri/src/keepalive/mod.rs | 134 ++++ src-tauri/src/keepalive/process.ps1 | 63 ++ src-tauri/src/keepalive/renderer.js | 110 ++++ src-tauri/src/keepalive/renderer.test.cjs | 25 + src-tauri/src/keepalive/windows.rs | 295 +++++++++ src-tauri/src/keepalive/worker.rs | 729 ++++++++++++++++++++++ src-tauri/src/lib.rs | 19 + src-tauri/src/store.rs | 4 + src/locales/en.js | 25 + src/locales/zh.js | 25 + src/main.js | 22 + src/styles.css | 11 + 20 files changed, 2159 insertions(+), 1 deletion(-) create mode 100644 docs/keepalive.md create mode 100644 src-tauri/src/keepalive/log_reader.rs create mode 100644 src-tauri/src/keepalive/mod.rs create mode 100644 src-tauri/src/keepalive/process.ps1 create mode 100644 src-tauri/src/keepalive/renderer.js create mode 100644 src-tauri/src/keepalive/renderer.test.cjs create mode 100644 src-tauri/src/keepalive/windows.rs create mode 100644 src-tauri/src/keepalive/worker.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43065e5..0e0ef76 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,20 @@ on: pull_request: jobs: + frontend: + name: Frontend and keepalive adapter + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - run: npm ci --ignore-scripts + - run: npm run check:i18n + - run: npm run test:keepalive + - run: npm run build + test-windows: name: cargo test (windows) runs-on: windows-latest diff --git a/README.en.md b/README.en.md index 8b19365..76ade04 100644 --- a/README.en.md +++ b/README.en.md @@ -8,6 +8,8 @@ A Tauri 2 desktop tool for one-click switching between multiple ZCode accounts, ## Features +- **Windows keepalive (opt-in)**: recover newly detected CAPTCHA interruptions by restarting the same account and resuming captured conversations, with bounded retry frequency and execution-progress checks. [Behavior and compatibility](docs/keepalive.md#english) + - **Save / switch accounts**: one-click login switching; the current login is auto-preserved before any switch — accounts are never lost - **Add accounts**: OAuth login for new accounts inside the tool (BigModel / z.ai entries), never touching the current login - **Quota display**: inline plan quota and reset time per account row, multi-plan grouping diff --git a/README.md b/README.md index f03fc44..377b695 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ Tauri 2 桌面工具:在多个 ZCode 账号之间一键切换,自动显示 ## 功能 +- **Windows 保活(可选)**:检测新发生的验证码中断,同账号重启 ZCode 并恢复原会话;持续重试、执行进展确认,默认关闭。[使用说明与兼容范围](docs/keepalive.md) + - **保存 / 切换账号**:一键切换登录身份;切换前自动保全当前登录,绝不丢号;设备身份跟账号走,远程控制中继密钥跨切换保活 - **添加账号**:工具内 OAuth 登录新号(BigModel / z.ai 双入口),全程不动当前登录 - **额度展示**:账号行内联显示套餐额度与重置时间,多套餐分组 diff --git a/docs/keepalive.md b/docs/keepalive.md new file mode 100644 index 0000000..e980456 --- /dev/null +++ b/docs/keepalive.md @@ -0,0 +1,46 @@ +# Windows 保活 / Windows keepalive + +保活是默认关闭的 Windows 功能。它在 **Z·SWITCH 保持运行(可以缩到托盘)** 时观察 ZCode 新产生的验证码失败,在同一个账号下冷启动 ZCode,并向符合条件的原会话发送一次 `继续`。 + +在工具栏打开 **保活**,阅读首次启用说明并确认即可。使用设置中探测或指定的 ZCode 路径以及现有数据目录,不需要安装插件、Python、Node.js 或修改 ZCode 安装包。其他操作系统暂不显示此开关。 + +## 行为 + +- 从开启时的日志末尾开始观察。只匹配结构化 `turn.failed` 的已知验证码错误链,并要求会话 ID、轮次 ID 和有效时间;普通停顿、网络错误或聊天里提到“验证码”不会触发。 +- 首次发现故障立即进入恢复。重复验证码失败持续恢复,间隔从 15 秒逐步增加,最多 5 分钟,不限制总尝试次数。 +- 只恢复本轮捕获的故障会话,以及开启后观察到正在执行、重启前索引仍为 running 的同实例会话。不会遍历历史错误会话批量发送消息。 +- 在发送前重新检查原会话和工作区。已完成、已暂停、等待回答或权限批准、已切到新轮次的任务不自动发送。 +- 发送使用当前桌面连接的客户端标识和稳定命令 ID;丢失回执时先观察会话,避免直接重复发送。只有新轮次实际产生进展或完成才增加“已恢复”计数。 +- 关闭开关取消后续续跑。已经停止 ZCode 的重启会以退避间隔继续尝试完成启动,并明确显示状态;也可手动启动。通过切换器手动换号、启停或更换路径会关闭保活并取消待执行的启动补偿,优先尊重这些操作。 +- 未知版本、未知状态、多个匹配实例、远程工作区或不明确的进程归属不会被猜测为可恢复。状态栏会显示等待或需要处理。 + +## 兼容性与本机控制 + +已在 Windows 的 **ZCode 3.14.4.7912** 上验证原会话冷恢复。会话控制复用 ZCode renderer 内部服务,因此不是 ZCode 承诺兼容的公开 API。实现按连接协议、服务、会话身份和状态字段检查兼容性;升级后若检查失败会停止续跑,需要更新适配后再使用。 + +恢复启动附加 `--remote-debugging-address=127.0.0.1 --remote-debugging-port=0`。系统选择端口;切换器验证监听进程、PID/启动时间、可执行路径、Windows 用户及桌面 renderer URL,然后才使用该连接。不会开放公网监听。**此接口没有额外身份认证,本机其他程序也可能控制该 ZCode 实例**,因此首次启用需明确确认。关闭保活不强行重启工作中的 ZCode;调试接口在下一次普通启动后关闭。 + +会话恢复集合仅保存在当前 Z·SWITCH 进程中。退出后不自动重放旧恢复命令;再次启动从新的观察边界开始。启用偏好会保存,手动生命周期操作会保存为关闭。保活负责故障恢复,不能保证服务端以后永远不再要求人工验证码,也不会自动解滑块或更换账号。 + +## 验证 + +```sh +npm ci --ignore-scripts +npm run check:i18n +npm run test:keepalive +npm run build +cd src-tauri +cargo test --lib +``` + +测试覆盖历史日志排除、轮转/截断/部分写入、错误归因、资源上限、精确恢复目标、手动续跑覆盖自动恢复、启动失败后的重试、关闭期间完成已提交启动、回执与执行进展的区别、请求去重及等待用户状态。 + +本次运行验收另外使用了隔离数据目录与隐藏桌面的实际 ZCode:本地模拟模型制造失败 → 原进程退出 → 冷启动 → 原 session ID 恢复 → `继续` → 新轮次完成;重复命令未增加模型请求。也通过 Rust 桥实际核实了 Windows 进程归属、随机端口发现和 renderer 控制连接。真实阿里云再次发出挑战后的完整自动恢复尚未作为本次验收复现;不能把模拟模型测试表述为该外部服务验收。 + +## English + +Keepalive is an opt-in Windows toolbar switch. While Z·SWITCH runs, it detects **new structured CAPTCHA turn failures**, restarts the selected local ZCode instance with the same account, and resumes only captured affected conversations. Repeated failures back off from 15 seconds to 5 minutes without a total attempt limit. Completed, manually paused, user-input and permission-waiting conversations are excluded; ACK alone is not counted as recovery. + +No ZCode installation patch, plugin, Python, or Node.js runtime is required by the packaged feature. Installation/data paths are discovered through existing settings. Manual account/lifecycle/path actions disable it. Recovery intents are in memory and are not replayed after the switcher exits. + +The adapter was exercised against ZCode 3.14.4.7912, including a real isolated cold restart and a local mock model. It uses internal desktop services with explicit capability/schema checks, not a supported public ZCode API. Other versions may require an adapter update. The random loopback debugging port is accessible to other local software; enablement requires consent. Disable stops automation, and a later normal ZCode restart closes that debugging interface. Real Aliyun challenge recovery remains a separate external acceptance boundary. diff --git a/package.json b/package.json index 678e4f6..6e79183 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "preview": "vite preview", "tauri": "tauri", "dist": "node scripts/dist.mjs", - "check:i18n": "node scripts/check-i18n-keys.mjs" + "check:i18n": "node scripts/check-i18n-keys.mjs", + "test:keepalive": "node --test src-tauri/src/keepalive/renderer.test.cjs" }, "dependencies": { "@tauri-apps/api": "^2.1.1" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7014105..60a2e4a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -43,6 +43,18 @@ dependencies = [ "subtle", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -718,6 +730,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "dbus" version = "0.9.12" @@ -1048,6 +1066,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.5.0" @@ -1528,12 +1558,30 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.4.1" @@ -2078,6 +2126,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2711,6 +2770,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "precomputed-hash" version = "0.1.1" @@ -2809,6 +2877,27 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -2976,6 +3065,20 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.13.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -3299,6 +3402,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -4261,6 +4375,24 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + [[package]] name = "typeid" version = "1.0.3" @@ -4396,6 +4528,12 @@ dependencies = [ "url", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -4414,6 +4552,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" @@ -5324,6 +5468,8 @@ dependencies = [ "chrono", "iana-time-zone", "pbkdf2", + "percent-encoding", + "rusqlite", "serde", "serde_json", "sha2", @@ -5333,11 +5479,32 @@ dependencies = [ "tauri-plugin-autostart", "tauri-plugin-dialog", "tauri-plugin-single-instance", + "tungstenite", "ureq", "uuid", "windows-sys 0.52.0", ] +[[package]] +name = "zerocopy" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zerofrom" version = "0.1.8" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index daa5699..47feb4e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -29,6 +29,9 @@ ureq = { version = "2", features = ["json"] } sys-locale = "0.3" [target.'cfg(windows)'.dependencies] +rusqlite = { version = "0.32", features = ["bundled"] } +tungstenite = { version = "0.24", default-features = false, features = ["handshake"] } +percent-encoding = "2" windows-sys = { version = "0.52", features = [ "Win32_System_Console", "Win32_Storage_FileSystem", diff --git a/src-tauri/src/keepalive/log_reader.rs b/src-tauri/src/keepalive/log_reader.rs new file mode 100644 index 0000000..3693e0c --- /dev/null +++ b/src-tauri/src/keepalive/log_reader.rs @@ -0,0 +1,461 @@ +//! Bounded, incremental observation of ZCode's structured daily CLI logs. +use chrono::{DateTime, NaiveDate}; +use serde_json::Value; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::fs::{self, File}; +use std::io::{Read, Seek, SeekFrom}; +use std::path::PathBuf; + +const MAX_FILES: usize = 3; +const MAX_POLL_BYTES: usize = 1024 * 1024; +const MAX_LINE_BYTES: usize = 64 * 1024; +const MAX_DEDUP: usize = 4096; +const ANCHOR_BYTES: usize = 64; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum EventKind { + CaptchaFailure, + TurnStarted, + Progress, + TurnCompleted, + OtherFailure, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ObservedEvent { + pub session_id: String, + pub turn_id: String, + pub at_ms: i64, + pub kind: EventKind, +} + +#[derive(Default)] +struct Cursor { + offset: u64, + pending: Vec, + discard_line: bool, + anchor: Vec, +} + +pub struct LogReader { + log_dir: PathBuf, + enabled_at_ms: i64, + cursors: HashMap, + seen: HashSet<(String, String, i64, EventKind)>, + seen_order: VecDeque<(String, String, i64, EventKind)>, + // When the cache evicts entries, reject older timestamps rather than replaying + // rotated history. Same-millisecond overflow is also rejected, conservatively. + retired_through_ms: i64, +} + +impl LogReader { + pub fn new(log_dir: PathBuf, enabled_at_ms: i64) -> Result { + let mut reader = Self { + log_dir, + enabled_at_ms, + cursors: HashMap::new(), + seen: HashSet::new(), + seen_order: VecDeque::new(), + retired_through_ms: i64::MIN, + }; + for path in reader.daily_files()? { + let mut file = File::open(&path).map_err(io_error)?; + let offset = file.metadata().map_err(io_error)?.len(); + let anchor = read_anchor(&mut file, offset)?; + // If enable happens halfway through a record, its suffix is historical. + let discard_line = anchor.last().is_some_and(|b| *b != b'\n'); + reader.cursors.insert( + path, + Cursor { + offset, + anchor, + discard_line, + pending: Vec::new(), + }, + ); + } + Ok(reader) + } + + pub fn poll(&mut self) -> Result, String> { + let paths = self.daily_files()?; + self.cursors.retain(|path, _| paths.contains(path)); + let mut remaining = MAX_POLL_BYTES; + let mut candidates = Vec::new(); + // Oldest first prevents a busy new file starving the previous day's tail. + for path in paths { + if remaining == 0 { + break; + } + let mut file = match File::open(&path) { + Ok(file) => file, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, + Err(e) => return Err(io_error(e)), + }; + let cursor = self.cursors.entry(path).or_default(); + let len = file.metadata().map_err(io_error)?.len(); + if len < cursor.offset || read_anchor(&mut file, cursor.offset)? != cursor.anchor { + *cursor = Cursor::default(); + } + file.seek(SeekFrom::Start(cursor.offset)) + .map_err(io_error)?; + let mut bytes = Vec::new(); + file.by_ref() + .take(remaining as u64) + .read_to_end(&mut bytes) + .map_err(io_error)?; + remaining -= bytes.len(); + cursor.offset += bytes.len() as u64; + // Derive the anchor from bytes actually consumed, never from a later + // reread which could silently accept a concurrent truncate/rewrite. + cursor.anchor.extend_from_slice(&bytes); + if cursor.anchor.len() > ANCHOR_BYTES { + cursor.anchor.drain(..cursor.anchor.len() - ANCHOR_BYTES); + } + for byte in bytes { + if byte == b'\n' { + if !cursor.discard_line { + if let Some(event) = parse_event(&cursor.pending, self.enabled_at_ms) { + candidates.push(event); + } + } + cursor.pending.clear(); + cursor.discard_line = false; + } else if !cursor.discard_line { + if cursor.pending.len() == MAX_LINE_BYTES { + cursor.pending.clear(); + cursor.discard_line = true; + } else { + cursor.pending.push(byte); + } + } + } + } + candidates.sort_by_key(|event| event.at_ms); + let mut events = Vec::new(); + for event in candidates { + let key = ( + event.session_id.clone(), + event.turn_id.clone(), + event.at_ms, + event.kind, + ); + if event.at_ms <= self.retired_through_ms || !self.seen.insert(key.clone()) { + continue; + } + self.seen_order.push_back(key); + if self.seen_order.len() > MAX_DEDUP { + if let Some(old) = self.seen_order.pop_front() { + self.retired_through_ms = self.retired_through_ms.max(old.2); + self.seen.remove(&old); + } + } + events.push(event); + } + Ok(events) + } + + fn daily_files(&self) -> Result, String> { + let entries = match fs::read_dir(&self.log_dir) { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(io_error(e)), + }; + let mut paths = Vec::new(); + for entry in entries { + let entry = entry.map_err(io_error)?; + if !entry.file_type().map_err(io_error)?.is_file() { + continue; + } + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + let Some(date) = name + .strip_prefix("zcode-") + .and_then(|s| s.strip_suffix(".jsonl")) + else { + continue; + }; + if date.len() != 10 + || NaiveDate::parse_from_str(date, "%Y-%m-%d") + .map(|d| d.format("%Y-%m-%d").to_string() != date) + .unwrap_or(true) + { + continue; + } + paths.push(entry.path()); + paths.sort(); + if paths.len() > MAX_FILES { + paths.remove(0); + } + } + Ok(paths) + } +} + +fn io_error(error: std::io::Error) -> String { + format!("Unable to read ZCode activity logs: {error}") +} + +fn read_anchor(file: &mut File, offset: u64) -> Result, String> { + let start = offset.saturating_sub(ANCHOR_BYTES as u64); + file.seek(SeekFrom::Start(start)).map_err(io_error)?; + let mut bytes = Vec::new(); + file.take(offset - start) + .read_to_end(&mut bytes) + .map_err(io_error)?; + Ok(bytes) +} + +fn parse_event(line: &[u8], enabled_at_ms: i64) -> Option { + let value: Value = serde_json::from_slice(line).ok()?; + let at_ms = DateTime::parse_from_rfc3339(value.get("timestamp")?.as_str()?) + .ok()? + .timestamp_millis(); + if at_ms < enabled_at_ms { + return None; + } + let session_id = value.get("sessionId")?.as_str()?.trim(); + let turn_id = value.get("turnId")?.as_str()?.trim(); + if session_id.is_empty() || turn_id.is_empty() { + return None; + } + let kind = match value.get("event")?.as_str()? { + "turn.started" => EventKind::TurnStarted, + "turn.completed" => EventKind::TurnCompleted, + "model.request.completed" => EventKind::Progress, + "turn.failed" if value.get("error").is_some_and(is_captcha_error) => { + EventKind::CaptchaFailure + } + "turn.failed" => EventKind::OtherFailure, + _ => return None, + }; + Some(ObservedEvent { + session_id: session_id.to_owned(), + turn_id: turn_id.to_owned(), + at_ms, + kind, + }) +} + +fn is_captcha_error(error: &Value) -> bool { + let mut current = Some(error); + let mut known_type = false; + let mut known_message = false; + for _ in 0..16 { + let Some(node) = current else { + break; + }; + known_type |= matches!( + node.get("name").and_then(Value::as_str), + Some("RuntimeHeadersRefreshError" | "ProtocolRequestError") + ); + known_message |= matches!( + node.get("message").and_then(Value::as_str), + Some( + "Captcha verification failed. Please try again." + | "Captcha verification request timed out. Please send your message again." + | "Captcha instance timed out after 10000ms." + ) + ); + current = node.get("cause"); + } + known_type && known_message +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use std::sync::atomic::{AtomicUsize, Ordering}; + static NEXT: AtomicUsize = AtomicUsize::new(0); + const NOW: i64 = 1_790_726_400_000; + struct Fixture(PathBuf); + impl Fixture { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "zswitch-log-tests-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&path).unwrap(); + Self(path) + } + fn path(&self) -> PathBuf { + self.0.join("zcode-2026-09-30.jsonl") + } + fn append(&self, bytes: &[u8]) { + fs::OpenOptions::new() + .create(true) + .append(true) + .open(self.path()) + .unwrap() + .write_all(bytes) + .unwrap(); + } + fn reader(&self) -> LogReader { + LogReader::new(self.0.clone(), NOW).unwrap() + } + } + impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + fn event(id: &str, ms: i64, name: &str) -> String { + format!( + "{}\n", + serde_json::json!({"timestamp": DateTime::from_timestamp_millis(ms).unwrap().to_rfc3339(), + "event": name, "sessionId": "session", "turnId": id, + "error": {"name":"Error","message":"Turn execution failed","cause":{ + "name":"AiSdkModelAdapterError","message":"Captcha verification failed. Please try again.", + "cause":{"name":"RuntimeHeadersRefreshError","cause":{"name":"ProtocolRequestError"}}}}}) + ) + } + #[test] + fn ignores_history_and_requires_structured_captcha_chain() { + let f = Fixture::new(); + f.append(event("old", NOW, "turn.failed").as_bytes()); + let mut reader = f.reader(); + assert!(reader.poll().unwrap().is_empty()); + f.append(event("too-old", NOW - 1, "turn.failed").as_bytes()); + f.append(event("good", NOW, "turn.failed").as_bytes()); + f.append(event("not-failure", NOW, "unrelated").as_bytes()); + f.append( + event("ordinary", NOW, "turn.failed") + .replace( + "Captcha verification failed. Please try again.", + "Network timeout", + ) + .as_bytes(), + ); + f.append( + event("wrong-type", NOW, "turn.failed") + .replace("RuntimeHeadersRefreshError", "OtherError") + .replace("ProtocolRequestError", "OtherError") + .as_bytes(), + ); + let found = reader.poll().unwrap(); + assert_eq!(found.len(), 3); + assert_eq!(found[0].kind, EventKind::CaptchaFailure); + assert_eq!(found[1].kind, EventKind::OtherFailure); + assert_eq!(found[2].kind, EventKind::OtherFailure); + } + #[test] + fn partial_writes_and_partial_record_at_enable() { + let f = Fixture::new(); + let old = event("historical", NOW, "turn.failed"); + f.append(&old.as_bytes()[..30]); + let mut reader = f.reader(); + f.append(&old.as_bytes()[30..]); + assert!(reader.poll().unwrap().is_empty()); + let next = event("new", NOW, "turn.failed"); + f.append(&next.as_bytes()[..40]); + assert!(reader.poll().unwrap().is_empty()); + f.append(&next.as_bytes()[40..]); + assert_eq!(reader.poll().unwrap()[0].turn_id, "new"); + assert!(reader.poll().unwrap().is_empty()); + } + #[test] + fn new_daily_file_and_truncation_do_not_replay() { + let f = Fixture::new(); + let mut reader = f.reader(); + let first = event("first", NOW, "turn.failed"); + f.append(first.as_bytes()); + assert_eq!(reader.poll().unwrap().len(), 1); + fs::write(f.path(), &first).unwrap(); + assert!(reader.poll().unwrap().is_empty()); + fs::write( + f.path(), + format!("{first}{}", event("second", NOW + 1, "turn.started")), + ) + .unwrap(); + assert_eq!(reader.poll().unwrap()[0].kind, EventKind::TurnStarted); + fs::write(f.path(), event("third", NOW + 2, "turn.completed")).unwrap(); + assert_eq!(reader.poll().unwrap()[0].kind, EventKind::TurnCompleted); + fs::write( + f.0.join("zcode-2026-10-01.jsonl"), + format!( + "{first}{}", + event("fourth", NOW + 3, "model.request.completed") + ), + ) + .unwrap(); + let result = reader.poll().unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].kind, EventKind::Progress); + } + #[test] + fn malformed_records_and_wrong_filenames_are_ignored() { + let f = Fixture::new(); + let mut reader = f.reader(); + for line in [ + event("", NOW, "turn.failed"), + event("x", NOW, "turn.failed").replace("sessionId", "missing"), + event("x", NOW, "turn.failed").replace("timestamp", "missing"), + "Captcha verification failed. Please try again.\n".into(), + ] { + f.append(line.as_bytes()); + } + for name in [ + "zcode-2026-9-30.jsonl", + "zcode-2026-02-30.jsonl", + "other.jsonl", + ] { + fs::write(f.0.join(name), event("x", NOW, "turn.failed")).unwrap(); + } + assert!(reader.poll().unwrap().is_empty()); + } + #[test] + fn known_timeout_variants_require_failure_event_and_error_chain() { + for message in [ + "Captcha verification request timed out. Please send your message again.", + "Captcha instance timed out after 10000ms.", + ] { + let line = event("x", NOW, "turn.failed") + .replace("Captcha verification failed. Please try again.", message); + assert_eq!( + parse_event(line.as_bytes(), NOW).unwrap().kind, + EventKind::CaptchaFailure + ); + } + let mut value: Value = serde_json::from_str(&event("x", NOW, "turn.failed")).unwrap(); + value["error"] = + serde_json::json!({"name":"NetworkError","message":"Network request failed"}); + value["context"] = serde_json::json!({"name":"ProtocolRequestError","message":"Captcha verification failed. Please try again."}); + assert_eq!( + parse_event(value.to_string().as_bytes(), NOW).unwrap().kind, + EventKind::OtherFailure + ); + } + #[test] + fn oversized_lines_and_poll_budget_are_bounded() { + let f = Fixture::new(); + let mut reader = f.reader(); + f.append(&vec![b'x'; MAX_POLL_BYTES + MAX_LINE_BYTES]); + f.append(b"\n"); + f.append(event("good", NOW, "turn.failed").as_bytes()); + assert!(reader.poll().unwrap().is_empty()); + let cursor = reader.cursors.get(&f.path()).unwrap(); + assert_eq!(cursor.offset, MAX_POLL_BYTES as u64); + assert!(cursor.pending.len() <= MAX_LINE_BYTES); + assert_eq!(reader.poll().unwrap()[0].turn_id, "good"); + } + #[test] + fn dedup_cache_is_bounded_and_evicted_history_is_rejected() { + let f = Fixture::new(); + let mut reader = f.reader(); + for n in 0..MAX_DEDUP + 10 { + f.append(event(&n.to_string(), NOW + n as i64, "turn.started").as_bytes()); + } + let mut count = 0; + for _ in 0..5 { + count += reader.poll().unwrap().len(); + } + assert_eq!(count, MAX_DEDUP + 10); + assert_eq!(reader.seen.len(), MAX_DEDUP); + fs::write(f.path(), event("0", NOW, "turn.started")).unwrap(); + assert!(reader.poll().unwrap().is_empty()); + } +} diff --git a/src-tauri/src/keepalive/mod.rs b/src-tauri/src/keepalive/mod.rs new file mode 100644 index 0000000..592aab1 --- /dev/null +++ b/src-tauri/src/keepalive/mod.rs @@ -0,0 +1,134 @@ +//! Opt-in recovery of newly observed local CAPTCHA interruptions. +mod log_reader; +#[cfg(windows)] +mod windows; +#[cfg(windows)] +mod worker; + +use serde::Serialize; +use std::sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Mutex, OnceLock, +}; + +static ENABLED: AtomicBool = AtomicBool::new(false); +static GENERATION: AtomicU64 = AtomicU64::new(0); +static FINISH_ON_CANCEL: AtomicBool = AtomicBool::new(true); +static LAUNCH_COMMITTED: AtomicBool = AtomicBool::new(false); +static STATUS: OnceLock> = OnceLock::new(); + +#[derive(Clone, Debug, Serialize)] +pub struct Status { + pub supported: bool, + pub enabled: bool, + pub phase: String, + pub pending: usize, + pub recovered: usize, + pub attempt: u32, + pub next_retry_at: Option, + pub reason: Option, +} +impl Default for Status { + fn default() -> Self { + Self { + supported: cfg!(windows), + enabled: false, + phase: "off".into(), + pending: 0, + recovered: 0, + attempt: 0, + next_retry_at: None, + reason: None, + } + } +} +pub fn status() -> Status { + let mut s = STATUS + .get_or_init(Default::default) + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(); + s.enabled = ENABLED.load(Ordering::SeqCst); + s +} +fn publish(mut s: Status) { + s.enabled = ENABLED.load(Ordering::SeqCst); + *STATUS + .get_or_init(Default::default) + .lock() + .unwrap_or_else(|e| e.into_inner()) = s; +} +/// Manual account/lifecycle actions cancel recovery before waiting for STORE_LOCK. +pub fn cancel() { + ENABLED.store(false, Ordering::SeqCst); + GENERATION.fetch_add(1, Ordering::SeqCst); + publish(Status::default()); +} +pub fn configure(enabled: bool) -> Result { + FINISH_ON_CANCEL.store(true, Ordering::SeqCst); + configure_inner(enabled) +} +pub fn manual_action() -> Result { + cancel_manual(); + configure_inner(false) +} +pub fn cancel_manual() { + FINISH_ON_CANCEL.store(false, Ordering::SeqCst); + LAUNCH_COMMITTED.store(false, Ordering::SeqCst); + cancel(); +} +fn configure_inner(enabled: bool) -> Result { + if !enabled { + cancel(); + } + #[cfg(not(windows))] + if enabled { + return Err("Keepalive is available on Windows only".into()); + } + #[cfg(windows)] + if enabled { + worker::enable()?; + } + let _guard = crate::store_guard(); + let paths = crate::store::Paths::detect(); + let mut settings = crate::store::load_settings(&paths); + settings.keepalive_enabled = Some(enabled); + if let Err(e) = crate::store::save_settings(&paths, &settings) { + cancel(); + return Err(e); + } + Ok(status()) +} +pub fn start() { + #[cfg(windows)] + { + worker::start(); + if crate::store::load_settings(&crate::store::Paths::detect()) + .keepalive_enabled + .unwrap_or(false) + { + if let Err(_e) = worker::enable() { + let mut s = Status::default(); + s.phase = "error".into(); + s.reason = Some("setup".into()); + publish(s); + } + } + } +} + +fn retry_delay_ms(attempt: u32) -> i64 { + // Continuous recovery, with a finite frequency cap rather than an attempt cap. + (15_000_i64.saturating_mul(1_i64 << attempt.saturating_sub(1).min(5))).min(300_000) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn retry_frequency_is_bounded_without_an_attempt_limit() { + assert_eq!(retry_delay_ms(1), 15_000); + assert_eq!(retry_delay_ms(2), 30_000); + assert_eq!(retry_delay_ms(100_000), 300_000); + } +} diff --git a/src-tauri/src/keepalive/process.ps1 b/src-tauri/src/keepalive/process.ps1 new file mode 100644 index 0000000..b0df88c --- /dev/null +++ b/src-tauri/src/keepalive/process.ps1 @@ -0,0 +1,63 @@ +$ErrorActionPreference = 'Stop' +[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) +$request = $env:ZSW_KEEPALIVE_REQUEST | ConvertFrom-Json +$expectedPath = [IO.Path]::GetFullPath([string]$request.executable) +$sessionId = (Get-Process -Id $PID).SessionId +$sid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value + +function Read-Instance { + $all = @(Get-CimInstance Win32_Process -Filter "Name = 'ZCode.exe'") + $matching = @($all | Where-Object { + $_.ExecutablePath -and [IO.Path]::GetFullPath($_.ExecutablePath) -eq $expectedPath -and $_.SessionId -eq $sessionId + }) + $ids = @($matching | ForEach-Object { $_.ProcessId }) + $roots = @($matching | Where-Object { $_.ParentProcessId -notin $ids -and $_.CommandLine -notmatch '(?:^|\s)--type=' }) + if ($roots.Count -gt 1) { throw 'Multiple ZCode instances match. Keepalive requires one local instance.' } + if ($roots.Count -eq 0) { + if ($matching.Count -gt 0) { throw 'ZCode child processes are still running.' } + return $null + } + $root = $roots[0] + $owner = Invoke-CimMethod -InputObject $root -MethodName GetOwnerSid + if ($owner.Sid -ne $sid) { throw 'ZCode belongs to a different Windows user.' } + $created = [DateTimeOffset]$root.CreationDate + $ports = @() + if ($request.ports) { + $ports = @(Get-NetTCPConnection -State Listen -OwningProcess $root.ProcessId -ErrorAction SilentlyContinue | + Where-Object { $_.LocalAddress -eq '127.0.0.1' } | Select-Object -ExpandProperty LocalPort -Unique) + } + return [pscustomobject]@{ pid=[uint32]$root.ProcessId; created_ms=$created.ToUnixTimeMilliseconds(); executable=$root.ExecutablePath; ports=$ports } +} + +switch ($request.action) { + 'inspect' { + $instance = Read-Instance + if ($null -eq $instance) { 'null' } + else { ConvertTo-Json -InputObject $instance -Compress -Depth 4 } + } + 'stop' { + $instance = Read-Instance + if (!$instance -or $instance.pid -ne $request.pid -or $instance.created_ms -ne $request.created_ms) { + throw 'ZCode process identity changed; restart cancelled.' + } + # Retain the exact live process handle across the PID-based tree operation. + $process = Get-Process -Id $instance.pid + $null = $process.Handle + if ($process.HasExited) { throw 'ZCode exited before restart.' } + if (([DateTimeOffset]$process.StartTime).ToUnixTimeMilliseconds() -ne $request.created_ms -or [IO.Path]::GetFullPath($process.Path) -ne $expectedPath) { + throw 'ZCode process identity changed before acquiring its handle.' + } + $null = & "$env:SystemRoot\System32\taskkill.exe" /PID $instance.pid /T /F 2>&1 + if ($LASTEXITCODE -ne 0 -and !$process.HasExited) { throw 'Could not stop the selected ZCode instance.' } + $process.WaitForExit(10000) | Out-Null + if (!$process.HasExited) { throw 'ZCode is still running.' } + '{"stopped":true}' + } + 'start' { + if (Read-Instance) { throw 'ZCode is already running; launch cancelled.' } + $arguments = @('--remote-debugging-address=127.0.0.1', '--remote-debugging-port=0') + $process = Start-Process -FilePath $expectedPath -ArgumentList $arguments -WorkingDirectory ([IO.Path]::GetDirectoryName($expectedPath)) -WindowStyle Hidden -PassThru + [pscustomobject]@{ pid=$process.Id; created_ms=([DateTimeOffset]$process.StartTime).ToUnixTimeMilliseconds(); executable=$expectedPath; ports=@() } | ConvertTo-Json -Compress + } + default { throw 'Unknown keepalive process operation.' } +} diff --git a/src-tauri/src/keepalive/renderer.js b/src-tauri/src/keepalive/renderer.js new file mode 100644 index 0000000..037067b --- /dev/null +++ b/src-tauri/src/keepalive/renderer.js @@ -0,0 +1,110 @@ +/* Evaluated only in the controller-verified local ZCode desktop renderer. */ +(() => { + 'use strict'; + if (globalThis.__zswitchKeepalive?.contractVersion === 1) return { contractVersion: 1 }; + const own = (o, k) => o && Object.prototype.hasOwnProperty.call(o, k); + const pathKey = p => p.replace(/\//g, '\\').replace(/\\+$/, '').toLowerCase(); + const valid = t => t && typeof t.sessionId === 'string' && /^[A-Za-z0-9_-]{1,200}$/.test(t.sessionId) && typeof t.workspacePath === 'string' && /^[A-Za-z]:[\\/]/.test(t.workspacePath) && !/[\0\r\n]/.test(t.workspacePath) && !t.workspaceIdentity && !t.remoteSessionId; + const scope = t => ({ sessionId: t.sessionId, workspacePath: t.workspacePath }); + function discover() { + const root = document.getElementById('root'); + if (!root) return null; + const roots = Object.keys(root).filter(k => /^__react(Container|Fiber)\$/.test(k)).map(k => root[k]); + const stack = roots.map(f => f?.stateNode?.current || f?.current || f); + const seen = new Set(); + while (stack.length && seen.size < 10000) { + const f = stack.pop(); + if (!f || seen.has(f)) continue; + seen.add(f); + for (const s of [f.memoizedProps?.services, f.memoizedProps?.value]) { + if (own(s, 'zcodeAgentService') && own(s, 'zcodeTaskService')) return s; + } + if (f.sibling) stack.push(f.sibling); + if (f.child) stack.push(f.child); + } + return null; + } + const services = discover(); + const agent = services?.zcodeAgentService, tasks = services?.zcodeTaskService; + const capable = !!agent && ['helloConversationV4','readSession','resumeSession','sendConversationCommandV4'].every(k => typeof agent[k] === 'function') && typeof tasks?.getTaskMeta === 'function'; + async function probe() { + if (!capable) return { supported: false, reason: 'capability_unavailable' }; + try { + const clientId = globalThis.localStorage?.getItem('zcode-v4-client-id:v1'); + if (!clientId) return { supported:false, reason:'desktop_client_not_ready' }; + const h = await agent.helloConversationV4(); + return { supported: h?.protocolVersion === 3 && h?.clientMode === 'desktop-continuous', protocolVersion: h?.protocolVersion, clientMode: h?.clientMode }; + } catch { return { supported: false, reason: 'handshake_unavailable' }; } + } + async function meta(t) { + const m = await tasks.getTaskMeta({taskId: t.sessionId, workspacePath: t.workspacePath}); + if (!m || m.taskId !== t.sessionId || typeof m.workspacePath !== 'string' || pathKey(m.workspacePath) !== pathKey(t.workspacePath) || m.workspaceIdentity) throw Error('identity_unavailable'); + return m; + } + function summarize(t, s, m) { + const a = s?.session, p = s?.projection, r = s?.runtime; + if (a?.sessionId !== t.sessionId || p?.sessionId !== t.sessionId || !a.workspace || a.workspace.workspaceIdentity || a.workspace.remoteSessionId || pathKey(a.workspace.workspacePath || '') !== pathKey(t.workspacePath) || !Array.isArray(p.pendingPermissions) || !Array.isArray(r?.pendingRequestIds)) throw Error('snapshot_unavailable'); + const statuses = ['idle','running','waiting','paused','completed','error']; + if (!statuses.includes(a.status) || !statuses.includes(p.status)) throw Error('snapshot_unavailable'); + const pendingInput = !!m.pendingInteraction || p.pendingPermissions.length > 0 || r.pendingRequestIds.length > 0 || a.status === 'waiting' || p.status === 'waiting'; + let status = a.status; + if (a.status === 'paused' || p.status === 'paused') status = 'paused'; + else if (pendingInput) status = 'waiting'; + else if (a.status === 'running' || p.status === 'running' || r.activeTurnId || p.activeToolCalls?.some(x => ['running','pending'].includes(x.status))) status = 'running'; + else if (a.status === 'completed' || p.status === 'completed' || m.status === 'completed') status = 'completed'; + return {...scope(t), exists:true, status, pendingInput, updatedAt:a.updatedAt, turnId:r.activeTurnId || p.currentTurnId || null}; + } + const read = t => agent.readSession({...scope(t), runtimePolicy:'existing-only', messageLimit:1}); + async function inspect(targets) { + if (!Array.isArray(targets) || targets.length > 64 || targets.some(t => !valid(t))) throw Error('invalid_targets'); + if (!(await probe()).supported) return targets.map(t => ({...scope(t),exists:false,status:'unsupported',pendingInput:true})); + const results = []; + for (const t of targets) { + try { const m = await meta(t); results.push(summarize(t, await read(t), m)); } + catch { results.push({...scope(t),exists:false,status:'unavailable',pendingInput:true}); } + } + return results; + } + // Cache in-flight and uncertain sends too: a lost ACK must never cause a new send. + const commands = new Map(), busy = new Set(); + async function execute(t) { + const result = status => ({...scope(t),commandId:t.commandId,status}); + if (!(await probe()).supported) return result('unsupported'); + let m; + try { m = await meta(t); } catch { return result('identity_unavailable'); } + if (m.pendingInteraction) return result('blocked'); + if (m.status === 'completed') return result('completed'); + let snapshot; + try { snapshot = await read(t); } + catch { + // Only an explicitly journaled recovery may hydrate the original session. + if (!['running','error'].includes(m.status)) return result('unavailable'); + try { snapshot = await agent.resumeSession(scope(t)); } catch { return result('unavailable'); } + } + let state; + try { state = summarize(t, snapshot, m); } catch { return result('unavailable'); } + if (state.pendingInput || state.status === 'paused') return result('blocked'); + if (state.status === 'running') return result('already_running'); + if (state.status === 'completed') return result('completed'); + if (!['idle','error'].includes(state.status)) return result('blocked'); + // A new user turn after detection invalidates the captured recovery target. + if (t.baselineTurnId && state.turnId && t.baselineTurnId !== state.turnId) return result('changed'); + try { + const ack = await agent.sendConversationCommandV4({workspacePath:t.workspacePath,envelope:{commandId:t.commandId,clientId:globalThis.localStorage.getItem('zcode-v4-client-id:v1'),sessionId:t.sessionId,type:'sendText',payload:{text:'继续',requestedDelivery:'startNow'},issuedAt:t.issuedAt}}); + return {...result(ack?.commandId === t.commandId && ['accepted','rejected','stale','duplicate','noop','failed'].includes(ack?.status) ? ack.status : 'unknown_ack'), requiresObservation:true}; + } catch { return {...result('uncertain'),requiresObservation:true}; } + } + function resume(t) { + if (!valid(t) || typeof t.commandId !== 'string' || !/^[A-Za-z0-9_-]{1,200}$/.test(t.commandId) || !Number.isFinite(t.issuedAt) || !Number.isFinite(t.recoveryStartedAt) || t.recoveryStartedAt > t.issuedAt) return Promise.resolve({status:'invalid_target'}); + const key = pathKey(t.workspacePath) + '\0' + t.sessionId; + if (commands.has(t.commandId)) { const c=commands.get(t.commandId); return c.key === key ? c.promise : Promise.resolve({status:'command_scope_mismatch'}); } + if (busy.has(key)) return Promise.resolve({...scope(t),commandId:t.commandId,status:'busy'}); + if (commands.size >= 2048) return Promise.resolve({status:'command_capacity'}); + busy.add(key); + const promise = execute(t).then(r => { if (!r.requiresObservation) commands.delete(t.commandId); return r; }).finally(() => busy.delete(key)); + commands.set(t.commandId, {key,promise}); + return promise; + } + Object.defineProperty(globalThis,'__zswitchKeepalive',{value:Object.freeze({contractVersion:1,probe,inspect,resume}),configurable:true}); + return {contractVersion:1}; +})() diff --git a/src-tauri/src/keepalive/renderer.test.cjs b/src-tauri/src/keepalive/renderer.test.cjs new file mode 100644 index 0000000..6e86b65 --- /dev/null +++ b/src-tauri/src/keepalive/renderer.test.cjs @@ -0,0 +1,25 @@ +const {test}=require('node:test'); const assert=require('node:assert/strict'); const vm=require('node:vm'); const fs=require('node:fs'); +const source=fs.readFileSync(__dirname+'/renderer.js','utf8'); +const target={sessionId:'sess_one',workspacePath:'D:\\work',commandId:'recovery_one',issuedAt:200,recoveryStartedAt:100}; +function setup({status='error',permissions=[],pending=[],missing=false,readFail=false,metaStatus='error',sendFail=false}={}) { + const calls=[]; + const snapshot={session:{sessionId:'sess_one',workspace:{workspacePath:'D:\\work'},status,updatedAt:100},projection:{sessionId:'sess_one',status,pendingPermissions:permissions,activeToolCalls:[]},runtime:{pendingRequestIds:pending}}; + const agent={helloConversationV4:async()=>({protocolVersion:3,clientMode:'desktop-continuous'}),readSession:async p=>{calls.push(['read',p]);if(readFail)throw Error('cold');return snapshot},resumeSession:async p=>{calls.push(['hydrate',p]);return snapshot},sendConversationCommandV4:async p=>{calls.push(['send',p]);if(sendFail)throw Error('lost');return {status:'accepted',commandId:p.envelope.commandId}}}; + const tasks={getTaskMeta:async p=>{calls.push(['meta',p]);return {taskId:'sess_one',workspacePath:'D:\\work',status:metaStatus}}}; + const root={__reactContainer$test:{stateNode:{current:{memoizedProps:{value:{zcodeAgentService:agent,zcodeTaskService:tasks}}}}}}; + const ctx=vm.createContext({localStorage:{getItem:()=> 'test-desktop-client'},document:{getElementById:()=>missing?null:root}});vm.runInContext(source,ctx); + return {api:ctx.__zswitchKeepalive,calls,snapshot,tasks,agent}; +} +test('capabilities fail closed',async()=>{const {api}=setup({missing:true});assert.equal((await api.probe()).supported,false);assert.equal((await api.resume(target)).status,'unsupported')}); +test('read scoped existing-only; output excludes text',async()=>{const {api,calls}=setup();const [s]=await api.inspect([target]);assert.equal(s.status,'error');assert.equal(calls.find(c=>c[0]==='read')[1].runtimePolicy,'existing-only');assert.equal(Object.keys(s).includes('messages'),false)}); +for(const status of ['running','waiting','paused','completed'])test('blocks '+status,async()=>{const {api,calls}=setup({status});assert.notEqual((await api.resume(target)).status,'accepted');assert.equal(calls.some(c=>c[0]==='send'),false)}); +for(const arg of [{permissions:[{}]},{pending:['question']}])test('blocks pending interaction '+JSON.stringify(arg),async()=>{const {api,calls}=setup(arg);assert.equal((await api.resume(target)).status,'blocked');assert.equal(calls.some(c=>c[0]==='send'),false)}); +test('same command concurrently sends once; same id cannot change scope',async()=>{const {api,calls}=setup();const r=await Promise.all([api.resume(target),api.resume(target)]);assert.equal(r[0].status,'accepted');assert.equal(calls.filter(c=>c[0]==='send').length,1);assert.equal((await api.resume({...target,sessionId:'sess_other'})).status,'command_scope_mismatch')}); +test('uncertain ACK never resent',async()=>{const {api,calls}=setup({sendFail:true});assert.equal((await api.resume(target)).status,'uncertain');await api.resume(target);assert.equal(calls.filter(c=>c[0]==='send').length,1)}); +test('hydrate only explicit captured target and exact metadata',async()=>{const {api,calls}=setup({readFail:true});assert.equal((await api.resume({...target,recoveryStartedAt:undefined})).status,'invalid_target');assert.equal((await api.resume(target)).status,'accepted');assert.equal(calls.filter(c=>c[0]==='hydrate').length,1)}); +test('wrong workspace and remote blocked',async()=>{const {api,calls}=setup();assert.equal((await api.resume({...target,workspacePath:'D:\\other'})).status,'identity_unavailable');assert.equal((await api.resume({...target,workspaceIdentity:'ssh:host'})).status,'invalid_target');assert.equal(calls.some(c=>c[0]==='send'),false)}); +test('completed persisted task never hydrated',async()=>{const {api,calls}=setup({readFail:true,metaStatus:'completed'});assert.equal((await api.resume(target)).status,'completed');assert.equal(calls.some(c=>c[0]==='hydrate'),false)}); +test('unknown runtime schema fails closed',async()=>{const {api,snapshot}=setup();delete snapshot.runtime.pendingRequestIds;assert.equal((await api.resume(target)).status,'unavailable')}); +test('pre-send temporary failure can retry stable ID',async()=>{const {api,agent,calls}=setup({readFail:true});const original=agent.resumeSession;agent.resumeSession=async()=>{throw Error('not ready')};assert.equal((await api.resume(target)).status,'unavailable');agent.resumeSession=original;assert.equal((await api.resume(target)).status,'accepted');assert.equal(calls.filter(c=>c[0]==='send').length,1)}); +test('newer turn invalidates baseline',async()=>{const {api,snapshot,calls}=setup();snapshot.projection.currentTurnId='turn_new';assert.equal((await api.resume({...target,baselineTurnId:'turn_old'})).status,'changed');assert.equal(calls.some(c=>c[0]==='send'),false)}); +test('ACK must match command ID',async()=>{const {api,agent}=setup();agent.sendConversationCommandV4=async()=>({status:'accepted',commandId:'different'});assert.equal((await api.resume(target)).status,'unknown_ack')}); diff --git a/src-tauri/src/keepalive/windows.rs b/src-tauri/src/keepalive/windows.rs new file mode 100644 index 0000000..fc79a7a --- /dev/null +++ b/src-tauri/src/keepalive/windows.rs @@ -0,0 +1,295 @@ +use base64::Engine; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::{ + io::Read, + net::{SocketAddr, TcpStream}, + path::Path, + process::{Command, Stdio}, + time::Duration, +}; +use tungstenite::{protocol::WebSocketConfig, Message}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Instance { + pub pid: u32, + pub created_ms: i64, + pub executable: String, + #[serde(default)] + pub ports: Vec, +} + +fn process(request: Value) -> Result { + use std::os::windows::process::CommandExt; + let script: Vec = include_str!("process.ps1") + .encode_utf16() + .flat_map(u16::to_le_bytes) + .collect(); + let encoded = base64::engine::general_purpose::STANDARD.encode(script); + let mut child = Command::new("powershell.exe") + .args([ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-EncodedCommand", + &encoded, + ]) + .env("ZSW_KEEPALIVE_REQUEST", request.to_string()) + .creation_flags(0x0800_0000) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(|_| "Could not run the Windows process helper".to_owned())?; + let deadline = std::time::Instant::now() + Duration::from_secs(30); + loop { + if child.try_wait().map_err(|e| e.to_string())?.is_some() { + break; + } + if std::time::Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return Err("Windows process helper timed out; operation must be reconciled".into()); + } + std::thread::sleep(Duration::from_millis(50)); + } + let output = child.wait_with_output().map_err(|e| e.to_string())?; + if !output.status.success() { + // Do not forward PowerShell's error record, command line, or environment. + return Err("ZCode instance verification or lifecycle operation failed".into()); + } + serde_json::from_slice(&output.stdout).map_err(|_| "Invalid process helper response".into()) +} + +pub fn inspect(executable: &str, ports: bool) -> Result, String> { + serde_json::from_value(process( + json!({"action":"inspect","executable":executable,"ports":ports}), + )?) + .map_err(|_| "Invalid process identity".into()) +} + +pub fn stop(instance: &Instance) -> Result<(), String> { + process( + json!({"action":"stop","executable":instance.executable,"pid":instance.pid,"created_ms":instance.created_ms}), + )?; + Ok(()) +} + +pub fn start(executable: &str) -> Result { + serde_json::from_value(process(json!({"action":"start","executable":executable}))?) + .map_err(|_| "Invalid launched process identity".into()) +} + +pub fn same_instance(a: &Instance, b: &Instance) -> bool { + a.pid == b.pid + && a.created_ms == b.created_ms + && a.executable.eq_ignore_ascii_case(&b.executable) +} + +pub struct Renderer { + pub instance: Instance, + port: u16, + path: String, +} + +impl Renderer { + pub fn discover(instance: &Instance) -> Result { + let agent = ureq::AgentBuilder::new() + .timeout(Duration::from_secs(2)) + .build(); + for port in instance.ports.iter().take(8) { + let response = match agent + .get(&format!("http://127.0.0.1:{port}/json/list")) + .call() + { + Ok(r) => r, + Err(_) => continue, + }; + let mut raw = String::new(); + if response + .into_reader() + .take(256 * 1024) + .read_to_string(&mut raw) + .is_err() + { + continue; + } + let Ok(targets) = serde_json::from_str::>(&raw) else { + continue; + }; + for target in targets { + if target["type"] != "page" { + continue; + } + let Some(url) = target["url"].as_str() else { + continue; + }; + if !is_zcode_renderer(url, &instance.executable) { + continue; + } + let Some(ws) = target["webSocketDebuggerUrl"].as_str() else { + continue; + }; + let prefix = format!("ws://127.0.0.1:{port}/devtools/page/"); + let Some(id) = ws.strip_prefix(&prefix) else { + continue; + }; + if id.is_empty() || !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') { + continue; + } + return Ok(Self { + instance: instance.clone(), + port: *port, + path: format!("/devtools/page/{id}"), + }); + } + } + Err("Waiting for the ZCode desktop control connection".into()) + } + + pub fn evaluate(&self, expression: &str) -> Result { + let current = + inspect(&self.instance.executable, true)?.ok_or("ZCode is no longer running")?; + if !same_instance(¤t, &self.instance) || !current.ports.contains(&self.port) { + return Err("ZCode control connection ownership changed".into()); + } + let address: SocketAddr = ([127, 0, 0, 1], self.port).into(); + let stream = TcpStream::connect_timeout(&address, Duration::from_secs(3)) + .map_err(|_| "ZCode control connection unavailable")?; + stream + .set_read_timeout(Some(Duration::from_secs(20))) + .map_err(|e| e.to_string())?; + stream + .set_write_timeout(Some(Duration::from_secs(3))) + .map_err(|e| e.to_string())?; + let url = format!("ws://127.0.0.1:{}{}", self.port, self.path); + let config = WebSocketConfig { + max_message_size: Some(2 * 1024 * 1024), + max_frame_size: Some(2 * 1024 * 1024), + ..Default::default() + }; + let (mut socket, _) = + tungstenite::client::client_with_config(url.as_str(), stream, Some(config)) + .map_err(|_| "ZCode control handshake failed")?; + let request = json!({"id":1,"method":"Runtime.evaluate","params":{"expression":expression,"returnByValue":true,"awaitPromise":true,"timeout":15000}}); + socket + .send(Message::Text(request.to_string())) + .map_err(|_| "Could not send ZCode control request")?; + let deadline = std::time::Instant::now() + Duration::from_secs(20); + while std::time::Instant::now() < deadline { + let msg = socket + .read() + .map_err(|_| "ZCode control response was interrupted")?; + let Message::Text(text) = msg else { continue }; + let value: Value = + serde_json::from_str(&text).map_err(|_| "Invalid ZCode control response")?; + if value["id"] != 1 { + continue; + } + let _ = socket.close(None); + if value.get("error").is_some() || value["result"].get("exceptionDetails").is_some() { + return Err("ZCode does not support the required keepalive operation".into()); + } + return Ok(value["result"]["result"]["value"].clone()); + } + Err("ZCode control request timed out".into()) + } +} + +fn is_zcode_renderer(url: &str, executable: &str) -> bool { + let Some(parent) = Path::new(executable).parent() else { + return false; + }; + let expected = format!( + "file:///{}/resources/app.asar/out/renderer/index.html", + parent + .to_string_lossy() + .replace('\\', "/") + .trim_end_matches('/') + ); + // Decode only URL escapes, never accept another scheme, host, or renderer path. + let decoded = + percent_encoding::percent_decode_str(url.split('?').next().unwrap_or(url)).decode_utf8(); + decoded + .map(|s| s.eq_ignore_ascii_case(&expected)) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn only_the_selected_installation_renderer_is_accepted() { + assert!(is_zcode_renderer("file:///C:/Program%20Files/ZCode/resources/app.asar/out/renderer/index.html?restoreSession=true", r"C:\Program Files\ZCode\ZCode.exe")); + for url in [ + "https://evil.test/index.html", + "file:///D:/other/resources/app.asar/out/renderer/index.html", + "file:///D:/ZCode/resources/app.asar/out/renderer/index.html.evil", + ] { + assert!(!is_zcode_renderer(url, r"D:\ZCode\ZCode.exe")); + } + } + #[test] + fn windows_helper_encodes_absent_instance_as_json_null() { + let nonexistent = std::env::temp_dir().join(format!( + "zswitch-absent-{}\\ZCode.exe", + uuid::Uuid::new_v4() + )); + assert!(inspect(&nonexistent.to_string_lossy(), false) + .unwrap() + .is_none()); + } + #[test] + #[ignore = "requires an explicitly selected isolated ZCode fixture"] + fn isolated_installed_renderer_uses_the_real_windows_and_cdp_bridge() { + let executable = + std::env::var("ZSW_KEEPALIVE_TEST_EXE").expect("set the isolated fixture executable"); + let instance = inspect(&executable, true) + .unwrap() + .expect("fixture running"); + let renderer = Renderer::discover(&instance).unwrap(); + renderer.evaluate(include_str!("renderer.js")).unwrap(); + let result = renderer + .evaluate("globalThis.__zswitchKeepalive.probe()") + .unwrap(); + assert_eq!(result["supported"], true); + assert_eq!(result["clientMode"], "desktop-continuous"); + } + #[test] + #[ignore = "restarts an explicitly isolated installation on a hidden test desktop"] + fn isolated_process_lifecycle_restarts_only_the_selected_installation() { + let executable = std::env::var("ZSW_KEEPALIVE_TEST_EXE").expect("isolated executable"); + let root = std::env::var("ZSW_KEEPALIVE_TEST_ROOT").expect("isolated root"); + let home = std::env::var("ZCODE_DESKTOP_HOME_DIR").expect("isolated home"); + assert!(Path::new(&executable).starts_with(&root)); + assert!(Path::new(&home).starts_with(&root)); + assert_eq!(std::env::var("ZCODE_DATA_BASE_DIR").unwrap(), home); + assert_eq!( + std::env::var("ZCODE_DESKTOP_APPLICATION_NAME").unwrap(), + "ZCode Keepalive Probe" + ); + let before = inspect(&executable, false) + .unwrap() + .expect("fixture running"); + stop(&before).unwrap(); + assert!(inspect(&executable, false).unwrap().is_none()); + let after = start(&executable).unwrap(); + assert!(!same_instance(&before, &after)); + let deadline = std::time::Instant::now() + Duration::from_secs(45); + let ready = loop { + let current = inspect(&executable, true).unwrap().unwrap(); + assert!(same_instance(&after, ¤t)); + if Renderer::discover(¤t).is_ok() { + break current; + } + assert!(std::time::Instant::now() < deadline, "renderer not ready"); + std::thread::sleep(Duration::from_secs(1)); + }; + std::fs::write( + Path::new(&root).join("lifecycle-proof.json"), + serde_json::to_vec_pretty(&json!({"before":before,"after":ready,"result":"passed"})) + .unwrap(), + ) + .unwrap(); + } +} diff --git a/src-tauri/src/keepalive/worker.rs b/src-tauri/src/keepalive/worker.rs new file mode 100644 index 0000000..d677c21 --- /dev/null +++ b/src-tauri/src/keepalive/worker.rs @@ -0,0 +1,729 @@ +use super::{ + log_reader::{EventKind, LogReader, ObservedEvent}, + windows::{self, Instance, Renderer}, + *, +}; +use crate::store::{self, Paths}; +use rusqlite::{Connection, OpenFlags}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use sha2::{Digest, Sha256}; +use std::{ + collections::{BTreeMap, HashMap}, + path::PathBuf, + sync::mpsc::{self, Sender}, + time::Duration, +}; + +static COMMANDS: OnceLock> = OnceLock::new(); +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Target { + session_id: String, + workspace_path: String, +} +struct Pending { + target: Target, + baseline_turn: Option, + detected_at: i64, + command_id: String, + submitted_at: Option, +} +struct Context { + generation: u64, + executable: String, + data_root: PathBuf, + account: String, + reader: LogReader, + pending: BTreeMap, + active: HashMap, + instance: Option, + restarted_at: Option, + next_retry: i64, + needs_restart: bool, + launch_pending: bool, + control_ready: bool, + state: Status, +} + +fn account(paths: &Paths) -> Result { + let credentials = + store::read_live(paths)?.ok_or("Sign in to ZCode before enabling keepalive")?; + let identity = crate::zcrypto::account_identity(&credentials, &paths.home); + let user = identity + .user_id + .ok_or("ZCode account identity could not be verified")?; + Ok(format!( + "{:x}", + Sha256::digest(format!("{}\0{}", identity.provider, user)) + )) +} + +pub fn enable() -> Result<(), String> { + if LAUNCH_COMMITTED.load(Ordering::SeqCst) { + return Err("ZCode is finishing a committed restart; wait or start it manually".into()); + } + let paths = Paths::detect(); + let (executable, ok) = store::effective_zcode_path(&paths); + if !ok { + return Err("Select a valid ZCode installation before enabling keepalive".into()); + } + let account = account(&paths)?; + let reader = LogReader::new( + paths.zcode_dir().join("cli/log"), + chrono::Utc::now().timestamp_millis(), + )?; + let generation = GENERATION.fetch_add(1, Ordering::SeqCst) + 1; + let state = Status { + enabled: true, + phase: "watching".into(), + ..Status::default() + }; + let context = Context { + generation, + executable, + data_root: paths.data_root, + account, + reader, + pending: BTreeMap::new(), + active: HashMap::new(), + instance: None, + restarted_at: None, + next_retry: 0, + needs_restart: false, + launch_pending: false, + control_ready: false, + state: state.clone(), + }; + ENABLED.store(true, Ordering::SeqCst); + COMMANDS + .get() + .ok_or("Keepalive worker is not ready")? + .send(context) + .map_err(|_| "Keepalive worker is unavailable")?; + publish(state); + Ok(()) +} + +pub fn start() { + let (tx, rx) = mpsc::channel::(); + if COMMANDS.set(tx).is_err() { + return; + } + std::thread::spawn(move || { + let mut current: Option = None; + loop { + match rx.recv_timeout(Duration::from_secs(2)) { + Ok(next) => current = Some(next), + Err(mpsc::RecvTimeoutError::Disconnected) => break, + Err(mpsc::RecvTimeoutError::Timeout) => {} + } + let Some(c) = current.as_mut() else { continue }; + if !c.enabled() { + if c.launch_pending && FINISH_ON_CANCEL.load(Ordering::SeqCst) { + if chrono::Utc::now().timestamp_millis() < c.next_retry { + continue; + } + let _guard = crate::store_guard(); + if !FINISH_ON_CANCEL.load(Ordering::SeqCst) || ENABLED.load(Ordering::SeqCst) { + current = None; + continue; + } + // Cancellation stops continuation, but still completes the + // already committed shutdown when launching is possible. + let executable = c.executable.clone(); + let completed = match windows::inspect(&executable, false) { + Ok(Some(_)) => Ok(()), + Ok(None) => c.finish_launch(|| windows::start(&executable)), + Err(e) => Err(e), + }; + if !c.finish_cancelled_attempt(completed) { + continue; + } + } + current = None; + continue; + } + if let Err(_e) = c.tick() { + c.state.phase = "error".into(); + c.state.reason = Some("unavailable".into()); + c.next_retry = chrono::Utc::now().timestamp_millis() + 15_000; + } + if c.enabled() { + c.state.pending = c.pending.len(); + c.state.next_retry_at = + (c.next_retry > chrono::Utc::now().timestamp_millis()).then_some(c.next_retry); + publish(c.state.clone()); + } + } + }); +} + +fn target(paths: &Paths, id: &str, collateral: bool) -> Result, String> { + if id.is_empty() || id.len() > 200 { + return Ok(None); + } + let db = Connection::open_with_flags( + paths.zcode_dir().join("v2/tasks-index.sqlite"), + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .map_err(|_| "Cannot read ZCode task index")?; + db.busy_timeout(Duration::from_secs(2)) + .map_err(|e| e.to_string())?; + let rows = db.prepare("SELECT workspace_path FROM tasks WHERE task_id=?1 AND deleted=0 AND archived=0 AND (workspace_identity IS NULL OR workspace_identity='') AND (task_status='running' OR (?2=0 AND task_status='error'))").map_err(|_| "Unsupported ZCode task index")? + .query_map(rusqlite::params![id, collateral], |row| row.get::<_, String>(0)).map_err(|_| "Cannot read ZCode task index")? + .collect::, _>>().map_err(|_| "Cannot read ZCode task index")?; + if rows.len() != 1 || !PathBuf::from(&rows[0]).is_absolute() { + return Ok(None); + } + Ok(Some(Target { + session_id: id.into(), + workspace_path: rows[0].clone(), + })) +} + +impl Context { + fn finish_cancelled_attempt(&mut self, result: Result<(), String>) -> bool { + if result.is_err() { + self.state.attempt = self.state.attempt.saturating_add(1); + self.next_retry = + chrono::Utc::now().timestamp_millis() + retry_delay_ms(self.state.attempt); + let mut state = Status::default(); + state.reason = Some("restart_incomplete".into()); + state.next_retry_at = Some(self.next_retry); + publish(state); + return false; + } + self.launch_pending = false; + LAUNCH_COMMITTED.store(false, Ordering::SeqCst); + publish(Status::default()); + true + } + fn finish_launch( + &mut self, + start: impl FnOnce() -> Result, + ) -> Result<(), String> { + let instance = start()?; + self.instance = Some(instance); + self.launch_pending = false; + LAUNCH_COMMITTED.store(false, Ordering::SeqCst); + self.restarted_at = Some(chrono::Utc::now().timestamp_millis()); + Ok(()) + } + fn enabled(&self) -> bool { + ENABLED.load(Ordering::SeqCst) && GENERATION.load(Ordering::SeqCst) == self.generation + } + fn observe_snapshot(&mut self, state: &serde_json::Value, now: i64) { + let Some(id) = state["sessionId"].as_str() else { + return; + }; + let Some(p) = self.pending.get(id) else { + return; + }; + let status = state["status"].as_str().unwrap_or("unavailable"); + let changed = state["turnId"] + .as_str() + .is_some_and(|turn| p.baseline_turn.as_deref() != Some(turn)); + if matches!(status, "paused" | "waiting") { + self.pending.remove(id); + } else if status == "completed" { + if changed { + self.state.recovered += 1; + } + self.pending.remove(id); + } else if status == "error" && changed { + self.pending.remove(id); + } else if matches!(status, "error" | "idle") + && p.submitted_at.is_some_and(|sent| now - sent > 60_000) + { + self.needs_restart = true; + self.next_retry = now + retry_delay_ms(self.state.attempt); + } + } + fn observe(&mut self, paths: &Paths, event: ObservedEvent) -> Result<(), String> { + let id = event.session_id.clone(); + match event.kind { + EventKind::CaptchaFailure => { + self.active.remove(&id); + if let Some(t) = target(paths, &id, false)? { + let p = Pending { + target: t, + baseline_turn: Some(event.turn_id), + detected_at: event.at_ms, + command_id: uuid::Uuid::new_v4().to_string(), + submitted_at: None, + }; + self.pending.insert(id, p); + self.needs_restart = true; + // A fresh failure after real progress starts a new recovery cycle. + self.next_retry = if self.state.attempt == 0 { + 0 + } else { + chrono::Utc::now().timestamp_millis() + retry_delay_ms(self.state.attempt) + }; + } + } + EventKind::TurnStarted => { + if self.pending.get(&id).is_some_and(|p| { + p.submitted_at.is_none() + && event.at_ms > p.detected_at + && p.baseline_turn.as_deref() != Some(&event.turn_id) + }) { + self.pending.remove(&id); + } + self.active.insert(id, (event.turn_id, event.at_ms)); + } + EventKind::Progress | EventKind::TurnCompleted => { + if self.pending.get(&id).is_some_and(|p| { + p.submitted_at.is_some_and(|sent| event.at_ms >= sent) + && p.baseline_turn.as_deref() != Some(&event.turn_id) + }) { + self.pending.remove(&id); + self.state.recovered += 1; + } + if event.kind == EventKind::TurnCompleted { + self.active.remove(&id); + } else { + self.active.insert(id, (event.turn_id, event.at_ms)); + } + } + EventKind::OtherFailure => { + self.active.remove(&id); + self.pending.remove(&id); + } + } + // Bound in-memory observation to recent local activity. + let floor = chrono::Utc::now().timestamp_millis() - 24 * 60 * 60 * 1000; + self.active.retain(|_, (_, at)| *at >= floor); + if self.active.len() > 512 { + self.active.clear(); + } + Ok(()) + } + fn tick(&mut self) -> Result<(), String> { + let paths = Paths::detect(); + if paths.data_root != self.data_root + || store::effective_zcode_path(&paths).0 != self.executable + || account(&paths)? != self.account + { + manual_action()?; + let mut s = Status::default(); + s.reason = Some("identity_changed".into()); + publish(s); + return Ok(()); + } + for event in self.reader.poll()? { + self.observe(&paths, event)?; + } + if self.pending.is_empty() && !self.launch_pending { + self.needs_restart = false; + self.state.phase = "watching".into(); + self.state.reason = None; + self.state.attempt = 0; + self.next_retry = 0; + self.restarted_at = None; + return Ok(()); + } + let now = chrono::Utc::now().timestamp_millis(); + if now < self.next_retry { + self.state.phase = "retrying".into(); + return Ok(()); + } + let Ok(_guard) = crate::STORE_LOCK.try_lock() else { + return Ok(()); + }; + if !self.enabled() { + return Ok(()); + } + if self.launch_pending { + if let Some(existing) = windows::inspect(&self.executable, false)? { + if self + .instance + .as_ref() + .is_some_and(|old| windows::same_instance(old, &existing)) + { + self.launch_pending = false; + LAUNCH_COMMITTED.store(false, Ordering::SeqCst); + self.needs_restart = true; + self.next_retry = now + retry_delay_ms(self.state.attempt); + } else { + // A replacement outside our launch receipt must not receive + // recovery commands merely because it uses the same path. + self.pending.clear(); + self.active.clear(); + self.launch_pending = false; + LAUNCH_COMMITTED.store(false, Ordering::SeqCst); + self.instance = None; + } + return Ok(()); + } + self.state.phase = "connecting".into(); + let executable = self.executable.clone(); + self.finish_launch(|| windows::start(&executable))?; + return Ok(()); + } + if self.needs_restart { + let Some(instance) = windows::inspect(&self.executable, true)? else { + // A manual close is not permission to resurrect ZCode. + self.state.phase = "waiting_app".into(); + return Ok(()); + }; + if let Some(old) = &self.instance { + if !windows::same_instance(old, &instance) { + self.pending.clear(); + self.active.clear(); + self.instance = None; + return Ok(()); + } + } + // Include only activity observed during this opt-in period. Inspect + // after restart still excludes permissions, questions and manual stop. + for (id, (turn, at)) in &self.active { + if !self.pending.contains_key(id) { + if let Some(t) = target(&paths, id, true)? { + self.pending.insert( + id.clone(), + Pending { + target: t, + baseline_turn: Some(turn.clone()), + detected_at: *at, + command_id: uuid::Uuid::new_v4().to_string(), + submitted_at: None, + }, + ); + } + } + } + if self.pending.len() > 64 { + self.state.phase = "error".into(); + self.state.reason = Some("too_many".into()); + return Ok(()); + } + self.state.phase = "restarting".into(); + publish(self.state.clone()); + if !self.enabled() { + return Ok(()); + } + // Persist the in-memory shutdown intent before the helper: its + // response can be lost after it has already stopped the process. + self.launch_pending = true; + LAUNCH_COMMITTED.store(true, Ordering::SeqCst); + self.instance = Some(instance.clone()); + windows::stop(&instance)?; + self.instance = None; + self.needs_restart = false; + self.control_ready = false; + self.state.attempt = self.state.attempt.saturating_add(1); + for p in self.pending.values_mut() { + p.submitted_at = None; + p.command_id = uuid::Uuid::new_v4().to_string(); + } + // Complete a committed restart even if switched off during shutdown; + // cancellation prevents all subsequent continuation messages. + let executable = self.executable.clone(); + self.finish_launch(|| windows::start(&executable))?; + self.state.phase = "connecting".into(); + return Ok(()); + } + let Some(current) = windows::inspect(&self.executable, true)? else { + if !self.control_ready && self.restarted_at.is_some() { + self.launch_pending = true; + LAUNCH_COMMITTED.store(true, Ordering::SeqCst); + self.instance = None; + self.next_retry = now + retry_delay_ms(self.state.attempt); + } + self.state.phase = "waiting_app".into(); + return Ok(()); + }; + if self + .instance + .as_ref() + .is_none_or(|i| !windows::same_instance(i, ¤t)) + { + self.pending.clear(); + self.active.clear(); + self.instance = None; + return Ok(()); + } + let renderer = match Renderer::discover(¤t) { + Ok(r) => r, + Err(_) => { + self.state.phase = "connecting".into(); + if now - self.restarted_at.unwrap_or(now) > 90_000 { + self.state.phase = "error".into(); + self.state.reason = Some("control_unavailable".into()); + if self.pending.values().all(|p| p.submitted_at.is_none()) { + self.needs_restart = true; + self.next_retry = now + retry_delay_ms(self.state.attempt); + } + } + return Ok(()); + } + }; + renderer.evaluate(include_str!("renderer.js"))?; + let probe = renderer.evaluate("globalThis.__zswitchKeepalive.probe()")?; + if probe["supported"] != true { + self.state.phase = "error".into(); + self.state.reason = Some("unsupported_version".into()); + self.next_retry = now + 30_000; + return Ok(()); + } + self.control_ready = true; + self.state.phase = "resuming".into(); + self.state.reason = None; + let observing: Vec<_> = self + .pending + .values() + .filter(|p| p.submitted_at.is_some()) + .map(|p| p.target.clone()) + .collect(); + if !observing.is_empty() { + let states = renderer.evaluate(&format!( + "globalThis.__zswitchKeepalive.inspect({})", + serde_json::to_string(&observing).unwrap() + ))?; + if let Some(states) = states.as_array() { + for state in states { + self.observe_snapshot(state, now); + } + } + } + let ids: Vec<_> = self.pending.keys().cloned().collect(); + for id in ids { + if !self.enabled() { + break; + } + let p = self.pending.get(&id).unwrap(); + if p.submitted_at.is_some() { + continue; + } + let request = json!({"sessionId":p.target.session_id,"workspacePath":p.target.workspace_path,"commandId":p.command_id,"issuedAt":now,"recoveryStartedAt":p.detected_at,"baselineTurnId":p.baseline_turn}); + let result = + renderer.evaluate(&format!("globalThis.__zswitchKeepalive.resume({request})")); + match result.as_ref().ok().and_then(|v| v["status"].as_str()) { + Some( + "accepted" | "duplicate" | "uncertain" | "unknown_ack" | "already_running" + | "rejected" | "stale" | "noop" | "failed", + ) => { + self.pending.get_mut(&id).unwrap().submitted_at = Some(now); + } + Some("completed" | "blocked" | "changed" | "identity_unavailable") => { + self.pending.remove(&id); + } + _ => { + self.state.reason = Some("resume_unavailable".into()); + self.next_retry = now + 15_000; + } + } + } + self.state.phase = "observing".into(); + // Never restart merely for slow progress. A fresh CAPTCHA failure or + // a verified idle/error snapshot is required; unknown ACKs are observed. + if self.needs_restart { + self.next_retry = now + retry_delay_ms(self.state.attempt); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + struct Fixture { + paths: Paths, + context: Context, + } + impl Fixture { + fn new() -> Self { + let home = + std::env::temp_dir().join(format!("zswitch-worker-{}", uuid::Uuid::new_v4())); + let paths = Paths { + home: home.clone(), + data_root: home.clone(), + }; + std::fs::create_dir_all(paths.zcode_dir().join("v2")).unwrap(); + let db = Connection::open(paths.zcode_dir().join("v2/tasks-index.sqlite")).unwrap(); + db.execute_batch("CREATE TABLE tasks(task_id TEXT, workspace_path TEXT, workspace_identity TEXT, task_status TEXT,deleted INTEGER DEFAULT 0,archived INTEGER DEFAULT 0); INSERT INTO tasks VALUES ('sess-a','C:\\work',NULL,'error',0,0),('sess-b','C:\\work',NULL,'running',0,0),('sess-c','C:\\work','ssh:test','running',0,0),('sess-d','C:\\work',NULL,'completed',0,0);").unwrap(); + let context = Context { + generation: u64::MAX, + executable: "fixture.exe".into(), + data_root: home, + account: "fixture".into(), + reader: LogReader::new(paths.zcode_dir().join("cli/log"), 0).unwrap(), + pending: BTreeMap::new(), + active: HashMap::new(), + instance: None, + restarted_at: None, + next_retry: 0, + needs_restart: false, + launch_pending: false, + control_ready: false, + state: Status::default(), + }; + Self { paths, context } + } + fn failure(&mut self) { + self.context + .observe( + &self.paths, + ObservedEvent { + session_id: "sess-a".into(), + turn_id: "turn-original".into(), + at_ms: chrono::Utc::now().timestamp_millis(), + kind: EventKind::CaptchaFailure, + }, + ) + .unwrap(); + } + } + impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.paths.home); + } + } + #[test] + fn collateral_requires_running_and_exact_local_index_membership() { + let f = Fixture::new(); + assert!(target(&f.paths, "sess-a", false).unwrap().is_some()); + assert!(target(&f.paths, "sess-a", true).unwrap().is_none()); + assert!(target(&f.paths, "sess-b", true).unwrap().is_some()); + for id in ["sess-c", "sess-d", "missing"] { + assert!(target(&f.paths, id, false).unwrap().is_none()); + } + } + #[test] + fn user_started_new_turn_cancels_captured_recovery() { + let mut f = Fixture::new(); + f.failure(); + let at = f.context.pending["sess-a"].detected_at + 1; + f.context + .observe( + &f.paths, + ObservedEvent { + session_id: "sess-a".into(), + turn_id: "turn-user".into(), + at_ms: at, + kind: EventKind::TurnStarted, + }, + ) + .unwrap(); + assert!(f.context.pending.is_empty()); + } + #[test] + fn acknowledged_send_requires_progress_from_a_new_turn() { + let mut f = Fixture::new(); + f.failure(); + let at = f.context.pending["sess-a"].detected_at + 1; + f.context.pending.get_mut("sess-a").unwrap().submitted_at = Some(at); + for (turn, kind) in [ + ("turn-original", EventKind::Progress), + ("turn-new", EventKind::TurnStarted), + ] { + f.context + .observe( + &f.paths, + ObservedEvent { + session_id: "sess-a".into(), + turn_id: turn.into(), + at_ms: at, + kind, + }, + ) + .unwrap(); + assert_eq!(f.context.state.recovered, 0); + assert_eq!(f.context.pending.len(), 1); + } + f.context + .observe( + &f.paths, + ObservedEvent { + session_id: "sess-a".into(), + turn_id: "turn-new".into(), + at_ms: at + 1, + kind: EventKind::Progress, + }, + ) + .unwrap(); + assert_eq!(f.context.state.recovered, 1); + assert!(f.context.pending.is_empty()); + } + #[test] + fn ambiguous_ack_retries_only_after_runtime_proves_idle() { + let mut f = Fixture::new(); + f.failure(); + f.context.needs_restart = false; + f.context.state.attempt = 3; + f.context.pending.get_mut("sess-a").unwrap().submitted_at = Some(1); + f.context.observe_snapshot( + &json!({"sessionId":"sess-a","status":"running","turnId":"turn-new"}), + 1_000_000, + ); + assert!(!f.context.needs_restart); + f.context.observe_snapshot( + &json!({"sessionId":"sess-a","status":"unavailable"}), + 1_000_000, + ); + assert!(!f.context.needs_restart); + f.context.observe_snapshot( + &json!({"sessionId":"sess-a","status":"error","turnId":"turn-original"}), + 1_000_000, + ); + assert!(f.context.needs_restart); + assert_eq!(f.context.next_retry, 1_060_000); + } + #[test] + fn pending_input_or_manual_pause_is_never_retried() { + for status in ["waiting", "paused"] { + let mut f = Fixture::new(); + f.failure(); + f.context.needs_restart = false; + f.context.pending.get_mut("sess-a").unwrap().submitted_at = Some(1); + f.context + .observe_snapshot(&json!({"sessionId":"sess-a","status":status}), 1_000_000); + assert!(f.context.pending.is_empty()); + assert!(!f.context.needs_restart); + assert_eq!(f.context.state.recovered, 0); + } + } + #[test] + fn committed_launch_survives_failure_and_can_finish_after_cancellation() { + let mut f = Fixture::new(); + f.context.launch_pending = true; + assert!(!f.context.enabled()); + assert!(f + .context + .finish_launch(|| Err("launch failure".into())) + .is_err()); + assert!(f.context.launch_pending); + assert!(f.context.instance.is_none()); + f.context + .finish_launch(|| { + Ok(Instance { + pid: 42, + created_ms: 1, + executable: "fixture.exe".into(), + ports: vec![], + }) + }) + .unwrap(); + assert!(!f.context.launch_pending); + assert_eq!(f.context.instance.as_ref().unwrap().pid, 42); + assert!(!f.context.enabled()); + assert!(f.context.pending.is_empty()); + } + #[test] + fn cancellation_loop_retains_only_launch_repair_until_success() { + let mut f = Fixture::new(); + f.context.launch_pending = true; + assert!(!f + .context + .finish_cancelled_attempt(Err("children still exiting".into()))); + assert!(f.context.launch_pending); + assert!(f.context.next_retry > chrono::Utc::now().timestamp_millis()); + assert!(!f.context.enabled()); + assert!(f.context.pending.is_empty()); + assert!(f.context.finish_cancelled_attempt(Ok(()))); + assert!(!f.context.launch_pending); + assert!(!f.context.enabled()); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d7b0fb5..ee0cb7c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,6 +8,7 @@ mod probe; mod quota; mod store; mod zcrypto; +mod keepalive; use serde_json::{json, Value}; use std::sync::Mutex; @@ -20,6 +21,11 @@ use tauri_plugin_dialog::DialogExt; const TRAY_ID: &str = "main"; +#[tauri::command] +async fn set_keepalive(enabled: bool) -> Result { + keepalive::configure(enabled) +} + static STORE_LOCK: Mutex<()> = Mutex::new(()); struct PendingClaim { @@ -119,9 +125,16 @@ fn show_main(app: &AppHandle) { } fn run_tray_action(app: AppHandle, action: String) { + if action == "kill" || action == "launch" { keepalive::cancel_manual(); } std::thread::spawn(move || { let paths = Paths::detect(); let mut emit_payload = json!({ "action": action }); + if action == "kill" || action == "launch" { + if let Err(e) = keepalive::manual_action() { + let _ = app.emit("tray-action", json!({"action":action,"ok":false,"error":e})); + return; + } + } let _guard = store_guard(); let result: Result = match action.as_str() { "capture" => store::capture_current(&paths, None).map(|a| json!({ "name": a.name })), @@ -195,6 +208,7 @@ async fn update_account_from_live(app: AppHandle, id: String) -> Result Result { + keepalive::manual_action()?; let _guard = store_guard(); let paths = Paths::detect(); let hot = load_settings(&paths).hot_switch(); @@ -832,6 +846,7 @@ fn open_captcha_window(app: &AppHandle, auto: bool) -> Result<(), String> { #[tauri::command] async fn kill_zcode(app: AppHandle) -> Result<(), String> { + keepalive::manual_action()?; let _guard = store_guard(); let r = if store::kill_zcode()? { Ok(()) } else { Err(i18n::tr("err.zcode.kill_timeout")) }; rebuild_tray(&app); @@ -1096,6 +1111,7 @@ async fn pick_zcode_path(app: AppHandle) -> Result { #[tauri::command] async fn set_zcode_path(app: AppHandle, path: String) -> Result<(), String> { + keepalive::manual_action()?; let _guard = store_guard(); let paths = Paths::detect(); let mut s = load_settings(&paths); @@ -1108,6 +1124,7 @@ async fn set_zcode_path(app: AppHandle, path: String) -> Result<(), String> { #[tauri::command] async fn launch_zcode(app: AppHandle) -> Result<(), String> { + keepalive::manual_action()?; let paths = Paths::detect(); let (p, ok) = effective_zcode_path(&paths); if !ok { @@ -1155,6 +1172,7 @@ pub fn run() { set_auth_proxy, kill_zcode, set_behavior, + set_keepalive, set_language, autostart_status, autostart_set, @@ -1186,6 +1204,7 @@ pub fn run() { } }) .setup(|app| { + keepalive::start(); i18n::init_from_settings(&store::load_settings(&Paths::detect())); if let Ok(data_dir) = app.path().app_local_data_dir() { flowlog::init(&data_dir); diff --git a/src-tauri/src/store.rs b/src-tauri/src/store.rs index 108842f..eda5fda 100644 --- a/src-tauri/src/store.rs +++ b/src-tauri/src/store.rs @@ -142,6 +142,8 @@ pub struct Settings { pub language: Option, #[serde(default)] pub auto_claim: Option, + #[serde(default)] + pub keepalive_enabled: Option, } impl Settings { @@ -173,6 +175,7 @@ pub struct AccountSummary { #[derive(Serialize, Clone, Debug)] pub struct AppState { + pub keepalive: crate::keepalive::Status, pub zcode_running: bool, pub live_exists: bool, pub live_logged_in: bool, @@ -1504,6 +1507,7 @@ pub fn get_state(paths: &Paths) -> Result { }) .collect(); Ok(AppState { + keepalive: crate::keepalive::status(), zcode_running: zcode_running(), live_exists: paths.live_file().exists(), live_logged_in, diff --git a/src/locales/en.js b/src/locales/en.js index 38576f5..4809b63 100644 --- a/src/locales/en.js +++ b/src/locales/en.js @@ -1,4 +1,29 @@ export const en = { + "k.label": "Keepalive", + "k.title": "Detect CAPTCHA interruptions, restart ZCode and resume the original conversations. Turn off to stop recovery.", + "k.enableTitle": "Enable ZCode keepalive?", + "k.enable": "Enable keepalive", + "k.enableDesc": "Windows only. While this app runs, a new CAPTCHA failure immediately restarts the current ZCode instance and resumes affected conversations. Repeated failures retry with increasing delays. Restart interrupts running tasks in that instance. ZCode starts with a loopback debug interface that other local software may also access. Your account and ZCode installation are preserved. Turning off stops continuation; an ongoing restart finishes. The debug interface closes after a normal ZCode restart.", + "k.phase.off": "Keepalive off", + "k.phase.watching": "Watching for CAPTCHA interruptions", + "k.phase.restarting": "Restarting ZCode", + "k.phase.connecting": "Waiting for ZCode to become ready", + "k.phase.resuming": "Resuming original conversations", + "k.phase.observing": "Waiting for execution progress", + "k.phase.retrying": "Waiting for the next recovery attempt", + "k.phase.waiting_app": "Waiting for ZCode to start", + "k.phase.error": "Keepalive needs attention", + "k.pending": "{n} pending", + "k.recovered": "{n} recovered", + "k.retry": "Retry in {n}s", + "k.reason.setup": "Check the ZCode installation path and sign-in, then enable again.", + "k.reason.unavailable": "ZCode state could not be verified. Further actions were stopped.", + "k.reason.identity_changed": "The account, path or instance changed. Enable keepalive again.", + "k.reason.too_many": "More than 64 recovery targets. Reduce running conversations first.", + "k.reason.control_unavailable": "The local control connection is not ready. Check that ZCode started correctly.", + "k.reason.unsupported_version": "This ZCode version does not support the required session interface. Recovery is paused.", + "k.reason.resume_unavailable": "The conversation is not ready to resume. Retrying shortly.", + "k.reason.restart_incomplete": "Keepalive is off. Retrying the committed relaunch; you can also start ZCode manually.", "common.cancel": "Cancel", "common.confirm": "Confirm", "common.save": "Save", diff --git a/src/locales/zh.js b/src/locales/zh.js index 432a763..e651b55 100644 --- a/src/locales/zh.js +++ b/src/locales/zh.js @@ -1,4 +1,29 @@ export const zh = { + "k.label": "保活", + "k.title": "检测验证码中断,重启 ZCode 并恢复原会话。关闭后停止自动恢复。", + "k.enableTitle": "开启 ZCode 保活?", + "k.enable": "开启保活", + "k.enableDesc": "仅 Windows。本工具运行期间,检测到新的验证码故障会立即重启当前 ZCode,并尝试继续受影响会话;失败后逐步延长间隔持续重试。重启会打断同一实例中的运行任务。ZCode 将以仅限本机的调试接口启动,本机其他程序也可能访问该接口。不会更换账号或修改 ZCode 安装包。关闭保活会停止续跑;已开始的重启会完成,调试接口在 ZCode 正常重启后关闭。", + "k.phase.off": "保活已关闭", + "k.phase.watching": "保活中 · 正在观察验证码中断", + "k.phase.restarting": "正在重启 ZCode", + "k.phase.connecting": "等待 ZCode 就绪", + "k.phase.resuming": "正在恢复原会话", + "k.phase.observing": "等待会话产生执行进展", + "k.phase.retrying": "等待下一次恢复", + "k.phase.waiting_app": "等待 ZCode 启动", + "k.phase.error": "保活需要处理", + "k.pending": "待恢复 {n} 个", + "k.recovered": "已恢复 {n} 个", + "k.retry": "{n} 秒后重试", + "k.reason.setup": "请检查 ZCode 安装路径和登录状态,然后重新开启。", + "k.reason.unavailable": "暂时无法核实 ZCode 状态,未执行后续操作。", + "k.reason.identity_changed": "账号、路径或实例已改变,请重新开启保活。", + "k.reason.too_many": "恢复目标超过 64 个,请先减少运行会话。", + "k.reason.control_unavailable": "本机控制连接未就绪,请检查 ZCode 是否正常启动。", + "k.reason.unsupported_version": "当前 ZCode 不支持所需会话接口,自动恢复已暂停。", + "k.reason.resume_unavailable": "会话尚不能续跑,稍后重试。", + "k.reason.restart_incomplete": "保活已关闭,正在重试完成已开始的重启;也可手动启动 ZCode。", "common.cancel": "取消", "common.confirm": "确认", "common.save": "保存", diff --git a/src/main.js b/src/main.js index d1c2ffb..a40b7df 100644 --- a/src/main.js +++ b/src/main.js @@ -138,6 +138,18 @@ function autoPillTitle(s) { } const actions = { + async toggleKeepalive() { + const enabled = !!state?.keepalive?.enabled; + const apply = async () => { + try { await invoke("set_keepalive", { enabled: !enabled }); await refresh(); render(); } + catch (e) { toast(stripErr(e), "err"); } + }; + if (enabled) return apply(); + openConfirmModal({ + title: t("k.enableTitle"), desc: esc(t("k.enableDesc")), + yesLabel: t("k.enable"), onYes: apply, + }); + }, async refresh() { await refresh(); render(); }, async capture() { @@ -848,6 +860,9 @@ function render() { ${t("btn.autoClaim")} + ${s.keepalive?.supported ? `` : ""} ${s.zcode_running ? `` : ``} @@ -855,6 +870,13 @@ function render() { + ${s.keepalive?.supported && (s.keepalive.enabled || s.keepalive.reason) ? `
+ ${esc(t(`k.phase.${s.keepalive.phase}`))} + ${s.keepalive.pending ? `${esc(t("k.pending", { n: s.keepalive.pending }))}` : ""} + ${s.keepalive.recovered ? `${esc(t("k.recovered", { n: s.keepalive.recovered }))}` : ""} + ${s.keepalive.next_retry_at ? `${esc(t("k.retry", { n: Math.max(0, Math.ceil((s.keepalive.next_retry_at - Date.now()) / 1000)) }))}` : ""} + ${s.keepalive.reason ? `${esc(t(`k.reason.${s.keepalive.reason}`))}` : ""} +
` : ""}

${t("m.accounts")}

${t("m.count", { count: s.accounts.length })} diff --git a/src/styles.css b/src/styles.css index eced75f..f10ed50 100644 --- a/src/styles.css +++ b/src/styles.css @@ -184,6 +184,17 @@ input:focus-visible, } .led-text b { color: var(--ink); font-weight: 600; } +.keepalive-status { + display: flex; + flex-wrap: wrap; + gap: 6px 14px; + margin: 10px 18px 0; + padding: 9px 12px; + border: 1px solid var(--hairline); + border-radius: 8px; + color: var(--ink-dim); + font-size: 12px; +} .section-head { display: flex; align-items: baseline;