From 67fda9dfe4b5e31c866e399426115961afcede96 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:06:30 +0800 Subject: [PATCH 1/4] Seamless upgrades: hand the running core over instead of stopping it The desktop side of the core handover (control API 52). After an update is in place, the app asks its core to wait for a successor (POST /handover/arm) and restarts at once; the new version's core takes over the listeners and the old one finishes its requests. When the core cannot be armed (older core, login in progress, safe mode), the update falls back to the previous flow: wait for requests, stop, restart. - Updates: arm after replacing on macOS, Linux AppImage and Windows (installer and portable); the exit hook leaves an armed core running. The Windows installer path renames the running twcore.exe to twcore.old-.exe before the installer runs and sweeps leftovers on start; a failed install renames it back and adopts the armed core. - Linux AppImage runs core from a per-version copy in the data directory (current and previous kept), so it outlives the mount. - Restart gateway hands over to a same-version core; the supervisor lets go of the armed child, and readiness now checks that the answering core is the one it started (a takeover in progress counts as starting). - Homebrew: a quit Apple Event sent by osascript whose parent is this Homebrew's Ruby running brew.rb upgrade/reinstall arms instead of stopping core; logout/shutdown and every other sender keep today's behaviour. A replaced Info.plist (brew --no-quit etc.) triggers an armed restart. - .last-exit records whether the exit was armed; an armed exit restores the window state within five minutes even when the version is unchanged. - In-progress counts on the overview and in the menu bar include the draining core's requests; the safe-mode page says the gateway keeps the previous configuration while a takeover waits for a fixed config. Co-Authored-By: Claude Opus 5.5 --- scripts/shots/core/en/status.json | 2 + scripts/shots/core/zh/status.json | 2 + src-tauri/src/call.rs | 2 + src-tauri/src/connection/connector.rs | 2 +- src-tauri/src/gateway.rs | 29 +- src-tauri/src/handover/brew.rs | 464 +++++++++++++++++++ src-tauri/src/handover/bundle_watch.rs | 141 ++++++ src-tauri/src/handover/core_cache.rs | 200 ++++++++ src-tauri/src/handover/mod.rs | 205 ++++++++ src-tauri/src/handover/retire.rs | 147 ++++++ src-tauri/src/lib.rs | 46 +- src-tauri/src/menubar/mod.rs | 13 +- src-tauri/src/menubar/model.rs | 26 +- src-tauri/src/menubar/model/tests.rs | 33 ++ src-tauri/src/notices/fixtures/snapshot.json | 2 + src-tauri/src/supervisor/mod.rs | 168 ++++++- src-tauri/src/supervisor/tests.rs | 106 +++++ src-tauri/src/update.rs | 29 +- src-tauri/src/updater.rs | 313 ++++++++++--- src/connection/Unlinked.tsx | 7 + src/connection/connection.i18n.tsx | 4 + src/generated/tw-api.ts | 46 +- src/overview/useLive.ts | 39 +- src/shell/Pages.tsx | 1 + 24 files changed, 1922 insertions(+), 105 deletions(-) create mode 100644 src-tauri/src/handover/brew.rs create mode 100644 src-tauri/src/handover/bundle_watch.rs create mode 100644 src-tauri/src/handover/core_cache.rs create mode 100644 src-tauri/src/handover/mod.rs create mode 100644 src-tauri/src/handover/retire.rs diff --git a/scripts/shots/core/en/status.json b/scripts/shots/core/en/status.json index 7b35176d..1919bdd1 100644 --- a/scripts/shots/core/en/status.json +++ b/scripts/shots/core/en/status.json @@ -2,8 +2,10 @@ "api_version": 51, "clients": 4, "config_path": "", + "draining": 0, "gateway_addr": null, "gateway_reachable": [], + "handover": "idle", "in_flight": 0, "pid": 0, "providers": 7, diff --git a/scripts/shots/core/zh/status.json b/scripts/shots/core/zh/status.json index 7b35176d..1919bdd1 100644 --- a/scripts/shots/core/zh/status.json +++ b/scripts/shots/core/zh/status.json @@ -2,8 +2,10 @@ "api_version": 51, "clients": 4, "config_path": "", + "draining": 0, "gateway_addr": null, "gateway_reachable": [], + "handover": "idle", "in_flight": 0, "pid": 0, "providers": 7, diff --git a/src-tauri/src/call.rs b/src-tauri/src/call.rs index eadfd3c4..c319dcaa 100644 --- a/src-tauri/src/call.rs +++ b/src-tauri/src/call.rs @@ -224,6 +224,8 @@ mod tests { "Diagnostics", "Fixture", "Status", + // 请 core 等接替者:只在更新、重启网关时由 Rust 这边发(`handover`) + "HandoverArm", "StartChatgptLogin", "CancelChatgptLogin", "StartZaiLogin", diff --git a/src-tauri/src/connection/connector.rs b/src-tauri/src/connection/connector.rs index 3ef304a9..36515fa5 100644 --- a/src-tauri/src/connection/connector.rs +++ b/src-tauri/src/connection/connector.rs @@ -262,7 +262,7 @@ mod tests { serde_json::json!({ "api_version": tw_api::CONTROL_API_VERSION, "version": "x", "pid": 1, "gateway_addr": gateway, "config_path": "/x", "clients": 0, "providers": 0, - "uptime_secs": 0, "in_flight": 0, + "uptime_secs": 0, "in_flight": 0, "handover": "idle", "draining": 0, "remote_control": { "enabled": true, "addr": null, "allow_from": [], "reachable": [] }, "gateway_reachable": reachable, }) diff --git a/src-tauri/src/gateway.rs b/src-tauri/src/gateway.rs index d65990c3..51e64df7 100644 --- a/src-tauri/src/gateway.rs +++ b/src-tauri/src/gateway.rs @@ -100,6 +100,18 @@ pub(crate) fn bundled_core(product: &str) -> Option { /// 让环境变量替换掉网关本体,在开发机上是便利,在用户机器上是一个口子。 pub fn locate_core(app: &tauri::AppHandle) -> anyhow::Result { if let Some(inside) = bundled_core(&app.package_info().name) { + // **Linux AppImage 从数据目录里的按版本缓存运行**:挂载点随这个进程退出而卸载, + // 交接时旧 core 还要接着跑(见 `handover::core_cache`) + #[cfg(target_os = "linux")] + if inside.exists() + && tauri::utils::platform::bundle_type() + == Some(tauri::utils::config::BundleType::AppImage) + { + return Ok(crate::handover::core_cache::from_cache( + inside, + &app.package_info().version.to_string(), + )); + } if inside.exists() { return Ok(inside); } @@ -238,12 +250,27 @@ pub(crate) async fn restart_gateway(app: &tauri::AppHandle) -> Out<()> { return Err(why.clone().into()); } if state.supervising.swap(true, Ordering::SeqCst) { - return state.supervisor.request_restart().await.map_err(text); + return restart_running(&state).await; } spawn_supervise(app, state.supervisor.clone(), state.supervising.clone()); Ok(()) } +/// 守护在跑时的「重新启动」:**同版本交接**(方案 R5)。请正在跑的 core 等接替者,守护起一个 +/// 新的接过监听,旧的做完手上的请求再走 —— 和换版本时一样,网关不断、在跑的流不被截断。 +/// +/// arm 不上(安全模式里的、还在启动的、登录进行中、core 太旧)就照以前停了再起。安全模式之后 +/// 配置修好了的那次自动重启也走这里:安全模式的 core 不交接,照旧停了再起 +async fn restart_running(state: &AppState) -> Out<()> { + if matches!(state.supervisor.state(), CoreState::Running { .. }) { + match crate::handover::arm(state.supervisor.control(), None).await { + Ok(_) => return state.supervisor.hand_over().await.map_err(text), + Err(e) => tracing::info!("重新启动网关:core 没能等接替者,停了再起({e})"), + } + } + state.supervisor.request_restart().await.map_err(text) +} + /// 守护没在跑就接回来;在跑就什么都不做。切回本机时用:远程模式下它停了 pub(crate) fn ensure_supervising(app: &tauri::AppHandle) { use std::sync::atomic::Ordering; diff --git a/src-tauri/src/handover/brew.rs b/src-tauri/src/handover/brew.rs new file mode 100644 index 00000000..72b6d933 --- /dev/null +++ b/src-tauri/src/handover/brew.rs @@ -0,0 +1,464 @@ +//! `brew upgrade` 退出应用时认出它来(方案 8.3.2)。 +//! +//! Homebrew 升级一个 cask 的顺序是:下好新版 → 执行旧版 cask 的 `uninstall quit:`(由 +//! `osascript -l JavaScript` 向应用发一个退出 Apple Event,10 秒内没退出就告警继续)→ 换上 +//! 新的 `.app` → `open -b` 重新打开。换上之后的重开和自动更新的「重启为新版本」是一回事, +//! 所以这次退出只要像自动更新一样 arm、不停 core,网关就不断。 +//! +//! **难在分清这次退出是谁发起的。**同一个退出事件,用户在活动监视器里点「退出」、`brew +//! uninstall`、用户自己的脚本也会发;那些时候用户要的是网关跟着停。所以只认三项**同时**成立的: +//! +//! 1. 事件的发送进程(`keySenderPIDAttr`)是 `/usr/bin/osascript`; +//! 2. 它的父进程的可执行文件在**装这个应用的那份 Homebrew** 的前缀之下(Homebrew 的 +//! `system_command` 经 `/usr/bin/env` 起 osascript,env 是 exec 过去的,父进程就是跑 +//! `brew.rb` 的那个 Ruby;Ruby 是 Homebrew 自带的,在前缀里); +//! 3. 父进程参数里 `brew.rb` 之后的子命令是 `upgrade` 或 `reinstall`(`brew.sh` 最后 +//! `exec ruby … brew.rb <命令> <参数>`,命令紧跟在 `brew.rb` 后面)。 +//! +//! 系统注销、关机、重启发的退出事件带 `kAEQuitReason`,一律不算。**任何一项取不到就当普通 +//! 退出**:判定只会漏判(漏了就是今天的行为,停掉 core),不会把普通退出当成升级。 + +use std::path::{Path, PathBuf}; + +/// 退出事件的发送方,判定要用到的几样。取不到的是 `None` +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Sender { + /// 发送进程的可执行文件 + pub exe: Option, + /// 它的父进程的可执行文件 + pub parent_exe: Option, + /// 父进程的参数(`argv`,含 `argv[0]`) + pub parent_args: Option>, +} + +/// Homebrew 发退出事件用的程序 +const OSASCRIPT: &str = "/usr/bin/osascript"; + +/// 算升级的子命令。`reinstall` 同样是先退出、换上、再打开 +const UPGRADES: [&str; 2] = ["upgrade", "reinstall"]; + +/// 这次退出是不是 `brew upgrade`(或 `brew reinstall`)发起的。 +/// +/// `system_quit`:事件带着 `kAEQuitReason`(注销、关机、重启)。`prefix`:装这个应用的那份 +/// Homebrew 的前缀,不是 Homebrew 装的就是 `None` —— 那就不可能是它的升级 +pub fn is_upgrade(sender: &Sender, prefix: Option<&Path>, system_quit: bool) -> bool { + if system_quit { + return false; + } + let Some(prefix) = prefix else { + return false; + }; + if sender.exe.as_deref() != Some(Path::new(OSASCRIPT)) { + return false; + } + if !sender + .parent_exe + .as_deref() + .is_some_and(|p| p.starts_with(prefix)) + { + return false; + } + sender + .parent_args + .as_deref() + .and_then(brew_command) + .is_some_and(|c| UPGRADES.contains(&c)) +} + +/// 参数里 `brew.rb` 后面紧跟的那个子命令。没有 `brew.rb`(不是 Homebrew 的 Ruby)就是 `None` +fn brew_command(args: &[String]) -> Option<&str> { + let at = args + .iter() + .position(|a| Path::new(a).file_name().is_some_and(|n| n == "brew.rb"))?; + args.get(at + 1).map(String::as_str) +} + +/// 解开 `sysctl(KERN_PROCARGS2)` 给的那一块:开头是 `argc`(本机字节序的 32 位整数),然后是 +/// 可执行文件的路径和若干个补齐用的 NUL,再往后是 `argc` 个以 NUL 结尾的参数(之后是环境 +/// 变量,不要)。 +/// +/// 形状不对(太短、参数个数对不上)就是 `None`:取不到,按普通退出算 +pub fn parse_procargs2(buf: &[u8]) -> Option> { + let argc = i32::from_ne_bytes(buf.get(..4)?.try_into().ok()?); + let argc = usize::try_from(argc).ok()?; + let rest = &buf[4..]; + // 跳过可执行文件路径 + let mut at = rest.iter().position(|b| *b == 0)?; + // 跳过补齐的 NUL + while rest.get(at) == Some(&0) { + at += 1; + } + let mut args = Vec::with_capacity(argc); + for _ in 0..argc { + let tail = rest.get(at..)?; + let end = tail.iter().position(|b| *b == 0)?; + args.push(String::from_utf8_lossy(&tail[..end]).into_owned()); + at += end + 1; + } + Some(args) +} + +/// 刚收到的退出事件判成了升级。**只在主线程上写和读**:退出事件的处理函数和退出回调都在 +/// 主线程的事件循环上 +static UPGRADE_QUIT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// 退出回调问:这次退出是不是 `brew upgrade` 发起的(见 [`super::arm_on_quit`]) +pub(crate) fn quit_was_upgrade() -> bool { + UPGRADE_QUIT.load(std::sync::atomic::Ordering::SeqCst) +} + +#[cfg(target_os = "macos")] +pub use macos::install_quit_handler; + +#[cfg(target_os = "macos")] +mod macos { + use std::path::PathBuf; + + use objc2::rc::Retained; + use objc2::runtime::{AnyObject, NSObject, NSObjectProtocol}; + use objc2::{MainThreadMarker, MainThreadOnly, define_class, msg_send, sel}; + use objc2_app_kit::NSApplication; + use objc2_foundation::{NSAppleEventDescriptor, NSAppleEventManager}; + + use super::{Sender, UPGRADE_QUIT, is_upgrade}; + + /// 四个字符的 Apple Event 代码 + const fn four(c: &[u8; 4]) -> u32 { + u32::from_be_bytes(*c) + } + const K_CORE_EVENT_CLASS: u32 = four(b"aevt"); + const K_AE_QUIT_APPLICATION: u32 = four(b"quit"); + /// 发送进程的 pid(`keySenderPIDAttr`) + const KEY_SENDER_PID: u32 = four(b"spid"); + /// 注销、关机、重启时退出事件带着的理由(`kAEQuitReason`) + const K_AE_QUIT_REASON: u32 = four(b"why?"); + + define_class!( + /// 退出事件的处理者:先判定来源、记下,再走原来的退出(`terminate:`) + #[unsafe(super(NSObject))] + #[thread_kind = MainThreadOnly] + #[name = "ThinkWatchQuitHandler"] + struct QuitHandler; + + impl QuitHandler { + #[unsafe(method(handleQuit:withReplyEvent:))] + fn handle_quit(&self, event: &NSAppleEventDescriptor, _reply: &NSAppleEventDescriptor) { + let upgrade = from_brew_upgrade(event); + if upgrade { + tracing::info!("退出事件来自 brew upgrade"); + } + UPGRADE_QUIT.store(upgrade, std::sync::atomic::Ordering::SeqCst); + // **交给原来的退出流程**:系统默认的处理函数做的也就是这一下 + let mtm = MainThreadMarker::from(self); + NSApplication::sharedApplication(mtm).terminate(None); + } + } + + unsafe impl NSObjectProtocol for QuitHandler {} + ); + + thread_local! { + /// Apple Event 管理器不持有处理者,这里拿着它活到进程结束 + static HANDLER: std::cell::OnceCell> = const { std::cell::OnceCell::new() }; + } + + /// 换上自己的退出事件处理函数。**在应用起来之后调**(setup 里):系统在 + /// `finishLaunching` 时装默认的那一个,早于它装的会被盖掉 + pub fn install_quit_handler(mtm: MainThreadMarker) { + let handler: Retained = unsafe { msg_send![QuitHandler::alloc(mtm), init] }; + let manager = NSAppleEventManager::sharedAppleEventManager(); + let target: &AnyObject = &handler; + // SAFETY: 选择子和上面声明的方法同名同签名;类和事件号是 32 位的四字符代码 + unsafe { + let _: () = msg_send![ + &*manager, + setEventHandler: target, + andSelector: sel!(handleQuit:withReplyEvent:), + forEventClass: K_CORE_EVENT_CLASS, + andEventID: K_AE_QUIT_APPLICATION + ]; + } + HANDLER.with(|h| { + let _ = h.set(handler); + }); + } + + /// 这个退出事件是不是 `brew upgrade` 发的 + fn from_brew_upgrade(event: &NSAppleEventDescriptor) -> bool { + let system_quit = keyword(event, K_AE_QUIT_REASON, true).is_some() + || keyword(event, K_AE_QUIT_REASON, false).is_some(); + if system_quit { + return false; + } + let Some(prefix) = crate::update::homebrew_prefix() else { + return false; + }; + let pid = keyword(event, KEY_SENDER_PID, true).map(|d| d.int32Value()); + let sender = match pid { + Some(pid) if pid > 0 => sender_of(pid), + _ => Sender::default(), + }; + is_upgrade(&sender, Some(&prefix), false) + } + + /// 事件上的一项:`attribute` 为真取属性,否则取参数 + fn keyword( + event: &NSAppleEventDescriptor, + key: u32, + attribute: bool, + ) -> Option> { + // SAFETY: 两个方法都只读这个描述符,关键字是四字符代码;没有这一项时返回空 + unsafe { + if attribute { + msg_send![event, attributeDescriptorForKeyword: key] + } else { + msg_send![event, paramDescriptorForKeyword: key] + } + } + } + + /// 发送进程、它的父进程、父进程的参数。都是同一个用户读得到的信息 + fn sender_of(pid: i32) -> Sender { + let parent = parent_of(pid); + Sender { + exe: path_of(pid), + parent_exe: parent.and_then(path_of), + parent_args: parent.and_then(args_of), + } + } + + /// 可执行文件的路径(`proc_pidpath`) + fn path_of(pid: i32) -> Option { + let mut buf = vec![0u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize]; + // SAFETY: 缓冲区的大小如实交出去,写进来的是以 NUL 结尾的路径,返回写了多少字节 + let n = unsafe { libc::proc_pidpath(pid, buf.as_mut_ptr().cast(), buf.len() as u32) }; + if n <= 0 { + return None; + } + buf.truncate(n as usize); + Some(PathBuf::from(String::from_utf8(buf).ok()?)) + } + + /// 父进程的 pid(`proc_pidinfo` 的 BSD 信息) + fn parent_of(pid: i32) -> Option { + let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() }; + let size = std::mem::size_of::() as i32; + // SAFETY: 结构体的大小如实交出去;成功时返回写进来的字节数 + let n = unsafe { + libc::proc_pidinfo( + pid, + libc::PROC_PIDTBSDINFO, + 0, + (&mut info as *mut libc::proc_bsdinfo).cast(), + size, + ) + }; + (n == size && info.pbi_ppid > 0).then_some(info.pbi_ppid as i32) + } + + /// 进程的参数(`sysctl(KERN_PROCARGS2)`) + fn args_of(pid: i32) -> Option> { + let mut mib = [libc::CTL_KERN, libc::KERN_PROCARGS2, pid]; + let mut size: libc::size_t = 0; + // SAFETY: 先问要多大(缓冲区为空),再按问到的大小取 + unsafe { + if libc::sysctl( + mib.as_mut_ptr(), + 3, + std::ptr::null_mut(), + &mut size, + std::ptr::null_mut(), + 0, + ) != 0 + { + return None; + } + } + let mut buf = vec![0u8; size]; + unsafe { + if libc::sysctl( + mib.as_mut_ptr(), + 3, + buf.as_mut_ptr().cast(), + &mut size, + std::ptr::null_mut(), + 0, + ) != 0 + { + return None; + } + } + buf.truncate(size); + super::parse_procargs2(&buf) + } + + #[cfg(test)] + mod tests { + /// 本进程的参数读得出来,和标准库看到的一样(`KERN_PROCARGS2` 的解析在真机上对得上) + #[test] + fn our_own_arguments_read_back() { + let pid = std::process::id() as i32; + let args = super::args_of(pid).expect("读不到自己的参数"); + let std_args: Vec = std::env::args().collect(); + assert_eq!(args, std_args); + assert!(super::path_of(pid).is_some()); + assert!(super::parent_of(pid).is_some()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const PREFIX: &str = "/opt/homebrew"; + const RUBY: &str = "/opt/homebrew/Library/Homebrew/vendor/portable-ruby/3.4.7/bin/ruby"; + const BREW_RB: &str = "/opt/homebrew/Library/Homebrew/brew.rb"; + + fn ruby_args(command: &[&str]) -> Vec { + let mut a: Vec = [RUBY, "-W1", "--disable=gems,rubyopt", BREW_RB] + .iter() + .map(|s| s.to_string()) + .collect(); + a.extend(command.iter().map(|s| s.to_string())); + a + } + + fn from_brew(command: &[&str]) -> Sender { + Sender { + exe: Some(OSASCRIPT.into()), + parent_exe: Some(RUBY.into()), + parent_args: Some(ruby_args(command)), + } + } + + fn judge(s: &Sender) -> bool { + is_upgrade(s, Some(Path::new(PREFIX)), false) + } + + /// 判定表:三项都成立的 `upgrade`、`reinstall` 才算升级 + #[test] + fn only_an_upgrade_or_reinstall_from_this_homebrew_counts() { + assert!(judge(&from_brew(&["upgrade", "--cask", "thinkwatch-lite"]))); + // `brew upgrade` 不带参数,一次升级所有过期的 + assert!(judge(&from_brew(&["upgrade"]))); + assert!(judge(&from_brew(&["reinstall", "thinkwatch-lite"]))); + // 卸载、别的子命令:网关跟着停 + assert!(!judge(&from_brew(&["uninstall", "thinkwatch-lite"]))); + assert!(!judge(&from_brew(&["install", "thinkwatch-lite"]))); + assert!(!judge(&from_brew(&[]))); + } + + /// 不是 osascript 发的(活动监视器、用户自己的程序)、父进程不在这份 Homebrew 的前缀里 + /// (用户自己的脚本里写了 brew.rb upgrade):不算 + #[test] + fn other_senders_are_ordinary_quits() { + let mut s = from_brew(&["upgrade"]); + s.exe = Some( + "/System/Applications/Utilities/Activity Monitor.app/Contents/MacOS/Activity Monitor" + .into(), + ); + assert!(!judge(&s)); + + let mut s = from_brew(&["upgrade"]); + s.parent_exe = Some("/usr/bin/ruby".into()); + assert!(!judge(&s)); + + // 另一份 Homebrew(Intel 的前缀)发的,而这一份是 /opt/homebrew 装的 + let mut s = from_brew(&["upgrade"]); + s.parent_exe = Some( + "/usr/local/Homebrew/Library/Homebrew/vendor/portable-ruby/current/bin/ruby".into(), + ); + assert!(!judge(&s)); + + // 父进程是 shell(用户在终端里直接跑 osascript) + let s = Sender { + exe: Some(OSASCRIPT.into()), + parent_exe: Some("/bin/zsh".into()), + parent_args: Some(vec!["-zsh".into()]), + }; + assert!(!judge(&s)); + } + + /// **取不到任何一项都当普通退出**:只会漏判,不会误判 + #[test] + fn anything_missing_is_an_ordinary_quit() { + let full = from_brew(&["upgrade"]); + for s in [ + Sender { + exe: None, + ..full.clone() + }, + Sender { + parent_exe: None, + ..full.clone() + }, + Sender { + parent_args: None, + ..full.clone() + }, + Sender::default(), + ] { + assert!(!judge(&s), "{s:?}"); + } + // 不是 Homebrew 装的这一份 + assert!(!is_upgrade(&full, None, false)); + // 参数里没有 brew.rb(父进程不是 brew 的 Ruby) + let mut s = full.clone(); + s.parent_args = Some(vec![RUBY.into(), "-e".into(), "upgrade".into()]); + assert!(!judge(&s)); + } + + /// 注销、关机、重启(带 `kAEQuitReason`):哪怕别的都对上了也不算 + #[test] + fn a_system_quit_is_never_an_upgrade() { + assert!(!is_upgrade( + &from_brew(&["upgrade"]), + Some(Path::new(PREFIX)), + true + )); + } + + fn procargs(argc: i32, exe: &str, pad: usize, args: &[&str], env: &[&str]) -> Vec { + let mut b = argc.to_ne_bytes().to_vec(); + b.extend_from_slice(exe.as_bytes()); + b.extend(std::iter::repeat_n(0u8, pad)); + for a in args.iter().chain(env) { + b.extend_from_slice(a.as_bytes()); + b.push(0); + } + b + } + + #[test] + fn procargs2_yields_the_arguments_and_not_the_environment() { + let buf = procargs( + 3, + RUBY, + 5, + &[RUBY, BREW_RB, "upgrade"], + &["HOME=/Users/x", "PATH=/usr/bin"], + ); + assert_eq!( + parse_procargs2(&buf), + Some(vec![ + RUBY.to_string(), + BREW_RB.to_string(), + "upgrade".to_string() + ]) + ); + } + + /// 截断的、个数对不上的、负数的:取不到 + #[test] + fn a_malformed_procargs2_is_unreadable() { + assert_eq!(parse_procargs2(&[1, 0]), None); + let buf = procargs(4, RUBY, 1, &[RUBY, BREW_RB], &[]); + assert_eq!(parse_procargs2(&buf), None); + let buf = procargs(-1, RUBY, 1, &[RUBY], &[]); + assert_eq!(parse_procargs2(&buf), None); + let mut buf = procargs(1, RUBY, 1, &["abc"], &[]); + buf.pop(); // 最后一个参数没有结尾的 NUL + assert_eq!(parse_procargs2(&buf), None); + } +} diff --git a/src-tauri/src/handover/bundle_watch.rs b/src-tauri/src/handover/bundle_watch.rs new file mode 100644 index 00000000..482ef85d --- /dev/null +++ b/src-tauri/src/handover/bundle_watch.rs @@ -0,0 +1,141 @@ +//! Homebrew 没退出应用就把它换掉了:自己 arm 并重启(方案 8.3.3、F10)。 +//! +//! `brew upgrade` 通常先让应用退出(见 [`super::brew`]),但有几种情况它直接换:`--no-quit`、 +//! 在 SSH 里执行(没登录图形界面)、自动化权限被拒、10 秒内没退出。这时跑着的还是旧版,磁盘上 +//! 已经是新版 —— 守护哪天重起 core,起来的就是一个和界面不同版本的网关;Homebrew 也不会再把 +//! 它重新打开。 +//! +//! 所以盯着自己应用包里的 `Info.plist`:磁盘上的版本和正在跑的不一样了,就照自动更新的样子 +//! 走一遍(arm、记下退出时的样子、重启),重启之后照退出时的窗口状态恢复。 +//! +//! **只看 Homebrew 装的那一份**:自己更新的那一份是更新流程自己在换包,换完自己重启;别的 +//! 方式换掉的不归这里管。 + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +/// 多久看一次。**轮询一个小文件**:读的是修改时间,变了才读内容 +const EVERY: Duration = Duration::from_secs(3); + +/// 看到版本变了之后再等多久、确认它不再变才动手:Homebrew 换好 `.app` 之后还要清掉隔离 +/// 属性 —— 带着隔离属性重启,未签名的应用会被系统拦下来 +const SETTLE: Duration = Duration::from_secs(5); + +/// 起一个后台任务盯着。不是 Homebrew 装的、找不到自己的应用包就不盯 +pub(crate) fn spawn(app: &tauri::AppHandle) { + if crate::update::kind() != crate::update::Install::Homebrew { + return; + } + let Some(bundle) = crate::update::own_bundle() else { + return; + }; + let plist = plist_of(&bundle); + let running = app.package_info().version.to_string(); + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let Some(found) = until_replaced(&plist, &running).await else { + return; + }; + tracing::info!(running, found, "应用包已被换成另一版,交接并重启"); + crate::updater::restart_into_replaced(&app).await; + }); +} + +/// 等到磁盘上的版本和 `running` 不同、并且稳定下来,返回那个版本 +async fn until_replaced(plist: &Path, running: &str) -> Option { + let mut seen: Option = None; + loop { + tokio::time::sleep(EVERY).await; + let modified = std::fs::metadata(plist).and_then(|m| m.modified()).ok(); + if modified.is_none() || modified == seen { + continue; + } + seen = modified; + let Some(disk) = read_version(plist) else { + continue; + }; + if disk == running { + continue; + } + tokio::time::sleep(SETTLE).await; + if read_version(plist).as_deref() == Some(disk.as_str()) { + return Some(disk); + } + // 还在变:下一轮重新看 + seen = None; + } +} + +fn read_version(plist: &Path) -> Option { + plist_version(&std::fs::read_to_string(plist).ok()?) +} + +/// 从 `Info.plist`(XML)里读出 `CFBundleShortVersionString`。打包器写的就是 XML;读不懂的 +/// (二进制 plist、没有这一项)是 `None`,当没变 +pub fn plist_version(text: &str) -> Option { + let key = "CFBundleShortVersionString"; + let rest = &text[text.find(key)? + key.len()..]; + let rest = rest.trim_start().strip_prefix("")?; + let v = rest[..rest.find("")?].trim(); + (!v.is_empty()).then(|| v.to_string()) +} + +/// 应用包里的 `Info.plist` +fn plist_of(bundle: &Path) -> PathBuf { + bundle.join("Contents").join("Info.plist") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn plist(version: &str) -> String { + format!( + r#" + + + + CFBundleExecutable + thinkwatch-lite + CFBundleShortVersionString + {version} + CFBundleVersion + {version} + +"# + ) + } + + #[test] + fn the_version_is_read_from_the_plist() { + assert_eq!( + plist_version(&plist("2026.10.29")).as_deref(), + Some("2026.10.29") + ); + assert_eq!(plist_version(""), None); + assert_eq!(plist_version(&plist("")), None); + assert_eq!(plist_version("bplist00\u{1}\u{2}"), None); + } + + /// 换成另一版、稳定下来才报;没变一直等 + #[tokio::test(start_paused = true)] + async fn a_replaced_bundle_is_noticed_once_it_settles() { + let d = tempfile::tempdir().unwrap(); + let bundle = d.path().join("ThinkWatch Lite.app"); + std::fs::create_dir_all(bundle.join("Contents")).unwrap(); + let p = plist_of(&bundle); + std::fs::write(&p, plist("2026.10.28")).unwrap(); + let watch = tokio::spawn({ + let p = p.clone(); + async move { until_replaced(&p, "2026.10.28").await } + }); + tokio::time::sleep(EVERY * 3).await; + assert!(!watch.is_finished(), "版本没变不该报"); + std::fs::write(&p, plist("2026.10.29")).unwrap(); + let found = tokio::time::timeout(EVERY * 10, watch) + .await + .unwrap() + .unwrap(); + assert_eq!(found.as_deref(), Some("2026.10.29")); + } +} diff --git a/src-tauri/src/handover/core_cache.rs b/src-tauri/src/handover/core_cache.rs new file mode 100644 index 00000000..7e5f1037 --- /dev/null +++ b/src-tauri/src/handover/core_cache.rs @@ -0,0 +1,200 @@ +//! Linux AppImage:core 从数据目录里的按版本缓存运行(方案 5.3、R7)。 +//! +//! AppImage 跑起来时,包里的文件在一个挂载点下,**挂载随主进程退出而卸载**。交接时旧 Lite +//! 先退出、旧 core 还要接着把手上的请求做完 —— 它要是从挂载点里跑的,可执行文件就在它脚下 +//! 消失了(已经映射进内存的部分还在,没读进来的页再要就是总线错误)。所以起 core 之前把包里 +//! 的 `twcore` 复制到 `<数据目录>/core-cache/<版本>/twcore`,从那里运行。 +//! +//! 只留当前和上一个版本:上一个正是交接时还在排空的那一个。哪个是「上一个」看目录的修改 +//! 时间 —— 每次用到一个版本都碰一下它的目录([`stage`]),最近碰过的就是最近在用的。 +//! +//! 复制不成(盘满、数据目录挂在 `noexec` 上)就照旧从挂载点运行:网关照常起来,只是这一份 +//! 交接时旧 core 没有退路,和今天一样。 +//! +//! 逻辑本身不分平台(测试在哪都跑);只有 Linux AppImage 真的用它 +#![cfg_attr(not(target_os = "linux"), allow(dead_code))] + +use std::path::{Path, PathBuf}; + +/// 数据目录下缓存放在哪 +pub const CACHE_DIR: &str = "core-cache"; + +/// 把 `src`(挂载点里的 `twcore`)放进缓存里 `version` 那一格,返回该从哪里运行。 +/// +/// 那一格里已经有、而且和 `src` 一样大的,不再复制(版本号相同的两次构建不会是两个 +/// 文件 —— 发出去的每一版号都不同)。**写到临时文件再改名**:中途断电不会留下半个 +/// 可执行文件被当成完整的 +pub fn stage(src: &Path, data: &Path, version: &str) -> std::io::Result { + let file = src + .file_name() + .ok_or_else(|| std::io::Error::other("the core has no file name"))?; + let cache = data.join(CACHE_DIR); + let slot = cache.join(version); + let target = slot.join(file); + std::fs::create_dir_all(&slot)?; + if !executable_here(&slot) { + return Err(std::io::Error::other( + "the data directory does not allow running programs (noexec)", + )); + } + let want = std::fs::metadata(src)?.len(); + let have = std::fs::metadata(&target).map(|m| m.len()).ok(); + if have != Some(want) { + let tmp = slot.join(format!(".{}.partial", file.to_string_lossy())); + // `copy` 连权限一起带过去:包里的 twcore 本来就是可执行的 + std::fs::copy(src, &tmp)?; + std::fs::File::open(&tmp)?.sync_all()?; + std::fs::rename(&tmp, &target)?; + } + touch(&slot); + prune(&cache, version); + Ok(target) +} + +/// 碰一下目录:把它的修改时间拨到现在。**用一个记号文件**:改目录里的东西就是改目录 +fn touch(slot: &Path) { + let mark = slot.join(".used"); + let _ = std::fs::remove_file(&mark); + let _ = std::fs::write(&mark, b""); +} + +/// 只留 `current` 和除它之外最近用过的那一格。删不掉的跳过 +fn prune(cache: &Path, current: &str) { + let Ok(entries) = std::fs::read_dir(cache) else { + return; + }; + let mut others: Vec<(std::time::SystemTime, PathBuf)> = entries + .flatten() + .filter(|e| e.file_type().is_ok_and(|t| t.is_dir())) + .filter(|e| e.file_name() != current) + .map(|e| { + let at = e + .metadata() + .and_then(|m| m.modified()) + .unwrap_or(std::time::UNIX_EPOCH); + (at, e.path()) + }) + .collect(); + // 最近用过的在前,留下第一个 + others.sort_by_key(|a| std::cmp::Reverse(a.0)); + for (_, dir) in others.into_iter().skip(1) { + if let Err(e) = std::fs::remove_dir_all(&dir) { + tracing::info!("旧的 core 缓存 {} 没删掉:{e}", dir.display()); + } + } +} + +/// 这个目录所在的文件系统允许运行程序吗(不是 `noexec` 挂载的)。问不出来就当允许 +#[cfg(target_os = "linux")] +fn executable_here(dir: &Path) -> bool { + use std::os::unix::ffi::OsStrExt; + let Ok(c) = std::ffi::CString::new(dir.as_os_str().as_bytes()) else { + return true; + }; + let mut st: libc::statvfs = unsafe { std::mem::zeroed() }; + // SAFETY: 路径以 NUL 结尾,结构体由调用方提供,只读 + if unsafe { libc::statvfs(c.as_ptr(), &mut st) } != 0 { + return true; + } + (st.f_flag & libc::ST_NOEXEC) == 0 +} + +#[cfg(not(target_os = "linux"))] +fn executable_here(_dir: &Path) -> bool { + true +} + +/// 包里的 core 换成缓存里的那一份;放不进去就还用包里的 +#[cfg(target_os = "linux")] +pub fn from_cache(inside: PathBuf, version: &str) -> PathBuf { + match stage(&inside, &crate::data_dir(), version) { + Ok(p) => p, + Err(e) => { + tracing::warn!("core 放不进数据目录里的缓存,从挂载点运行:{e}"); + inside + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn core_in(dir: &Path, bytes: &[u8]) -> PathBuf { + let p = dir.join("twcore"); + std::fs::write(&p, bytes).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + p + } + + fn slots(data: &Path) -> Vec { + let mut v: Vec = std::fs::read_dir(data.join(CACHE_DIR)) + .unwrap() + .flatten() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + v.sort(); + v + } + + #[test] + fn the_core_runs_from_a_copy_in_the_data_directory() { + let mount = tempfile::tempdir().unwrap(); + let data = tempfile::tempdir().unwrap(); + let src = core_in(mount.path(), b"core 1"); + let at = stage(&src, data.path(), "2026.10.29").unwrap(); + assert_eq!( + at, + data.path() + .join(CACHE_DIR) + .join("2026.10.29") + .join("twcore") + ); + assert_eq!(std::fs::read(&at).unwrap(), b"core 1"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&at).unwrap().permissions().mode(); + assert_eq!(mode & 0o111, 0o111, "复制出来的要能运行"); + } + // 挂载点卸载之后,缓存里那一份还在 + drop(mount); + assert!(at.exists()); + } + + /// 已经放好的不再复制;大小对不上的(上次复制到一半)重来 + #[test] + fn an_existing_copy_is_kept_and_a_broken_one_replaced() { + let mount = tempfile::tempdir().unwrap(); + let data = tempfile::tempdir().unwrap(); + let src = core_in(mount.path(), b"core 1"); + let at = stage(&src, data.path(), "v1").unwrap(); + let before = std::fs::metadata(&at).unwrap().modified().unwrap(); + assert_eq!(stage(&src, data.path(), "v1").unwrap(), at); + assert_eq!(std::fs::metadata(&at).unwrap().modified().unwrap(), before); + std::fs::write(&at, b"core").unwrap(); + stage(&src, data.path(), "v1").unwrap(); + assert_eq!(std::fs::read(&at).unwrap(), b"core 1"); + } + + /// 只留当前和上一个(最近用过的) + #[test] + fn only_the_current_and_the_previous_version_are_kept() { + let mount = tempfile::tempdir().unwrap(); + let data = tempfile::tempdir().unwrap(); + let src = core_in(mount.path(), b"core"); + for v in ["v1", "v2", "v3"] { + stage(&src, data.path(), v).unwrap(); + // 修改时间的精度:让三格分得出先后 + std::thread::sleep(std::time::Duration::from_millis(20)); + } + assert_eq!(slots(data.path()), ["v2", "v3"]); + // 回到 v2(比如换回旧版):v3 成了上一个 + stage(&src, data.path(), "v2").unwrap(); + assert_eq!(slots(data.path()), ["v2", "v3"]); + } +} diff --git a/src-tauri/src/handover/mod.rs b/src-tauri/src/handover/mod.rs new file mode 100644 index 00000000..76eef758 --- /dev/null +++ b/src-tauri/src/handover/mod.rs @@ -0,0 +1,205 @@ +//! 升级不断线:桌面端这一侧的几件事。 +//! +//! 换版本时**不再停掉网关再起**:旧 Lite 装好更新之后请本机的 core 等接替者(arm, +//! `POST /handover/arm`),然后立刻重启自己;新 Lite 照常起新 core,新 core 发现数据目录 +//! 被一个等接替者的旧 core 占着,就从它手里接过监听和状态,旧 core 做完手上的请求再走。 +//! 网关端口从头到尾都开着,在跑的流也不被截断。交接本身是 core 的事(见 tw-handover), +//! 这里只管: +//! +//! - 什么时候 arm、arm 不上时退回「等请求结束、停掉、重启」([`arm`],调用方在 `updater`); +//! - 已 arm 的这一次退出不停 core([`armed`],退出回调看它); +//! - 等不到接替者时 core 怎么把桌面端重新打开([`relaunch_for`]); +//! - 运行中的 core 的可执行文件在替换应用之后仍然在(Windows 改名 [`retire`]、Linux +//! AppImage 的按版本缓存 [`core_cache`]); +//! - `brew upgrade` 退出应用时认出它来([`brew`]),以及 Homebrew 没退出应用就换掉了它时 +//! 自己重启([`bundle_watch`])。 +//! +//! **界面上不提交接。**这是正常运转的事,用户不需要知道(只有新 core 的配置读不进来、网关 +//! 暂按旧配置运行的那一种在安全模式页上说一句)。 + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use crate::control::ControlClient; + +pub mod brew; +#[cfg(target_os = "macos")] +pub mod bundle_watch; +pub mod core_cache; +pub mod retire; + +/// 这一次退出已经请 core 等接替者了。 +/// +/// **退出回调看它决定停不停 core**(见 `lib.rs` 的 `RunEvent::Exit`),退出记录里也记上 +/// 它(`updater::record_exit`):重新打开时据此照退出时的样子恢复窗口。 +static ARMED: AtomicBool = AtomicBool::new(false); + +/// 这一次退出已经 arm 了吗 +pub(crate) fn armed() -> bool { + ARMED.load(Ordering::SeqCst) +} + +/// arm 上了记一笔;替换没成、把 core 接回来之后撤掉 +pub(crate) fn set_armed(on: bool) { + ARMED.store(on, Ordering::SeqCst); +} + +/// 请 core 等接替者,最多等多久。**arm 本身很快**(core 只是脱离父进程、开一个端点), +/// 等不到多半是 core 卡住了:那就当 arm 不上,退回现行流程 +const ARM_WITHIN: Duration = Duration::from_secs(5); + +/// 请本机的 core 等接替者。成了返回交接连接的版本号。 +/// +/// **任何一种不成都只是退回现行流程**,调用方不区分原因:core 太旧(协议 52 之前没有这个 +/// 端点,答的是 404;连 `Status` 都解不出来)、登录进行中(`control.handover.busy`)、安全 +/// 模式(`control.handover.safe_mode`)、core 没在跑、[`ARM_WITHIN`] 内没回话。原因进日志 +/// +/// `relaunch` 是等不到接替者时 core 重新打开桌面端的方式(见 [`relaunch_for`]);同一个 +/// 桌面端接着起新 core 的(「重新启动网关」)不需要它 +pub(crate) async fn arm( + control: &ControlClient, + relaunch: Option, +) -> Result { + let req = tw_api::HandoverArm { + wait_secs: None, + relaunch, + }; + match tokio::time::timeout( + ARM_WITHIN, + control.call::(&[], &req), + ) + .await + { + Ok(Ok(armed)) => { + tracing::info!(version = armed.version, "core 已在等接替者"); + Ok(armed.version) + } + Ok(Err(e)) => Err(format!("{e:#}")), + Err(_) => Err(format!("core {ARM_WITHIN:?} 内没回话")), + } +} + +/// arm 的结果出来之后:成了就什么都不用等,返回 `true`;不成就照以前的做法(方案 F2), +/// 先等手上的请求结束(`wait`)、再停掉 core(`stop`),返回 `false`。 +/// +/// **两步都只在退回时才跑**:future 不被 await 就什么都不做 +pub(crate) async fn or_fall_back( + armed: &Result, + wait: impl std::future::Future, + stop: impl std::future::Future, +) -> bool { + match armed { + Ok(_) => true, + Err(e) => { + tracing::info!("core 没能等接替者,退回等请求结束再重启:{e}"); + wait.await; + stop.await; + false + } + } +} + +/// 等不到接替者时,core 怎么把桌面端重新打开(方案 F1:新 Lite 没起来)。 +/// +/// - macOS:按 bundle id(`open -b`)。打开的是磁盘上现在的那一份:换好了是新版,Homebrew +/// 升级失败回滚了就是旧版 —— 两种都能接手; +/// - Linux AppImage:`$APPIMAGE` 那个文件,**不是挂载点里的可执行文件**:挂载点随这个 +/// 进程退出而卸载,那个路径之后就不在了; +/// - Windows(安装版、绿色版)和别的:自己这个可执行文件。重启之后的路径不变(安装程序 +/// 覆盖原处,绿色版换的是同一个文件名)。 +/// +/// 答不出来就不给:core 不重新打开,只是等满之后排空退出 +pub(crate) fn relaunch_for(app: &tauri::AppHandle) -> Option { + #[cfg(target_os = "macos")] + { + Some(tw_api::Relaunch::BundleId { + id: app.config().identifier.clone(), + }) + } + #[cfg(not(target_os = "macos"))] + { + let _ = app; + #[cfg(target_os = "linux")] + let path = crate::update::mounted_appimage( + tauri::utils::platform::bundle_type(), + std::env::var_os("APPIMAGE").as_deref(), + ) + .or_else(|| std::env::current_exe().ok()); + #[cfg(not(target_os = "linux"))] + let path = std::env::current_exe().ok(); + path.map(|p| tw_api::Relaunch::Exe { + path: p.display().to_string(), + args: Vec::new(), + }) + } +} + +/// 退出回调里:这次退出是 `brew upgrade` 发起的,就 arm(不停 core)。见 [`brew`]。 +/// +/// 跑在主线程的事件循环上(`RunEvent::Exit`),不在运行时里 —— 和 `stop_core_on_exit` +/// 一样用 `block_on` 等这一下;万一在运行时里就不等(`block_on` 会 panic),当普通退出。 +/// arm 不上也当普通退出:调用方接着停 core,和今天一样 +pub(crate) fn arm_on_quit(app: &tauri::AppHandle) { + if armed() || !brew::quit_was_upgrade() { + return; + } + let Some(st) = tauri::Manager::try_state::(app) else { + return; + }; + if tokio::runtime::Handle::try_current().is_ok() { + return; + } + let control = st.supervisor.control().clone(); + let relaunch = relaunch_for(app); + let armed = tauri::async_runtime::block_on(async move { arm(&control, relaunch).await }); + match armed { + Ok(_) => { + tracing::info!("Homebrew 升级退出应用:core 留着等新版接手"); + set_armed(true); + } + Err(e) => tracing::info!("Homebrew 升级退出应用,core 没能 arm,照常停下({e})"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + use std::sync::atomic::AtomicUsize; + + /// 本机没有 core 在跑(连着远程、core 停着、没装好):arm 不上,**马上**说不上,不挂住 + #[tokio::test] + async fn arming_without_a_core_fails_at_once() { + let dir = tempfile::tempdir().unwrap(); + let control = ControlClient::new( + tw_api::control::Address::in_dir(dir.path()), + dir.path().join("config.yaml"), + ); + let got = tokio::time::timeout(Duration::from_secs(10), arm(&control, None)) + .await + .expect("arm 挂住了"); + assert!(got.is_err()); + } + + /// 退回的那一支:先等请求结束、再停 core;arm 上了的两步都不跑 + #[tokio::test] + async fn falling_back_waits_then_stops_and_arming_does_neither() { + let order = Arc::new(std::sync::Mutex::new(Vec::new())); + let step = |name: &'static str| { + let order = order.clone(); + async move { order.lock().unwrap().push(name) } + }; + assert!(!or_fall_back(&Err("refused".into()), step("wait"), step("stop")).await); + assert_eq!(*order.lock().unwrap(), ["wait", "stop"]); + + let ran = Arc::new(AtomicUsize::new(0)); + let count = || { + let ran = ran.clone(); + async move { + ran.fetch_add(1, Ordering::SeqCst); + } + }; + assert!(or_fall_back(&Ok(1), count(), count()).await); + assert_eq!(ran.load(Ordering::SeqCst), 0); + } +} diff --git a/src-tauri/src/handover/retire.rs b/src-tauri/src/handover/retire.rs new file mode 100644 index 00000000..c8be3277 --- /dev/null +++ b/src-tauri/src/handover/retire.rs @@ -0,0 +1,147 @@ +//! Windows 安装版:把运行中的 `twcore.exe` 让出来给安装程序(方案 5.3)。 +//! +//! 安装程序要覆盖 `twcore.exe`,而交接时旧 core 还要接着跑完手上的请求 —— 以前的做法是先 +//! 停掉它。**NTFS 允许给运行中的可执行文件改名**:调安装程序之前把它改成 +//! `twcore.old-.exe`,它照常跑到排空退出,安装程序在腾出来的名字上写新版本。改出来 +//! 的文件下次启动时清掉([`sweep`]),删不掉(那个 core 还在排空)就留到下一次。 +//! +//! 绿色版不走这里:它换文件本来就是改名替换(`portable::swap_in`)。 +//! +//! 判断和改名本身不分平台(测试在哪都跑);只有 Windows 安装版真的用它 +#![cfg_attr(not(windows), allow(dead_code))] + +use std::path::{Path, PathBuf}; + +use crate::gateway::CORE_EXE; + +/// 改名之后叫什么:`twcore.old-.exe`。**带 pid**:上一个还在排空时又更新了一次,两个 +/// 旧的不会撞名 +pub fn retired_name(pid: u32) -> String { + format!("twcore.old-{pid}.exe") +} + +/// 这是不是改名改出来的那种文件。**只认这一种形状**:安装目录是我们的,但别的 `.exe` +/// 不是这里改出来的,不碰 +pub fn is_retired(name: &str) -> bool { + name.strip_prefix("twcore.old-") + .and_then(|r| r.strip_suffix(".exe")) + .is_some_and(|pid| !pid.is_empty() && pid.bytes().all(|b| b.is_ascii_digit())) +} + +/// 把 `dir` 里运行中的 `twcore.exe` 改名让出来,返回改成的路径 +pub fn retire(dir: &Path, pid: u32) -> std::io::Result { + let to = dir.join(retired_name(pid)); + std::fs::rename(dir.join(CORE_EXE), &to)?; + Ok(to) +} + +/// 安装没成:改回去。那个 core 还在跑,改名照样可以 +pub fn restore(dir: &Path, retired: &Path) -> std::io::Result<()> { + std::fs::rename(retired, dir.join(CORE_EXE)) +} + +/// 删掉 `dir` 里上次改出来的文件。删不掉的跳过(那个 core 还在排空),返回删掉了几个 +pub fn sweep(dir: &Path) -> usize { + let Ok(entries) = std::fs::read_dir(dir) else { + return 0; + }; + entries + .flatten() + .filter(|e| e.file_name().to_str().is_some_and(is_retired)) + .filter(|e| match std::fs::remove_file(e.path()) { + Ok(()) => true, + Err(err) => { + tracing::info!("上次更新改名留下的 {:?} 这次没删掉:{err}", e.file_name()); + false + } + }) + .count() +} + +/// 启动时在后台清一次。只在 Windows 安装版上做:别处不会有这种文件 +#[cfg(windows)] +pub fn sweep_in_background() { + if crate::update::kind() != crate::update::Install::Standalone { + return; + } + let Some(dir) = std::env::current_exe() + .ok() + .and_then(|e| e.parent().map(Path::to_path_buf)) + else { + return; + }; + std::thread::spawn(move || { + let n = sweep(&dir); + if n > 0 { + tracing::info!("清掉了 {n} 个上次更新改名留下的 core"); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_the_names_we_make_are_recognised() { + assert_eq!(retired_name(4242), "twcore.old-4242.exe"); + assert!(is_retired(&retired_name(4242))); + for other in [ + "twcore.exe", + "twcore.old-.exe", + "twcore.old-12a.exe", + "twcore.old-12.exe.bak", + "twcore.exe.old", + "ThinkWatch Lite.exe", + "xtwcore.old-12.exe", + ] { + assert!(!is_retired(other), "{other}"); + } + } + + #[test] + fn the_core_is_renamed_aside_and_back() { + let d = tempfile::tempdir().unwrap(); + std::fs::write(d.path().join(CORE_EXE), b"MZ old").unwrap(); + let aside = retire(d.path(), 77).unwrap(); + assert_eq!(aside, d.path().join("twcore.old-77.exe")); + assert!(!d.path().join(CORE_EXE).exists()); + // 安装程序在腾出来的名字上写新的 + std::fs::write(d.path().join(CORE_EXE), b"MZ new").unwrap(); + std::fs::remove_file(d.path().join(CORE_EXE)).unwrap(); + // 没装成:改回去 + restore(d.path(), &aside).unwrap(); + assert_eq!(std::fs::read(d.path().join(CORE_EXE)).unwrap(), b"MZ old"); + } + + /// 没有 `twcore.exe` 可让(被删了、目录不对):报错,调用方退回现行流程 + #[test] + fn retiring_a_missing_core_fails() { + let d = tempfile::tempdir().unwrap(); + assert!(retire(d.path(), 1).is_err()); + } + + /// 清理只删改名改出来的,别的文件都不碰 + #[test] + fn sweeping_removes_only_retired_cores() { + let d = tempfile::tempdir().unwrap(); + for f in [ + "twcore.old-1.exe", + "twcore.old-2.exe", + "twcore.exe", + "uninstall.exe", + "twcore.exe.old", + ] { + std::fs::write(d.path().join(f), b"x").unwrap(); + } + assert_eq!(sweep(d.path()), 2); + let mut left: Vec = std::fs::read_dir(d.path()) + .unwrap() + .flatten() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + left.sort(); + assert_eq!(left, ["twcore.exe", "twcore.exe.old", "uninstall.exe"]); + assert_eq!(sweep(&d.path().join("nope")), 0); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cc0bc8a8..d9046e10 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -42,6 +42,8 @@ pub mod error; /// 导出文件时系统的「存储」对话框和写盘(安全日志的导出),见模块头上 pub mod export; pub mod gateway; +/// 升级不断线:请 core 等接替者、已 arm 的退出不停 core、`brew upgrade` 的识别,见模块头上 +pub mod handover; pub mod import_link; pub mod keys; /// 一个在跑的请求的实时内容,搬给请求详情的「内容」页 @@ -332,17 +334,23 @@ pub fn run() { // 只读)。**两样都只在这里定一次**,守护和客户端用的是同一对 let at = control_address(); let key_file = tw_api::control::config_file(&data_dir()); - // 起好了没有,问控制面:`/status` 答得上来才算。半秒答不上这一次就 - // 算没答应,守护隔一会儿再问 + // 起好了没有,问控制面:`/status` 答得上来、**答话的就是刚起的那一个**才算 + // (升级不断线时,先答话的可能是等着被接替的旧 core,见 `supervisor::readiness`)。 + // 半秒答不上这一次就算没答应,守护隔一会儿再问 let ready = { let (at, key_file) = (at.clone(), key_file.clone()); - supervisor::probe(move || { + supervisor::probe_pid(move |pid| { let control = ControlClient::new(at.clone(), key_file.clone()); async move { - control - .ping(std::time::Duration::from_millis(500)) - .await - .is_ok() + match tokio::time::timeout( + std::time::Duration::from_millis(500), + control.call::(&[], &()), + ) + .await + { + Ok(Ok(s)) => supervisor::readiness(pid, s.pid, s.handover), + _ => supervisor::Answer::NotYet, + } } }) }; @@ -591,6 +599,19 @@ pub fn run() { update_loop(h).await; }); + // `brew upgrade` 退出应用时认出它来(arm 之后再退,见 `handover::brew`);它没退出 + // 应用就把包换掉了的话自己重启(`handover::bundle_watch`) + #[cfg(target_os = "macos")] + { + if let Some(mtm) = objc2::MainThreadMarker::new() { + handover::brew::install_quit_handler(mtm); + } + handover::bundle_watch::spawn(&handle); + } + // 上次更新时改名让出来的 core,这次清掉(见 `handover::retire`) + #[cfg(windows)] + handover::retire::sweep_in_background(); + Ok(()) }) .on_window_event(|window, event| { @@ -656,11 +677,16 @@ pub fn run() { api.prevent_exit(); } } - // 真要退了:先记下这个实例的样子(更新之后重新打开时照它恢复窗口), - // 再让 core 跟着走 —— 「一个程序」原则的另一半 + // 真要退了:`brew upgrade` 发起的先请 core 等接替者(见 `handover::brew`), + // 再记下这个实例的样子(重新打开时照它恢复窗口),最后让 core 跟着走 —— + // 「一个程序」原则的另一半。**arm 过的不停**:自动更新、`brew upgrade` 换版本时, + // 旧 core 接着服务,等新版起来的 core 接手 tauri::RunEvent::Exit => { + handover::arm_on_quit(app); updater::record_exit(app); - stop_core_on_exit(app); + if !handover::armed() { + stop_core_on_exit(app); + } } _ => {} } diff --git a/src-tauri/src/menubar/mod.rs b/src-tauri/src/menubar/mod.rs index 90d72e6e..e1cff23e 100644 --- a/src-tauri/src/menubar/mod.rs +++ b/src-tauri/src/menubar/mod.rs @@ -193,8 +193,12 @@ async fn run(app: tauri::AppHandle) { /// 该不该每秒走一次:菜单开着;或者有请求在跑、菜单栏上挂着速度那一行(只有标识的、 /// 不显示的没有)。**什么都没在跑、菜单关着时不走**:菜单栏上的破折号不会自己变 +/// +/// **升级之后旧 core 还在排空时也走**:它手上的请求结束了不会有事件过来,不走的话进行中的 +/// 个数(和菜单栏上的忙碌标记)就一直停在交接那一刻 fn ticks(open: bool, snap: &Snapshot, style: Style) -> bool { - open || (!snap.live.is_empty() && matches!(style, Style::Full | Style::Numbers)) + open || snap.draining > 0 + || (!snap.live.is_empty() && matches!(style, Style::Full | Style::Numbers)) } /// 叫醒菜单栏的几种理由(见 [`wait`]):有事件(攒一攒再收)、要立刻收、有请求开始了 @@ -268,6 +272,12 @@ async fn refresh_live(state: &AppState, snap: &mut Snapshot) { if let Ok(live) = ask(ASK, state.control.call::(&[], &())).await { apply_live(snap, live); } + // 排空中的旧 core 结束一个请求,这边没有事件:在排空的时候每一拍顺带问一下还剩几个 + if snap.draining > 0 + && let Ok(s) = ask(ASK, state.control.status()).await + { + snap.draining = s.draining; + } } /// 问 core 一句,最多等 `within`。**等不到就当没问到**,和问了被拒一样处理 @@ -476,6 +486,7 @@ async fn collect(app: &tauri::AppHandle, state: &AppState, credits: &mut Credits } if let Ok(s) = &status { snap.listen_error = s.listen_error.clone().map(|e| crate::core_text::text(&e)); + snap.draining = s.draining; } // 右边写的是「复制网关地址」复制的那一个:主机按连接(本机是 127.0.0.1),端口按配置。 // 网关没在监听时不写 diff --git a/src-tauri/src/menubar/model.rs b/src-tauri/src/menubar/model.rs index 103e8716..2d73a8f6 100644 --- a/src-tauri/src/menubar/model.rs +++ b/src-tauri/src/menubar/model.rs @@ -58,6 +58,9 @@ pub struct Snapshot { pub quotas: Vec, /// 进行中的请求,开始得早的在前 pub live: Vec, + /// 升级之后还在排空的旧 core 手上的请求数(`/status` 的 `draining`)。**算进进行中的个数**, + /// 不单独提示;它们不在 `live` 里(旧 core 的请求没有实时行) + pub draining: usize, /// 此刻每秒生成多少 token:在跑的请求各自的速度加起来(core 的 `/live`)。**没有在跑的 /// 请求是 None**;有请求在跑、却都还没开始生成(在等第一个 token,或者是非流式的)是 0 pub rate: Option, @@ -76,6 +79,13 @@ pub struct Snapshot { pub now_ms: u64, } +impl Snapshot { + /// 进行中的个数:这个 core 手上的,加上升级之后还在排空的旧 core 手上的 + pub fn in_progress(&self) -> usize { + self.live.len() + self.draining + } +} + #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct Connection { pub id: String, @@ -580,7 +590,7 @@ fn bar(s: &Snapshot, style: Style) -> Bar { None }, dim: !running, - busy: running && !s.live.is_empty(), + busy: running && s.in_progress() > 0, alert: !running && !matches!(s.gateway, Gateway::Starting), tooltip: tooltip(s, tight), } @@ -663,8 +673,8 @@ fn tooltip(s: &Snapshot, tight: Option<(&str, &Window)>) -> String { ) )); } - if !s.live.is_empty() { - parts.push(live_count(s.live.len())); + if s.in_progress() > 0 { + parts.push(live_count(s.in_progress())); } } parts.join(tr!(",", ", ")) @@ -798,10 +808,10 @@ fn rows(s: &Snapshot) -> Vec { } out.push(Row::Separator); - if !s.live.is_empty() { + if s.in_progress() > 0 { out.push(section( tr!("进行中", "In Progress"), - Some(s.live.len().to_string()), + Some(s.in_progress().to_string()), )); for l in s.live.iter().take(MAX_LIVE) { out.push(Row::Live { @@ -817,8 +827,10 @@ fn rows(s: &Snapshot) -> Vec { action: Action::OpenRequest(l.id), }); } - if s.live.len() > MAX_LIVE { - let more = s.live.len() - MAX_LIVE; + // 列不出来的那几个:超出行数的,加上排空中的旧 core 手上的(它们没有实时行) + let shown = s.live.len().min(MAX_LIVE); + if s.in_progress() > shown { + let more = s.in_progress() - shown; out.push(Row::Item(Item { icon: None, ..Item::new( diff --git a/src-tauri/src/menubar/model/tests.rs b/src-tauri/src/menubar/model/tests.rs index 0cc9c21a..e2c49263 100644 --- a/src-tauri/src/menubar/model/tests.rs +++ b/src-tauri/src/menubar/model/tests.rs @@ -83,6 +83,39 @@ fn windows(rows: &[Row]) -> Vec<(&str, &WindowRow)> { .collect() } +/// 升级之后旧 core 还在排空:**它手上的算进进行中的个数**,不单独提示;它们没有实时行, +/// 落在「另有 N 个」里。菜单栏照样标成忙 +#[test] +fn requests_left_on_the_draining_core_count_as_in_progress() { + with_lang(Lang::Zh, || { + let mut s = running(); + s.live = vec![Live { + id: 1, + started_ms: NOW - 1_000, + ..Default::default() + }]; + s.draining = 2; + let (bar, rows) = build(&s, Style::Full); + assert!(bar.busy); + assert!(bar.tooltip.contains("3 个请求进行中"), "{}", bar.tooltip); + let count = rows.iter().find_map(|r| match r { + Row::Section { title, right } if title == "进行中" => right.clone(), + _ => None, + }); + assert_eq!(count.as_deref(), Some("3")); + assert_eq!( + item(&rows, "live-more").map(|i| i.title.as_str()), + Some("另有 2 个") + ); + + // 只剩旧 core 手上的 + s.live.clear(); + let (bar, rows) = build(&s, Style::Icon); + assert!(bar.busy); + assert!(outline(&rows).contains(&"[进行中]".to_string())); + }); +} + /// 网关在运行、什么都有:打开主界面在最上面;提醒;今日、额度连在一起;进行中;策略组和 /// 两个复制;有新版本时「安装新版本」在设置前面;退出 #[test] diff --git a/src-tauri/src/notices/fixtures/snapshot.json b/src-tauri/src/notices/fixtures/snapshot.json index e85ff69f..f3cd0568 100644 --- a/src-tauri/src/notices/fixtures/snapshot.json +++ b/src-tauri/src/notices/fixtures/snapshot.json @@ -22,6 +22,8 @@ "providers": 1, "uptime_secs": 19, "in_flight": 0, + "handover": "idle", + "draining": 0, "remote_control": { "enabled": false, "addr": null, "allow_from": [], "reachable": [] }, "gateway_reachable": [] }, diff --git a/src-tauri/src/supervisor/mod.rs b/src-tauri/src/supervisor/mod.rs index f729bbd0..279f8de2 100644 --- a/src-tauri/src/supervisor/mod.rs +++ b/src-tauri/src/supervisor/mod.rs @@ -27,19 +27,76 @@ const HEALTHY_AFTER: Duration = Duration::from_secs(30); /// 进程起来之后,控制面多久还不答应就算这次没起来:换掉它,按一次失败算。 const READY_WITHIN: Duration = Duration::from_secs(15); -/// 问一次控制面答不答应。 +/// 新起来的 core 接管一个等接替者的旧 core,最多给它多久(方案 9.4 的接管超时,从连上旧 core +/// 到提交)。这段里控制面还是旧 core 在答([`Answer::HandingOver`]),**算启动中,不算没答应** +const TAKEOVER_WITHIN: Duration = Duration::from_secs(30); + +/// 问一次控制面,答成什么样(见 [`Probe`]) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Answer { + /// 这一个 core 答应了 + Ready, + /// 还没人答,或者答不上来 + NotYet, + /// 答话的是另一个**正在交接**的 core(等接替者的旧 core、接管还没交出控制面):这一个 + /// 正在接管它,接着等 + HandingOver, +} + +/// 问一次控制面答不答应。参数是刚起来的这个 core 的 pid。 /// /// **守护不认识控制面**,由外面给(lib.rs 里是一次带超时的 `/status`)。这样 /// 测试可以换成一个假的,不用真起一个会说 HTTP 的 core。 -pub type Probe = Arc Pin + Send>> + Send + Sync>; +/// +/// **要带 pid**:升级不断线以后,新 core 刚起来时控制面那个 socket 上答话的可能还是等着 +/// 被接替的旧 core(见 [`readiness`])。只问「有没有人答」的话,旧 core 一答就报了「运行中」, +/// 界面去连的是旧的那个 +pub type Probe = Arc Pin + Send>> + Send + Sync>; -/// 把一个返回 future 的闭包包成 [`Probe`]。 +/// 把一个只回答「答没答应」的闭包包成 [`Probe`](测试里用:不看是谁答的)。 pub fn probe(f: F) -> Probe where F: Fn() -> Fut + Send + Sync + 'static, Fut: Future + Send + 'static, { - Arc::new(move || -> Pin + Send>> { Box::pin(f()) }) + let f = Arc::new(f); + probe_pid(move |_| { + let f = f.clone(); + async move { + if f().await { + Answer::Ready + } else { + Answer::NotYet + } + } + }) +} + +/// 把一个按 pid 回答的闭包包成 [`Probe`]。 +pub fn probe_pid(f: F) -> Probe +where + F: Fn(u32) -> Fut + Send + Sync + 'static, + Fut: Future + Send + 'static, +{ + Arc::new(move |pid| -> Pin + Send>> { Box::pin(f(pid)) }) +} + +/// 控制面答了话(`/status`),对刚起来的 `child` 来说算什么。 +/// +/// - 答话的就是它:答应了; +/// - 答话的是另一个 core、它说自己在等接替者或者正被接管:`child` 正在接手它,接着等; +/// - 答话的是另一个 core、它不在交接(多出来的一个、上一次没退干净的):**和以前一样算答应**。 +/// 那种时候 `child` 取不到锁,马上会以「已有实例在运行」退出,守护照崩溃处理 —— 不在这里 +/// 另立一套判断;pid 对不上也可能只是开发时套了一层不 `exec` 的启动脚本 +pub fn readiness(child: u32, answered_by: u32, handover: tw_api::HandoverState) -> Answer { + use tw_api::HandoverState; + if answered_by == child { + return Answer::Ready; + } + match handover { + HandoverState::Armed | HandoverState::TakingOver => Answer::HandingOver, + HandoverState::Idle | HandoverState::Draining => Answer::Ready, + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -220,6 +277,11 @@ pub struct Supervisor { /// 叫醒。只是一个标志的话,那两段里没人看它 —— 以前切到远程时,一个还在启动的 core /// 照样起来、照样占着网关端口,一个在退避里的睡醒了照样被拉起来 stopping: watch::Sender, + /// 手上这个 core 已经 arm 了,要起一个新的来接替它(见 [`Supervisor::hand_over`])。 + /// + /// **和 `intentional` 的区别在于旧的那个不等它退出**:它还要接着把手上的请求做完,可能 + /// 要几十分钟;守护循环放开它(不再等、不杀),当场起下一个 + handing_over: watch::Sender, /// 起 core 之前补上用户配的环境变量。见 [`user_env`]。 /// /// 测试里不开:那要真去跑一次用户的登录 shell。 @@ -274,6 +336,7 @@ impl Supervisor { state: watch::channel(CoreState::Stopped).0, intentional: Arc::new(AtomicBool::new(false)), stopping: watch::channel(false).0, + handing_over: watch::channel(false).0, user_env: false, } } @@ -430,6 +493,41 @@ impl Supervisor { Ok(()) } + /// 手上这个 core 已经 arm 了(在等接替者):放开它,当场起一个新的来接替。 + /// + /// 「重新启动网关」(方案 R5)和更新没装成、要把 arm 过的 core 接回来时用。**旧的那个不停**: + /// 守护循环不再等它、不杀它,它接着把手上的请求做完,新起来的 core 连上它、接过监听,它 + /// 排空了自己退出。新的那个起来之前、接管的那几秒里网关照常由旧的服务。 + /// + /// 不计入退避(和 `request_restart` 一样是我们要求的)。新的那个接管失败、退出了,守护按 + /// 崩溃处理:再起一个,再接管一次(旧的那个这时回到等接替者,方案 F4) + pub async fn hand_over(&self) -> anyhow::Result<()> { + let pid = match self.state() { + CoreState::Running { pid } => pid, + CoreState::Starting | CoreState::Restarting { .. } | CoreState::SafeMode { .. } => { + anyhow::bail!(tr!( + "core 正在启动,请稍后再试", + "Core is starting; try again in a moment" + )) + } + CoreState::Stopped | CoreState::Exited { .. } | CoreState::Failed { .. } => { + anyhow::bail!(tr!( + "core 未运行,无法重启", + "Core is not running and cannot be restarted" + )) + } + }; + if self.user_env { + tokio::spawn(user_env::read_ahead()); + } + self.handing_over.send_replace(true); + // 守护循环收到就换状态(放开它、报「重启中」),几乎是当场的 + if !self.wait_gone(pid, Duration::from_secs(3)).await { + tracing::warn!(pid, "守护循环没有按时放开要交出去的 core"); + } + Ok(()) + } + /// 停掉 core、守护不再拉它,**并且等它真的退出**。 /// /// 应用重启(装完更新)之前要用它。只是自己退出、让 core 靠 `--parent` @@ -502,6 +600,27 @@ impl Supervisor { pub fn resume(&self) { self.stopping.send_replace(false); self.intentional.store(false, Ordering::SeqCst); + self.handing_over.send_replace(false); + } + + /// 放开一个已经 arm 的 core:不再等它、不杀它,报「重启中」,守护循环当场起下一个来接替。 + /// + /// **子进程交给一个后台任务去等**:丢掉句柄会杀掉它(`kill_on_drop`),而它要接着把手上 + /// 的请求做完;等到它退出也顺带把它收走,不留僵尸。它的 stderr 照旧有人读([`Tail`] 的 + /// 读取任务不随句柄结束),管道不会写满 + fn let_go(&self, mut child: tokio::process::Child, last_words: Option) -> Next { + let pid = child.id(); + tracing::info!(?pid, "交出 core:它做完手上的请求再退出,起一个新的接替它"); + tokio::spawn(async move { + let status = child.wait().await; + tracing::info!(?pid, ?status, "交出去的 core 已退出"); + drop(last_words); + }); + self.set(CoreState::Restarting { + attempt: 0, + in_ms: 0, + }); + Next::Again } /// 按要求停下:报「已停止」,守护循环不再起它。 @@ -557,17 +676,28 @@ impl Supervisor { &self.binary } - /// 等控制面答应,最多 [`READY_WITHIN`]。答应了是 true。 + /// 等 `pid` 这个 core 的控制面答应,最多 [`READY_WITHIN`]。答应了是 true。 /// /// 先密后疏:通常一两百毫秒就好了,那时候隔半秒才问一次,启动画面就平白 /// 多停半秒。 - async fn until_ready(&self) -> bool { + /// + /// **接管进行中算启动中**:答话的是一个正在交接的旧 core([`Answer::HandingOver`])时, + /// 期限放宽到再给它一个接管超时([`TAKEOVER_WITHIN`])—— 旧 core 要先做完手上的令牌续期、 + /// 上传没传的配置改动,才把控制面交过来 + async fn until_ready(&self, pid: u32) -> bool { // tokio 的时钟:测试里暂停时钟就能跑过这 15 秒 - let deadline = tokio::time::Instant::now() + READY_WITHIN; + let mut deadline = tokio::time::Instant::now() + READY_WITHIN; + let mut extended = false; let mut gap = Duration::from_millis(25); loop { - if (self.ready)().await { - return true; + match (self.ready)(pid).await { + Answer::Ready => return true, + Answer::HandingOver if !extended => { + extended = true; + deadline = deadline.max(tokio::time::Instant::now() + TAKEOVER_WITHIN); + tracing::info!(pid, "新起的 core 正在接管等接替者的那一个"); + } + Answer::HandingOver | Answer::NotYet => {} } if tokio::time::Instant::now() >= deadline { return false; @@ -585,6 +715,8 @@ impl Supervisor { if *stop.borrow_and_update() { return Ok(self.stopped()); } + // 「交出去」只对上一轮那一个有意义:它没等到就自己退出了的话,记号不能落到这一个身上 + self.handing_over.send_replace(false); self.set(if safe { CoreState::SafeMode { pid: None } } else { @@ -663,9 +795,11 @@ impl Supervisor { Ready(bool), Stop, } + // 已经退出的话没有 pid:那一支马上就会先到 + let pid = child.id().unwrap_or(0); let waited = tokio::select! { status = child.wait() => Waited::Exited(status), - ready = self.until_ready() => Waited::Ready(ready), + ready = self.until_ready(pid) => Waited::Ready(ready), _ = until_stopped(&mut stop) => Waited::Stop, }; // 信号一:子进程退出事件。**最快最准**,但只覆盖我们自己 spawn @@ -675,6 +809,7 @@ impl Supervisor { // 答应的那一刻「要停」也到了:不报「运行中」,和下面一样停掉 Waited::Ready(true) if *self.stopping.borrow() => stop_starting(&mut child).await?, Waited::Ready(ready) => { + let mut running = false; match (ready, child.id()) { (true, Some(pid)) => { self.set(if safe { @@ -682,6 +817,7 @@ impl Supervisor { } else { CoreState::Running { pid } }); + running = !safe; tracing::info!(pid, safe, elapsed = ?started.elapsed(), "core 已就绪"); } (true, None) => {} @@ -690,7 +826,17 @@ impl Supervisor { let _ = child.start_kill(); } } - child.wait().await? + // **跑着的时候也听着「交出去」**([`Supervisor::hand_over`])。只有正常模式里 + // 答应了的才会被交出去:`hand_over` 只认「运行中」 + let mut over = self.handing_over.subscribe(); + tokio::select! { + status = child.wait() => status?, + // 拿到的那个借用当场放掉:它握着 watch 的读锁,下面要写 + _ = async { let _ = over.wait_for(|h| *h).await; }, if running => { + self.handing_over.send_replace(false); + return Ok(self.let_go(child, last_words)); + } + } } Waited::Stop => stop_starting(&mut child).await?, }; diff --git a/src-tauri/src/supervisor/tests.rs b/src-tauri/src/supervisor/tests.rs index e78d2beb..b5c0cc3d 100644 --- a/src-tauri/src/supervisor/tests.rs +++ b/src-tauri/src/supervisor/tests.rs @@ -881,3 +881,109 @@ async fn asking_a_core_whose_control_plane_never_answers_gives_up() { .expect("请它退出挂住了"); assert!(!asked, "控制面没答、信号也没处送,却说请到了"); } + +/// 控制面答话的是谁:刚起的那一个才算答应;等接替者、正被接管的旧 core 答的算「接管中」; +/// 不在交接的另一个 core 答的照以前算答应(刚起的那个取不到锁,马上会以崩溃退出) +#[test] +fn who_answered_decides_whether_the_new_core_is_ready() { + use tw_api::HandoverState as H; + for h in [H::Idle, H::Armed, H::TakingOver, H::Draining] { + assert_eq!(readiness(7, 7, h), Answer::Ready, "{h:?}"); + } + assert_eq!(readiness(7, 3, H::Armed), Answer::HandingOver); + assert_eq!(readiness(7, 3, H::TakingOver), Answer::HandingOver); + assert_eq!(readiness(7, 3, H::Idle), Answer::Ready); + assert_eq!(readiness(7, 3, H::Draining), Answer::Ready); +} + +/// 一直是旧 core 在答(接管中):**期限放宽一个接管超时**,过了这个也还没交过来才换掉它 +#[tokio::test(start_paused = true)] +async fn a_takeover_in_progress_counts_as_starting() { + let (_dir, bin) = long_runner("takeover"); + let s = Supervisor::new( + bin, + None, + probe_pid(|_| async { Answer::HandingOver }), + test_address(), + PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), + ); + let t0 = tokio::time::Instant::now(); + assert!(!s.until_ready(1).await); + assert!( + t0.elapsed() >= TAKEOVER_WITHIN, + "接管中只等了 {:?}", + t0.elapsed() + ); + // 没人答的:照旧 READY_WITHIN + let s = Supervisor::new( + PathBuf::from("/x"), + None, + probe_pid(|_| async { Answer::NotYet }), + test_address(), + PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), + ); + let t0 = tokio::time::Instant::now(); + assert!(!s.until_ready(1).await); + assert!(t0.elapsed() < TAKEOVER_WITHIN); +} + +/// 「重新启动网关」走交接:**旧的那个不停**(它还要把手上的请求做完),守护放开它、当场起 +/// 一个新的,不计入退避 +#[cfg(unix)] +#[tokio::test] +async fn handing_over_lets_the_old_core_run_and_starts_a_new_one() { + let (_dir, bin) = long_runner("handover"); + let s = Arc::new(Supervisor::new( + bin, + None, + always_ready(), + test_address(), + PathBuf::from("/tw-no-such-dir-xyz/config.yaml"), + )); + let looped = { + let s = s.clone(); + tokio::spawn(async move { s.run_once(false).await.unwrap() }) + }; + let old = until_running(&s).await; + s.hand_over().await.unwrap(); + assert_eq!( + tokio::time::timeout(PATIENCE, looped) + .await + .unwrap() + .unwrap(), + Next::Again + ); + assert_eq!( + s.state(), + CoreState::Restarting { + attempt: 0, + in_ms: 0 + } + ); + // SAFETY: 信号 0 只问这个 pid 还在不在,什么都不送 + assert_eq!( + unsafe { libc::kill(old as i32, 0) }, + 0, + "交出去的 core 被停了" + ); + + // 下一轮照常起一个新的 + let again = { + let s = s.clone(); + tokio::spawn(async move { s.run_once(false).await.unwrap() }) + }; + let new = until_running(&s).await; + assert_ne!(new, old); + // 交出去的那一个还在跑,停新的不碰它 + s.stop_and_wait(Duration::from_secs(5)).await; + assert_eq!(again.await.unwrap(), Next::Stop); + assert_eq!(unsafe { libc::kill(old as i32, 0) }, 0); + // 收拾:测试自己停掉它 + unsafe { libc::kill(old as i32, libc::SIGKILL) }; +} + +/// 没在运行的(启动中、安全模式、停着)不交接:调用方退回停了再起 +#[tokio::test] +async fn only_a_running_core_is_handed_over() { + assert!(sup().hand_over().await.is_err()); +} diff --git a/src-tauri/src/update.rs b/src-tauri/src/update.rs index c2d4a8f8..3246fd30 100644 --- a/src-tauri/src/update.rs +++ b/src-tauri/src/update.rs @@ -104,12 +104,19 @@ fn bundle_of(exe: &Path) -> Option<&Path> { /// **不去 exec `brew`。**Finder 拉起来的进程 PATH 里没有它;而且为了回答 /// 一个布尔值去跑一个 Ruby 程序,代价和失败面都不对。 fn from_homebrew(bundle: &Path, roots: &[PathBuf]) -> bool { + homebrew_root(bundle, roots).is_some() +} + +/// 放这个 `.app` 的那一份 Homebrew 的前缀([`from_homebrew`] 比对上的那一个)。 +/// +/// `brew upgrade` 退出应用时要认出发起的是不是这一份 Homebrew(见 `handover::brew`), +/// 前缀就取这里认出来的,不另猜、不调用 brew。 +#[cfg(target_os = "macos")] +fn homebrew_root<'a>(bundle: &Path, roots: &'a [PathBuf]) -> Option<&'a PathBuf> { // 两边都解引用之后再比。`/Applications` 本身可能是链接,Caskroom 里 // 那一项一定是链接 —— 比路径字符串会两边都判错。 - let Ok(me) = bundle.canonicalize() else { - return false; - }; - roots.iter().any(|root| { + let me = bundle.canonicalize().ok()?; + roots.iter().find(|root| { let Ok(versions) = std::fs::read_dir(root.join("Caskroom").join(CASK)) else { return false; }; @@ -122,6 +129,20 @@ fn from_homebrew(bundle: &Path, roots: &[PathBuf]) -> bool { }) } +/// 自己所在的 `.app`(见 [`bundle_of`])。不在一个 `.app` 里是 `None` +#[cfg(target_os = "macos")] +pub(crate) fn own_bundle() -> Option { + let exe = std::env::current_exe().ok()?; + bundle_of(&exe).map(Path::to_path_buf) +} + +/// 这一份是 Homebrew 装的话,那份 Homebrew 的前缀(`/opt/homebrew` 或 `/usr/local`) +#[cfg(target_os = "macos")] +pub(crate) fn homebrew_prefix() -> Option { + let bundle = own_bundle()?; + homebrew_root(&bundle, &BREW_PREFIXES.map(PathBuf::from)).cloned() +} + /// 给定可执行文件和 brew 的前缀,判断这是哪一种安装。 #[cfg(target_os = "macos")] pub fn kind_at(exe: &Path, roots: &[PathBuf]) -> Install { diff --git a/src-tauri/src/updater.rs b/src-tauri/src/updater.rs index 98b9c4d0..474a7699 100644 --- a/src-tauri/src/updater.rs +++ b/src-tauri/src/updater.rs @@ -8,7 +8,9 @@ use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder}; use crate::dmg; #[cfg(windows)] use crate::gateway::restart_gateway; -use crate::{AppState, control::ControlClient, data_dir, error::Out, i18n, notices, prefs, update}; +use crate::{ + AppState, control::ControlClient, data_dir, error::Out, handover, i18n, notices, prefs, update, +}; /// 更新这件事的当前状态,一次说完。 /// @@ -456,15 +458,20 @@ pub(crate) async fn wait_for_quiet(control: &ControlClient, on_wait: impl Fn(usi } } -/// 下载、等手上的请求结束、替换、重启。**按一次之后不再问任何问题。** +/// 下载、替换、请 core 等接替者(arm)、重启。**按一次之后不再问任何问题。** /// /// **Homebrew 装的一律拒绝。**理由在 `update` 模块开头。这里再挡一次,是 /// 因为这是一条策略 —— 只靠界面上那个按钮不显示来维持,等于让一次渲染的 /// 疏忽去降级用户的安装。 /// -/// **先下载,再等,最后才替换。**反过来的话,磁盘上的包在等待的那几分钟 -/// 里已经是新版本了 —— 这期间 core 万一退出,守护会从磁盘上拉起一个和 -/// 正在运行的界面不是同一版的网关。 +/// **不再等手上的请求结束**:旧 core arm 之后脱离这个进程接着服务,新版起来的 core 接过 +/// 监听,旧的做完手上的请求再走(见 `handover`)。arm 不上(core 太旧、登录进行中、安全 +/// 模式)才退回以前的做法:等请求结束、停掉 core、重启([`hand_over_or_stop`])。 +/// +/// **先替换,再 arm。**arm 之后旧 core 只等接替者一段时间(缺省 120 秒),替换要是在那之后 +/// 才完成(macOS 上要等用户输管理员密码),它就当新版没起来、自己排空退出了。代价是退回 +/// 现行流程时,等请求结束的那几分钟里磁盘上已经是新版:这期间 core 万一崩溃,守护拉起的是 +/// 新版的 core,界面要到重启之后才对得上 —— 只在 arm 不上又恰好崩溃时才会遇到。 #[tauri::command] pub async fn update_install( app: tauri::AppHandle, @@ -528,8 +535,7 @@ pub async fn update_install( if install == update::Install::Portable { return install_portable(&app, &state, bytes).await; } - // 发布页上只有 DMG,插件只装 `.app.tar.gz` —— 见 `dmg`。放在等请求之前: - // 包打不开的话,不该先让用户白等几分钟 + // 发布页上只有 DMG,插件只装 `.app.tar.gz` —— 见 `dmg` #[cfg(target_os = "macos")] let bytes = tauri::async_runtime::spawn_blocking(move || dmg::app_archive(&bytes)) .await @@ -541,49 +547,22 @@ pub async fn update_install( ) })?; - // 问的是本机的 core:要重启的是它。连着远程时它本来就停着,不用等 - wait_for_quiet(state.supervisor.control(), |in_flight| { - let _ = app.emit("update-step", Step::Waiting { in_flight }); - }) - .await; - let _ = app.emit("update-step", Step::Installing); - // **Windows 上 `install` 不回来。**插件拉起新版本的安装程序之后当场 - // `exit(0)`,排在它后面的每一步都轮不到,退出时的那一套(记下窗口开没开、 - // 停 core)也轮不到 —— 所以都得挪到它前面。停 core 在那边还是硬要求: - // `twcore.exe` 还在跑的话,安装程序覆盖不了一个被占用的文件。装不成的话这条 - // 记录留着也不碍事:版本没变,下次启动不会把它当成更新之后重开 #[cfg(windows)] { - record_exit(&app); - let _ = app.emit("update-step", Step::Restarting); - state - .supervisor - .stop_and_wait(std::time::Duration::from_secs(5)) - .await; - let installed = install_blocking(up, bytes) - .await - .and_then(|r| r.map_err(|e| e.to_string())); - if let Err(e) = installed { - // 最常见的是 UAC 那一下点了「否」。**把停掉的网关接回来**:不接的话, - // 它就这么停着 - resume_after_failed_update(&app).await; - return Err(tr!( - format!("安装失败:{e}"), - format!("Installation failed: {e}") - ) - .into()); - } + install_windows(&app, &state, up, bytes).await?; // 走到这里说明插件没有照它自己说的那样退出。那就和其他平台一样,自己重启 } #[cfg(not(windows))] { - // Linux 上走到这里的只有 AppImage:插件原地换掉 `$APPIMAGE` 那个文件 + // Linux 上走到这里的只有 AppImage:插件原地换掉 `$APPIMAGE` 那个文件。正在跑的 + // core 不在那个文件里,在数据目录的缓存里(见 `handover::core_cache`) #[cfg(target_os = "linux")] install_blocking(up, bytes) .await? .map_err(|e| update::appimage_failure(&e))?; + // macOS 上整个 `.app` 被换掉;正在跑的 core 已经把旧文件映射进来了,不受影响 #[cfg(target_os = "macos")] install_blocking(up, bytes).await?.map_err(|e| { tr!( @@ -591,26 +570,144 @@ pub async fn update_install( format!("Installation failed: {e}") ) })?; + hand_over_or_stop(&app, &state).await; + } + let _ = app.emit("update-step", Step::Restarting); + app.restart() +} + +/// 换好了新版、重启之前:请本机的 core 等接替者。arm 上了返回 `true`,这一次退出不停它 +/// (退出回调看 [`handover::armed`]),退出记录里也记上([`record_exit`])。 +/// +/// **arm 不上就照以前的做法**(方案 F2):等手上的请求结束(更新窗口里显示「等待 N 个请求 +/// 结束」)、停掉 core、等它真的退出 —— 否则新起来的应用会先撞上旧 core 手里的锁(见 +/// `Supervisor::stop_and_wait`)。问的是本机的 core:要重启的是它,连着远程时它本来就停着, +/// arm 不上、也不用等 +async fn hand_over_or_stop(app: &tauri::AppHandle, state: &AppState) -> bool { + let armed = handover::arm(state.supervisor.control(), handover::relaunch_for(app)).await; + let armed = handover::or_fall_back(&armed, wait_out(app, state), async { let _ = app.emit("update-step", Step::Restarting); - // 先停掉 core、等它真的退出,再重启应用 —— 否则新起来的应用会先撞上 - // 旧 core 手里的锁。见 `Supervisor::stop_and_wait`。 state .supervisor .stop_and_wait(std::time::Duration::from_secs(5)) .await; - } + }) + .await; + handover::set_armed(armed); + armed +} + +/// 退回现行流程时的「等请求结束」:更新窗口里显示还剩几个 +async fn wait_out(app: &tauri::AppHandle, state: &AppState) { + wait_for_quiet(state.supervisor.control(), |in_flight| { + let _ = app.emit("update-step", Step::Waiting { in_flight }); + }) + .await; +} + +/// Homebrew 没退出应用就把它换掉了(见 `handover::bundle_watch`):照自动更新的样子交接、 +/// 重启。重启之后照退出时的窗口状态恢复(退出回调记下的) +#[cfg(target_os = "macos")] +pub(crate) async fn restart_into_replaced(app: &tauri::AppHandle) { + let Some(state) = app.try_state::() else { + return; + }; + // 连着远程时本机的 core 停着:arm 不上,等也是立刻回来,照样重启 + hand_over_or_stop(app, &state).await; app.restart() } -/// 绿色版的安装:拆包、等请求结束、停网关、换掉文件夹里的两个 exe、重启。**不弹 -/// UAC**:文件夹是用户自己解压的,启动时已经确认过能写(`portable::prepare`)。 +/// Windows 安装版:arm、把运行中的 `twcore.exe` 改名让出来、跑安装程序。 +/// +/// **`install` 不回来。**插件拉起新版本的安装程序之后当场 `exit(0)`,排在它后面的每一步都 +/// 轮不到,退出时的那一套(记下窗口开没开、停不停 core)也轮不到 —— 所以都得挪到它前面。 +/// +/// **安装程序要覆盖 `twcore.exe`**:以前是先停掉它;现在 arm 上了就把它改名成 +/// `twcore.old-.exe`(`handover::retire`),它照常跑到排空,安装程序在腾出来的名字上 +/// 写新版本。改不了名(被别的程序占着、杀毒软件拦了)就和 arm 不上一样,退回停掉它。 +/// +/// 装不成(最常见的是 UAC 那一下点了「否」):arm 过的,名字改回来、起一个同版本的 core +/// 接替它(交接,网关不断);停过的,把停掉的网关接回来 +#[cfg(windows)] +async fn install_windows( + app: &tauri::AppHandle, + state: &AppState, + up: tauri_plugin_updater::Update, + bytes: Vec, +) -> Out<()> { + use crate::supervisor::CoreState; + let dir = state + .supervisor + .binary() + .parent() + .map(std::path::Path::to_path_buf); + let pid = match state.supervisor.state() { + CoreState::Running { pid } => Some(pid), + _ => None, + }; + let mut retired = None; + if let (Some(dir), Some(pid)) = (&dir, pid) + && handover::arm(state.supervisor.control(), handover::relaunch_for(app)) + .await + .is_ok() + { + match handover::retire::retire(dir, pid) { + Ok(to) => retired = Some(to), + Err(e) => tracing::warn!("运行中的 twcore.exe 改不了名,退回停掉它:{e}"), + } + } + // arm 上了、也让出了文件才算交接;改不了名的和 arm 不上的一样退回停掉它 + let handed = match &retired { + Some(_) => Ok(0), + None => Err("没有 arm,或者 twcore.exe 改不了名".to_string()), + }; + let armed = handover::or_fall_back(&handed, wait_out(app, state), async { + let _ = app.emit("update-step", Step::Restarting); + state + .supervisor + .stop_and_wait(std::time::Duration::from_secs(5)) + .await; + }) + .await; + handover::set_armed(armed); + record_exit(app); + let _ = app.emit("update-step", Step::Restarting); + let installed = install_blocking(up, bytes) + .await + .and_then(|r| r.map_err(|e| e.to_string())); + let Err(e) = installed else { + return Ok(()); + }; + // 没装成:这次不算退出,记录删掉 + forget_exit(); + match (retired, dir) { + (Some(aside), Some(dir)) => { + handover::set_armed(false); + if let Err(r) = handover::retire::restore(&dir, &aside) { + tracing::error!("改了名的 twcore.exe 改不回来:{r}"); + } + adopt_armed_core(app).await; + } + _ => resume_after_failed_update(app).await, + } + Err(tr!( + format!("安装失败:{e}"), + format!("Installation failed: {e}") + ) + .into()) +} + +/// 绿色版的安装:拆包、arm、换掉文件夹里的两个 exe、重启。**不弹 UAC**:文件夹是用户自己 +/// 解压的,启动时已经确认过能写(`portable::prepare`)。 +/// +/// **包先拆开看。**形状不对(见 `portable::unpack`)不该先 arm、也不该让用户白等。 /// -/// **包先拆开看。**形状不对(见 `portable::unpack`)不该先让用户白等几分钟,网关 -/// 也还没停。 +/// 换文件本来就是改名替换(`portable::swap_in`):正在跑的 `twcore.exe` 改名成 `.old` 照常 +/// 跑到排空,所以 arm 上了就不停它。arm 不上才照以前的做法等请求结束、停掉它。 /// -/// 换文件之前先记下退出时的样子([`record_exit`]),和其他平台一样:重启之后照它 -/// 恢复窗口。换不成就把改过名的文件改回来(`portable::swap_in` 自己做)、把停掉的 -/// 网关接回来,说清楚去哪下载。 +/// 换文件之前先记下退出时的样子([`record_exit`]),和其他平台一样:重启之后照它恢复窗口。 +/// 换不成就把改过名的文件改回来(`swap_in` 自己做):arm 过的起一个同版本的 core 接替它, +/// 停过的把网关接回来;说清楚去哪下载。 #[cfg(windows)] async fn install_portable(app: &tauri::AppHandle, state: &AppState, zip: Vec) -> Out<()> { use crate::portable; @@ -619,31 +716,37 @@ async fn install_portable(app: &tauri::AppHandle, state: &AppState, zip: Vec .map_err(|e| e.to_string())? .map_err(|e| update::portable_failure(&e))?; - // 问的是本机的 core:要重启的是它。连着远程时它本来就停着,不用等 - wait_for_quiet(state.supervisor.control(), |in_flight| { - let _ = app.emit("update-step", Step::Waiting { in_flight }); - }) - .await; - let _ = app.emit("update-step", Step::Installing); + let armed = hand_over_or_stop(app, state).await; record_exit(app); - // `twcore.exe` 也要换:先停下,等它真的退出 - state - .supervisor - .stop_and_wait(std::time::Duration::from_secs(5)) - .await; let swapped = tauri::async_runtime::spawn_blocking(move || portable::install(package)) .await .map_err(|e| e.to_string()) .and_then(|r| r); if let Err(e) = swapped { - resume_after_failed_update(app).await; + forget_exit(); + if armed { + handover::set_armed(false); + adopt_armed_core(app).await; + } else { + resume_after_failed_update(app).await; + } return Err(update::portable_failure(&e).into()); } let _ = app.emit("update-step", Step::Restarting); app.restart() } +/// 更新没装成,core 却已经 arm 了(它脱离了这个进程,在等接替者):起一个同版本的 core +/// 接替它。**交接,不是停掉再起**:网关不断,和「重新启动网关」走的是同一条路 +#[cfg(windows)] +async fn adopt_armed_core(app: &tauri::AppHandle) { + let state = app.state::(); + if let Err(e) = state.supervisor.hand_over().await { + tracing::error!("更新没装成,也没能起一个 core 接替已经 arm 的那一个:{e:#}"); + } +} + /// 装下载好的那一份,**在阻塞线程上**。 /// /// `install` 从头到尾都是阻塞的活:macOS 上要替换的位置写不进去时,插件把管理员密码 @@ -699,9 +802,17 @@ struct LastExit { at_ms: u64, /// 主窗口开着吗 window: bool, + /// 这次退出前请 core 等接替者了(见 `handover`):接下来要么是新版被打开(自动更新的 + /// 重启、Homebrew 的 `open -b`、看到应用包被换掉之后的自己重启),要么是等不到接替者的 + /// core 把桌面端重新打开 —— 后者版本没变,**只有这个记号说得出它是被重新打开的**。 + /// + /// 缺省 `false`:上一版写的记录没有这一项(第一次升级到带交接的版本,走的还是现行流程, + /// 退出时写的是旧格式),读不出来就丢了那一次的窗口状态 + #[serde(default)] + armed: bool, } -/// 被更新换下来之后重新打开:换之前是哪一版,那时主窗口开着没有 +/// 被重新打开(更新换下来之后、core 等不到接替者之后):之前是哪一版,那时主窗口开着没有 #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct Relaunch { pub from: String, @@ -722,7 +833,8 @@ pub(crate) fn quitting_by_user() { /// /// **用户自己点的「退出」不记**(旧的记录也删掉):之后再打开是用户要打开,照常开窗口。 /// 别的退出都记 —— Homebrew 升级时用 Apple Event 让应用退出,和在 Dock 上点「退出」、注销时 -/// 系统让它退出是同一条路;应用自己装完更新是先正常退出再重启 +/// 系统让它退出是同一条路;应用自己装完更新是先正常退出再重启。**这次退出 arm 过就记上** +/// ([`handover::armed`]):重新打开时凭它照样恢复窗口,版本变没变都一样 pub(crate) fn record_exit(app: &tauri::AppHandle) { let path = data_dir().join(LAST_EXIT); if QUIT_BY_USER.load(std::sync::atomic::Ordering::SeqCst) { @@ -735,6 +847,7 @@ pub(crate) fn record_exit(app: &tauri::AppHandle) { window: app .get_webview_window("main") .is_some_and(|w| w.is_visible().unwrap_or(false)), + armed: handover::armed(), }; let written = serde_json::to_vec(&exit) .map_err(std::io::Error::other) @@ -744,11 +857,23 @@ pub(crate) fn record_exit(app: &tauri::AppHandle) { } } -/// 这次启动是不是更新之后被重新打开的。启动时调一次,**读完就删**。 +/// 更新没装成、这次不退出了:删掉刚才记下的那一条(它标着「已 arm」,留着的话用户过一会儿 +/// 自己退出又打开,会被当成更新之后重新打开) +#[cfg_attr(not(windows), allow(dead_code))] +pub(crate) fn forget_exit() { + let _ = std::fs::remove_file(data_dir().join(LAST_EXIT)); +} + +/// 这次启动是不是被重新打开的(更新之后、core 等不到接替者之后)。启动时调一次,**读完就删**。 /// -/// 三条都要满足:上一个实例不是用户自己退出的([`record_exit`] 记下了它)、那之后版本变了、 -/// 离它退出没多久([`RELAUNCH_WITHIN_MS`])。之前没在跑的,Homebrew 不会重新打开它, -/// 之后是用户自己点开的 +/// 三条都要满足:上一个实例不是用户自己退出的([`record_exit`] 记下了它)、那次退出 arm 过 +/// 或者那之后版本变了、离它退出没多久([`RELAUNCH_WITHIN_MS`])。之前没在跑的,Homebrew 不会 +/// 重新打开它,之后是用户自己点开的。 +/// +/// **arm 过的不看版本**:core 等不到接替者时会把桌面端重新打开(Homebrew 升级失败回滚了, +/// 打开的还是这一版),那也是「之前什么样、现在什么样」。**版本变了而没 arm 的也算**:arm 不上 +/// 退回现行流程的那次更新、没认出是 `brew upgrade` 的那次退出、上一版写的记录 —— 都是被更新 +/// 换下来的 pub(crate) fn relaunched(dir: &std::path::Path, version: &str, now_ms: u64) -> Option { let path = dir.join(LAST_EXIT); let raw = std::fs::read(&path).ok(); @@ -759,7 +884,7 @@ pub(crate) fn relaunched(dir: &std::path::Path, version: &str, now_ms: u64) -> O fn after_update(exit: LastExit, version: &str, now_ms: u64) -> Option { let recent = now_ms.saturating_sub(exit.at_ms) <= RELAUNCH_WITHIN_MS; - (exit.version != version && recent).then_some(Relaunch { + ((exit.armed || exit.version != version) && recent).then_some(Relaunch { from: exit.version, window: exit.window, }) @@ -870,7 +995,61 @@ mod tests { version: version.into(), at_ms: EXITED, window, + armed: false, + } + } + + fn armed_exit(version: &str, window: bool) -> LastExit { + LastExit { + armed: true, + ..exit(version, window) + } + } + + /// 退出前 arm 过的(自动更新、`brew upgrade`、看到应用包被换掉之后的自己重启):照退出 + /// 时的样子恢复。**版本没变也算** —— core 等不到接替者时把桌面端重新打开(Homebrew 升级 + /// 失败回滚了,打开的还是这一版) + #[test] + fn an_armed_exit_restores_the_window_even_without_a_new_version() { + let soon = EXITED + 8_000; + for (window, version) in [ + (false, "2026.9.23"), + (true, "2026.9.23"), + (false, "2026.9.22"), + ] { + assert_eq!( + after_update(armed_exit("2026.9.22", window), version, soon), + Some(Relaunch { + from: "2026.9.22".into(), + window + }), + "{window} {version}" + ); } + // 太久之后才打开的不算,arm 过也一样 + assert_eq!( + after_update( + armed_exit("2026.9.22", false), + "2026.9.22", + EXITED + RELAUNCH_WITHIN_MS + 1 + ), + None + ); + } + + /// 上一版写的记录没有 `armed`:读得出来,当没 arm(版本变了照样恢复) + #[test] + fn a_record_from_the_previous_version_still_reads() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join(LAST_EXIT), + format!(r#"{{"version":"2026.9.22","at_ms":{EXITED},"window":false}}"#), + ) + .unwrap(); + assert_eq!( + relaunched(dir.path(), "2026.9.23", EXITED + 8_000).map(|r| r.window), + Some(false) + ); } /// Homebrew 退出应用、换上新版、几秒后重新打开:照退出时的样子,窗口关着的还关着,开着 diff --git a/src/connection/Unlinked.tsx b/src/connection/Unlinked.tsx index 77bb0568..f97a4b64 100644 --- a/src/connection/Unlinked.tsx +++ b/src/connection/Unlinked.tsx @@ -188,6 +188,7 @@ export function Mismatch({ view, ours, theirs }: { view: ConnView; ours: string; export function ConfigBroken({ rejection, path, + held = false, fixes, repairing, onRepair, @@ -196,6 +197,11 @@ export function ConfigBroken({ }: { rejection: ConfigRejection; path: string; + /** + * 升级之后的新 core 读不了配置、只接过了控制面(`handover` 为 `taking_over`):网关还在 + * 旧 core 上按更新前的配置转发,配置修好后新 core 再接过网关 + */ + held?: boolean; /** 一键修复要改的几处(见 `useRepair`)。空的就是修不了,只剩另外两条路 */ fixes: ConfigFix[]; repairing: boolean; @@ -212,6 +218,7 @@ export function ConfigBroken({ return ( } tone="error" title={t.configBroken} status={t.safeMode}>

{t.configBrokenLead}

+ {held &&

{t.configBrokenHeld}

}
@@ -328,6 +330,8 @@ export const connText = messages( openConfigFile: "Open configuration file", restoreFromBackup: "Restore from backup…", configBrokenResume: "Once a corrected configuration is saved, the gateway resumes on its own.", + configBrokenHeld: + "The gateway runs with the configuration from before the update and switches over once the configuration is fixed.", lostBanner: (name: string, attempt: number) => attempt > 0 diff --git a/src/generated/tw-api.ts b/src/generated/tw-api.ts index 3666424b..03314f23 100644 --- a/src/generated/tw-api.ts +++ b/src/generated/tw-api.ts @@ -1,6 +1,6 @@ // Generated by tw-api (`tw_api::ts::export_all`). Do not edit by hand. -export const CONTROL_API_VERSION = 51; +export const CONTROL_API_VERSION = 52; /** * 手动中止(`POST /request/{id}/abort`、`POST /sessions/{id}/abort`)叫停了哪几个请求。 @@ -1959,6 +1959,34 @@ rules: Array, }; */ export type GuardMode = "off" | "observe" | "enforce"; +/** + * `POST /handover/arm` 的请求体。 + */ +export type HandoverArm = { +/** + * 等接替者多久,秒。缺省 120 + */ +wait_secs: number | null, +/** + * 等不到时怎么重新打开桌面端(只试一次,之后再等一个 `wait_secs`,仍等不到就排空退出)。 + * 空 = 不重新打开 + */ +relaunch: Relaunch | null, }; + +/** + * `POST /handover/arm` 的应答。 + */ +export type HandoverArmed = { +/** + * 交接连接的版本号(`tw_handover::VERSION`)。接替者的不同就不交接,退回现行流程 + */ +version: number, }; + +/** + * 交接走到了哪一步([`Status::handover`])。 + */ +export type HandoverState = "idle" | "armed" | "taking_over" | "draining"; + /** * 一个报文头:请求行或状态行,和头([`RequestDetail::heads`],也是实时内容里的 * `head`,见 [`LiveContent`])。 @@ -4169,6 +4197,11 @@ median: number, samples: number, }; */ export type ReferenceView = { "kind": "rule_target", route: string, rule: string, } | { "kind": "rule_condition", route: string, rule: string, } | { "kind": "group", group: string, }; +/** + * 重新打开桌面端的方式。 + */ +export type Relaunch = { "kind": "bundle_id", id: string, } | { "kind": "exe", path: string, args: Array, }; + /** * 远程控制端口(`listen.control.remote`)。 */ @@ -5467,6 +5500,15 @@ config_rejected?: ConfigRejection | null, config_path: string, clients: number, * 重启网关之前要看它 —— 重启会掐断所有还没结束的流。 */ in_flight: number, +/** + * 交接走到了哪一步。见 [`HandoverState`] + */ +handover: HandoverState, +/** + * 排空中的旧 core 手上还有几个请求(它每结束一个就报一次)。**只有接管了它的新 core + * 报**,平时是 0。界面把它加进在途个数,不单独提示 + */ +draining: number, /** * 远程控制端口此刻的样子。 */ @@ -6252,6 +6294,7 @@ export type ErrorBody = Msg; export const ENDPOINTS = { Status: { method: "GET", path: "/status", params: [], format: "json" }, Shutdown: { method: "POST", path: "/shutdown", params: [], format: "json" }, + HandoverArm: { method: "POST", path: "/handover/arm", params: [], format: "json" }, Interfaces: { method: "GET", path: "/interfaces", params: [], format: "json" }, Events: { method: "GET", path: "/events", params: [], format: "events" }, InFlight: { method: "GET", path: "/in-flight", params: [], format: "json" }, @@ -6403,6 +6446,7 @@ export const ENDPOINTS = { export type Endpoints = { Status: { req: null; res: Status }; Shutdown: { req: null; res: Msg }; + HandoverArm: { req: HandoverArm; res: HandoverArmed }; Interfaces: { req: null; res: Array }; Events: { req: null; res: Event }; InFlight: { req: null; res: InFlight }; diff --git a/src/overview/useLive.ts b/src/overview/useLive.ts index 34226a99..b35a97ce 100644 --- a/src/overview/useLive.ts +++ b/src/overview/useLive.ts @@ -1,11 +1,18 @@ import { useEffect, useReducer, useRef } from "react"; +import { invoke } from "@tauri-apps/api/core"; import { call } from "@/control"; import { subscribe } from "@/lib/tauriEvent"; import { isRunning, parseCoreState } from "@/coreState"; import { textOf } from "@/i18n"; -import type { CoreEvent, HistoryRow } from "@/types"; +import type { CoreEvent, CoreStatus, HistoryRow } from "@/types"; import { liveText } from "./useLive.i18n"; +/** + * 升级之后旧 core 还在排空时,隔多久问一次它还剩几个请求(见 `useInFlight`)。旧 core 结束 + * 一个请求,这边没有事件;排空通常几十秒到几分钟,两秒一问,数字跟得上又不密 + */ +export const DRAINING_POLL_MS = 2_000; + /** * 实时档一格多宽。十秒:十分钟铺六十格。 * @@ -198,14 +205,38 @@ function useFrame(): [() => void, () => void] { * * **别拿 `Status::in_flight`**:那个从连接进数据面就算,鉴权失败、排队的都在 * 里面。 + * + * **升级之后还在排空的旧 core 手上的也算进来**(`Status::draining`),不单独提示。它们 + * 不在事件流上(旧 core 的事件不到这边),所以对账时问一次 `/status`,还有剩的就每隔 + * {@link DRAINING_POLL_MS} 再问,直到归零。 */ export function useInFlight(): number { const flying = useRef(new Set()); + const draining = useRef(0); const [, soon] = useFrame(); useEffect(() => { let alive = true; let since: { started: Set; ended: Set } | null = null; + let drainTimer: ReturnType | null = null; + const stopDraining = () => { + if (drainTimer) clearTimeout(drainTimer); + drainTimer = null; + }; + const askDraining = async () => { + stopDraining(); + try { + const s = await invoke("core_status"); + if (!alive) return; + if (s.draining !== draining.current) { + draining.current = s.draining; + soon(); + } + if (s.draining > 0) drainTimer = setTimeout(() => void askDraining(), DRAINING_POLL_MS); + } catch { + // 问不到就当没有:core 回来的时候(`running:`)还会再问 + } + }; const start = (id: number) => { flying.current.add(id); since?.started.add(id); @@ -250,6 +281,7 @@ export function useInFlight(): number { } finally { if (since === mark) since = null; } + void askDraining(); }; void resync(); /* @@ -262,17 +294,20 @@ export function useInFlight(): number { } else { since = null; flying.current.clear(); + stopDraining(); + draining.current = 0; soon(); } }); return () => { alive = false; + stopDraining(); un(); unState(); }; }, [soon]); - return flying.current.size; + return flying.current.size + draining.current; } /** diff --git a/src/shell/Pages.tsx b/src/shell/Pages.tsx index c689f539..d4ddbd6a 100644 --- a/src/shell/Pages.tsx +++ b/src/shell/Pages.tsx @@ -282,6 +282,7 @@ export function Pages({ Date: Sat, 24 Oct 2026 14:48:54 +0800 Subject: [PATCH 2/4] Seamless upgrades: follow the core's own takeover after a broken config, translate the handover codes When the updated core cannot read the configuration it now takes over only the control plane and finishes the takeover by itself once the file is fixed. The safe-mode page says the gateway keeps forwarding on the previous configuration instead of claiming it is not running. Co-Authored-By: Claude Opus 5.5 --- src/connection/Unlinked.tsx | 12 ++++++++---- src/connection/connection.i18n.tsx | 9 +++++++-- src/i18n/core.zh.json | 6 ++++++ 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/connection/Unlinked.tsx b/src/connection/Unlinked.tsx index f97a4b64..aa2eabe9 100644 --- a/src/connection/Unlinked.tsx +++ b/src/connection/Unlinked.tsx @@ -216,9 +216,13 @@ export function ConfigBroken({ const [restarting, run] = usePending(); const stage = stageLabel(rejection.stage); return ( - } tone="error" title={t.configBroken} status={t.safeMode}> -

{t.configBrokenLead}

- {held &&

{t.configBrokenHeld}

} + } + tone={held ? "warn" : "error"} + title={held ? t.configBrokenHeldTitle : t.configBroken} + status={t.safeMode} + > +

{held ? t.configBrokenHeld : t.configBrokenLead}

-

{t.configBrokenResume}

+

{held ? t.configBrokenHeldResume : t.configBrokenResume}

); } diff --git a/src/connection/connection.i18n.tsx b/src/connection/connection.i18n.tsx index 3e41c54a..4d2415c5 100644 --- a/src/connection/connection.i18n.tsx +++ b/src/connection/connection.i18n.tsx @@ -174,7 +174,10 @@ export const connText = messages( restoreFromBackup: "从备份恢复…", configBrokenResume: "保存修正后的配置后,网关自动恢复。", /** 升级之后新 core 读不了配置:网关还在旧 core 上按更新前的配置转发 */ - configBrokenHeld: "网关暂按更新前的配置运行,配置修复后自动切换。", + configBrokenHeldTitle: "更新后的配置文件有误", + configBrokenHeld: + "新版本无法读取配置文件。网关暂按更新前的配置运行,客户端的请求照常转发;修正配置文件或从备份恢复可用的版本后,自动切换到新版本。", + configBrokenHeldResume: "保存修正后的配置后,自动切换到新版本。", // 断线横幅 lostBanner: (name: string, attempt: number) => @@ -330,8 +333,10 @@ export const connText = messages( openConfigFile: "Open configuration file", restoreFromBackup: "Restore from backup…", configBrokenResume: "Once a corrected configuration is saved, the gateway resumes on its own.", + configBrokenHeldTitle: "The configuration file has an error after the update", configBrokenHeld: - "The gateway runs with the configuration from before the update and switches over once the configuration is fixed.", + "The new version cannot read the configuration file. The gateway runs with the configuration from before the update and keeps forwarding client requests; fix the file or restore a working version from the backups to switch to the new version.", + configBrokenHeldResume: "Once a corrected configuration is saved, the new version takes over on its own.", lostBanner: (name: string, attempt: number) => attempt > 0 diff --git a/src/i18n/core.zh.json b/src/i18n/core.zh.json index 51e0f101..4fb7d4c2 100644 --- a/src/i18n/core.zh.json +++ b/src/i18n/core.zh.json @@ -427,6 +427,7 @@ "gw.oauth.status": "令牌端点返回 {status}:{body}", "gw.oauth.no_token": "令牌端点的响应中没有 access_token。", "gw.oauth.expired": "OAuth 凭据已过期,请重新登录或更换 refresh token。令牌端点返回 {status}:{body}", + "gw.oauth.held": "网关正在更新,登录令牌暂不续期。请稍后重试该请求。", "gw.oauth.backoff.unreachable": "上一次刷新令牌失败,{secs} 秒后重试:无法连接令牌端点 {endpoint}:{detail}", "gw.oauth.backoff.status": "上一次刷新令牌失败,{secs} 秒后重试:令牌端点返回 {status}:{body}", "gw.oauth.backoff.no_token": "上一次刷新令牌失败,{secs} 秒后重试:令牌端点的响应中没有 access_token。", @@ -578,6 +579,11 @@ "control.name_reserved": "{kind:kind}名称不能以 {prefix} 开头,该前缀保留给内置项。", "control.name_is_builtin": "「{name}」是内置选项的名称,请使用其他名称。", "control.shutdown": "网关正在关闭。", + "control.handover.busy": "账号登录或另一次交接正在进行,完成后再试。", + "control.handover.endpoint_failed": "无法打开交接端点:{detail}", + "control.handover.login_refused": "网关即将更新,请在更新完成后重新登录。", + "control.handover.remote": "不能经远程连接准备交接。", + "control.handover.safe_mode": "网关处于安全模式、没有在转发,无需交接。", "control.no_such_endpoint": "控制面没有 {method} {path} 端点,桌面应用与网关的版本可能不一致。", "control.method_not_allowed": "控制面不接受对 {path} 的 {method} 请求,桌面应用与网关的版本可能不一致。", "control.request_rejected": "控制面无法读取该请求:{detail}", From 00d10e4fc0c0f58c7f9e596f541549907f0d0143 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:52:15 +0800 Subject: [PATCH 3/4] Pin core v0.82.0 Co-Authored-By: Claude Opus 5.5 --- scripts/shots/core/en/status.json | 4 ++-- scripts/shots/core/oracle.rs | 1 + scripts/shots/core/zh/status.json | 4 ++-- src-tauri/Cargo.lock | 28 ++++++++++++++-------------- src-tauri/Cargo.toml | 12 ++++++------ 5 files changed, 25 insertions(+), 24 deletions(-) diff --git a/scripts/shots/core/en/status.json b/scripts/shots/core/en/status.json index 1919bdd1..588cd118 100644 --- a/scripts/shots/core/en/status.json +++ b/scripts/shots/core/en/status.json @@ -1,5 +1,5 @@ { - "api_version": 51, + "api_version": 52, "clients": 4, "config_path": "", "draining": 0, @@ -16,5 +16,5 @@ "reachable": [] }, "uptime_secs": 0, - "version": "0.79.0" + "version": "0.82.0" } diff --git a/scripts/shots/core/oracle.rs b/scripts/shots/core/oracle.rs index 0b3c549d..746b4ed1 100644 --- a/scripts/shots/core/oracle.rs +++ b/scripts/shots/core/oracle.rs @@ -56,6 +56,7 @@ async fn dump() { price_updater: Default::default(), chatgpt: Default::default(), zai: Default::default(), + handover: Default::default(), }); let write = |name: &str, v: &serde_json::Value| { diff --git a/scripts/shots/core/zh/status.json b/scripts/shots/core/zh/status.json index 1919bdd1..588cd118 100644 --- a/scripts/shots/core/zh/status.json +++ b/scripts/shots/core/zh/status.json @@ -1,5 +1,5 @@ { - "api_version": 51, + "api_version": 52, "clients": 4, "config_path": "", "draining": 0, @@ -16,5 +16,5 @@ "reachable": [] }, "uptime_secs": 0, - "version": "0.79.0" + "version": "0.82.0" } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 135edc35..b2a256a6 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5338,8 +5338,8 @@ dependencies = [ [[package]] name = "tw-api" -version = "0.81.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.81.0#aae0e486da837c94189046a7bcf5095d4eb1948f" +version = "0.82.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.82.0#7e7ca2e3a081ff27afde9dd37e450f5791bd7912" dependencies = [ "serde", "serde_json", @@ -5350,8 +5350,8 @@ dependencies = [ [[package]] name = "tw-dialect" -version = "0.81.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.81.0#aae0e486da837c94189046a7bcf5095d4eb1948f" +version = "0.82.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.82.0#7e7ca2e3a081ff27afde9dd37e450f5791bd7912" dependencies = [ "memchr", "serde", @@ -5360,8 +5360,8 @@ dependencies = [ [[package]] name = "tw-guard" -version = "0.81.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.81.0#aae0e486da837c94189046a7bcf5095d4eb1948f" +version = "0.82.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.82.0#7e7ca2e3a081ff27afde9dd37e450f5791bd7912" dependencies = [ "aho-corasick", "base64 0.22.1", @@ -5379,8 +5379,8 @@ dependencies = [ [[package]] name = "tw-link" -version = "0.81.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.81.0#aae0e486da837c94189046a7bcf5095d4eb1948f" +version = "0.82.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.82.0#7e7ca2e3a081ff27afde9dd37e450f5791bd7912" dependencies = [ "serde", "serde_json", @@ -5406,8 +5406,8 @@ dependencies = [ [[package]] name = "tw-types" -version = "0.81.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.81.0#aae0e486da837c94189046a7bcf5095d4eb1948f" +version = "0.82.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.82.0#7e7ca2e3a081ff27afde9dd37e450f5791bd7912" dependencies = [ "serde", "ts-rs", @@ -5415,8 +5415,8 @@ dependencies = [ [[package]] name = "tw-watch" -version = "0.81.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.81.0#aae0e486da837c94189046a7bcf5095d4eb1948f" +version = "0.82.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.82.0#7e7ca2e3a081ff27afde9dd37e450f5791bd7912" dependencies = [ "libc", "notify", @@ -5426,8 +5426,8 @@ dependencies = [ [[package]] name = "tw-yaml" -version = "0.81.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.81.0#aae0e486da837c94189046a7bcf5095d4eb1948f" +version = "0.82.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.82.0#7e7ca2e3a081ff27afde9dd37e450f5791bd7912" dependencies = [ "saphyr-parser", "thiserror 2.0.21", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 37011f06..f39d405e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,12 +26,12 @@ license = "MIT" # twcore 二进制必须和这里编译进去的协议镜像来自同一个 core 版本 —— 打包脚本正是 # 从 tw-api 锁到的 tag 去取二进制的(见 scripts/fetch-core.sh)。 [workspace.dependencies] -tw-api = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.81.0" } -tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.81.0" } -tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.81.0" } -tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.81.0" } -tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.81.0" } -tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.81.0" } +tw-api = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.82.0" } +tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.82.0" } +tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.82.0" } +tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.82.0" } +tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.82.0" } +tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.82.0" } [lib] name = "thinkwatch_lite_lib" From d44839d5ceb7e9402c6282d192f43d84e717a21c Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:11:08 +0800 Subject: [PATCH 4/4] Core cache: open the copy writable before syncing it (Windows refuses a flush on a read-only handle) Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/handover/core_cache.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/handover/core_cache.rs b/src-tauri/src/handover/core_cache.rs index 7e5f1037..55f5e11f 100644 --- a/src-tauri/src/handover/core_cache.rs +++ b/src-tauri/src/handover/core_cache.rs @@ -43,7 +43,11 @@ pub fn stage(src: &Path, data: &Path, version: &str) -> std::io::Result let tmp = slot.join(format!(".{}.partial", file.to_string_lossy())); // `copy` 连权限一起带过去:包里的 twcore 本来就是可执行的 std::fs::copy(src, &tmp)?; - std::fs::File::open(&tmp)?.sync_all()?; + // **可写地打开**:Windows 上刷盘(FlushFileBuffers)要写权限,只读打开会被拒 + std::fs::OpenOptions::new() + .write(true) + .open(&tmp)? + .sync_all()?; std::fs::rename(&tmp, &target)?; } touch(&slot);