diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 42c90db6..2eb05d07 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -131,6 +131,12 @@ windows-sys = { version = "0.61", features = [ "Win32_System_Environment", # explorer.exe 按 Windows 目录拼全路径,不按 PATH 找(`clients::explorer`) "Win32_System_SystemInformation", + # 启动时看 Alt 键有没有按着:按着就先显示连接选择(`connection::launch`) + "Win32_UI_Input_KeyboardAndMouse", + # 数据目录建成只有自己能读(受保护的 DACL),见 `private_dir` + "Win32_Security", + "Win32_Security_Authorization", + "Win32_Storage_FileSystem", ] } # 系统通知走 WinRT 的 toast:能原地更新、撤回、点开落到对应页面(见 # `notices/windows/`)。版本跟着 Tauri 在 Windows 上已经拉进来的那一份,不多编一套 diff --git a/src-tauri/build.rs b/src-tauri/build.rs index d860e1e6..30c674da 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,32 @@ fn main() { + core_tag(); tauri_build::build() } + +/// 这一版应用配的是哪一版 core:`Cargo.lock` 里 tw-api 锁到的 tag,去掉开头的 `v`。 +/// +/// 连远程 core 时版本对不上,界面要说出「本应用需要 core X」。**从锁文件读,不另写 +/// 一份**:打包脚本取 twcore 看的也是这一行(`scripts/fetch-core.sh`),两处说的 +/// 必然是同一版。读不到(临时用 `[patch]` 指向本地 core 时就是这样)记成 `dev` +fn core_tag() { + println!("cargo:rerun-if-changed=Cargo.lock"); + let lock = std::fs::read_to_string("Cargo.lock").unwrap_or_default(); + let mut in_api = false; + let mut tag = None; + for line in lock.lines() { + if line.starts_with("name = ") { + in_api = line == "name = \"tw-api\""; + } else if in_api + && line.starts_with("source = ") + && let Some(i) = line.find("tag=v") + { + let rest = &line[i + "tag=v".len()..]; + let end = rest.find(['#', '&', '"']).unwrap_or(rest.len()); + tag = Some(rest[..end].to_string()); + } + } + println!( + "cargo:rustc-env=TW_CORE_TAG={}", + tag.unwrap_or_else(|| "dev".into()) + ); +} diff --git a/src-tauri/capabilities/picker.json b/src-tauri/capabilities/picker.json new file mode 100644 index 00000000..4391769e --- /dev/null +++ b/src-tauri/capabilities/picker.json @@ -0,0 +1,13 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "picker", + "description": "启动时的连接选择。按内容定好高度(picker_fit,走 Rust)之后自己亮出来、拿焦点;选好之后由 Rust 关掉它(pick_connection)。别的都不需要。", + "windows": [ + "picker" + ], + "permissions": [ + "core:default", + "core:window:allow-show", + "core:window:allow-set-focus" + ] +} diff --git a/src-tauri/src/clients.rs b/src-tauri/src/clients.rs index f03d715f..897b1bda 100644 --- a/src-tauri/src/clients.rs +++ b/src-tauri/src/clients.rs @@ -187,3 +187,50 @@ fn reveal(path: &str) -> Result<(), String> { ) .to_string()) } + +/// 这台机器上已接管、还指着本机网关的客户端。切到远程之前的确认里说(「已接管的 3 个 +/// 客户端仍指向本机网关 127.0.0.1:8788」) +#[derive(Debug, Clone, Default, serde::Serialize)] +pub struct Adopted { + pub count: usize, + /// 本机网关此刻的地址 + pub local_addr: Option, +} + +/// 数一数这台机器上已接管、还指着本机网关的客户端。**数不出来就当没有**:确认框里 +/// 少说一条提醒,不该挡住切换。 +/// +/// TODO(P3):客户端接管整块搬进 lite 之后,改成问 lite 自己的扫描;现在还是问本机 +/// core 的 `/clients`(`control` 必须是本机那一个,见 `Supervisor::control`)。 +pub async fn adopted_pointing_at_local(control: &crate::control::ControlClient) -> Adopted { + let (clients, status) = tokio::join!(control.call::(&[], &()), control.status()); + let local_addr = status.ok().and_then(|s| s.gateway_addr); + let count = match (&clients, &local_addr) { + (Ok(list), Some(addr)) => list + .clients + .iter() + .filter(|c| c.adopted_at_ms.is_some()) + .filter(|c| { + c.endpoint + .as_deref() + .is_none_or(|e| e.contains(addr.as_str())) + }) + .count(), + _ => 0, + }; + Adopted { count, local_addr } +} + +/// 把这台机器上已接管的客户端改为指向 `gateway_addr`(远程服务器的网关)。返回改了 +/// 几个。切换确认里勾了「同时将这些客户端改为指向…」时调。 +/// +/// TODO(P3):接管搬进 lite 之后在这里接上 —— 对每个已接管、指着本机网关的客户端, +/// 用 lite 侧的接管代码把端点换成 `http://{gateway_addr}`,密钥换成服务器上为它发放的 +/// 专用密钥。在那之前如实说做不到,不假装改过了。 +pub async fn retarget_adopted_clients( + app: &tauri::AppHandle, + gateway_addr: &str, +) -> anyhow::Result { + let _ = app; + anyhow::bail!("retargeting adopted clients to {gateway_addr} is not implemented yet (P3)") +} diff --git a/src-tauri/src/connection/connector.rs b/src-tauri/src/connection/connector.rs new file mode 100644 index 00000000..e4712957 --- /dev/null +++ b/src-tauri/src/connection/connector.rs @@ -0,0 +1,301 @@ +//! 连上一个 core:本机的,或者另一台机器上的。 +//! +//! **切换之前先试连**,试连和真正连上走的是同一个函数([`test`]),所以「试的时候 +//! 通了、切过去却连不上」只可能是这中间对面变了,不会是两段代码各说各的。 +//! +//! 失败分五种说([`ConnectError`]),每一种界面上都给出下一步:地址不通、被对面 +//! 关掉、密钥不对、版本不一致、超时。 +//! +//! # 远程那一档还没接上(P2) +//! +//! 远程控制面的握手(Noise,密钥来自服务器的 `listen.control.key`)由 core 的 +//! `tw-link` 提供,还没发版。这里先把 TCP 连上 —— 地址不通和超时现在就能如实说 —— +//! 握手那一步集中在 [`handshake`] 一个函数里,眼下一律回 [`ConnectError::NotYetAvailable`]。 +//! 接上 P2 时只改它(和删掉那一种错误)。 + +use std::time::Duration; + +use tokio::io::{AsyncRead, AsyncWrite}; + +use crate::control::{ControlClient, Stream, Target}; + +/// 远程的 core 在哪、拿什么进门。**密钥只在内存里**:存盘在 `secrets` 那个只有自己 +/// 能读的文件里,不进连接列表,也不交给界面 +#[derive(Clone)] +pub struct RemoteTarget { + pub host: String, + pub port: u16, + /// 服务器上 `twcore control-key` 给的那 64 位十六进制 + pub key: String, +} + +impl RemoteTarget { + /// `host:port`。IPv6 字面量加方括号 + pub fn addr(&self) -> String { + display_addr(&self.host, self.port) + } +} + +impl std::fmt::Debug for RemoteTarget { + /// **不打印密钥**:日志里出现的只有地址 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "RemoteTarget({})", self.addr()) + } +} + +pub fn display_addr(host: &str, port: u16) -> String { + if host.contains(':') && !host.starts_with('[') { + format!("[{host}]:{port}") + } else { + format!("{host}:{port}") + } +} + +/// 连上之后知道的:对面是哪一版 core,客户端该连它的哪个网关地址 +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct ServerInfo { + pub core_version: String, + /// 服务器的网关地址(安全模式下没有)。远程时侧栏上显示的就是它 —— 客户端要连的那个 + pub gateway_addr: Option, +} + +/// 连不上的原因。**界面按种类说话**(文案在前端的 `connection.i18n.tsx`),这里只 +/// 带事实:地址、两个版本号 +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ConnectError { + /// 地址不通:解析不出来、对面拒绝连接、网络不可达 + Unreachable { addr: String }, + /// TCP 连上了,对面一个字节不回就关掉。**多半是不在允许列表里**:core 对 + /// `allow_from` 之外的地址 accept 之后直接关,不向扫描者暴露自己是什么 + Closed { addr: String }, + /// 握手解不开:密钥和服务器的不一样 + WrongKey, + /// 握手通过了,两边的控制面版本不一样。`ours` 是这一版应用配的 core + VersionMismatch { ours: String, theirs: String }, + /// 在限定时间内没连上,或者握手没走完 + Timeout { addr: String }, + /// 这一版应用还不会远程握手。**P2 接上握手时删掉这一种** + NotYetAvailable, +} + +/// 一句话。**界面不用它**(界面按种类说,带下一步);它给的是经控制面客户端冒出来、 +/// 落进日志和个别错误提示里的那一句 +impl std::fmt::Display for ConnectError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + ConnectError::Unreachable { addr } => { + tr!( + format!("无法连接到 {addr}"), + format!("Cannot connect to {addr}") + ) + } + ConnectError::Closed { addr } => tr!( + format!("{addr} 关闭了连接"), + format!("{addr} closed the connection") + ), + ConnectError::WrongKey => tr!("密钥不正确", "The key is not correct").into(), + ConnectError::VersionMismatch { ours, theirs } => tr!( + format!("版本不一致:服务器 core {theirs},本应用需要 {ours}"), + format!("Version mismatch: the server runs core {theirs}; this app needs {ours}") + ), + ConnectError::Timeout { addr } => tr!( + format!("连接 {addr} 超时"), + format!("Connecting to {addr} timed out") + ), + ConnectError::NotYetAvailable => tr!( + "此版本尚不支持连接远程 core", + "This version cannot connect to a remote core yet" + ) + .into(), + }; + f.write_str(&s) + } +} + +impl std::error::Error for ConnectError {} + +/// 这一版应用配的是哪一版 core。**来自 `Cargo.lock` 里 tw-api 锁的 tag**,见 build.rs +pub const REQUIRED_CORE: &str = env!("TW_CORE_TAG"); + +/// 连一次 TCP、握手,最多等这么久 +const CONNECT_WITHIN: Duration = Duration::from_secs(5); +/// 本机那一档问一次 `/status` 最多等多久 +const LOCAL_WITHIN: Duration = Duration::from_secs(3); + +/// 握手的结果:对面说自己是哪一版 +#[derive(Debug, Clone)] +pub struct Handshake { + pub core_version: String, +} + +/// 试连:连上、握手、问一次 `/status`。**切换之前、启动时、断线重连都走它。** +pub async fn test(target: &Target) -> Result { + match target { + Target::Local { .. } => { + let c = ControlClient::to(target.clone()); + let s = tokio::time::timeout(LOCAL_WITHIN, c.status()) + .await + .map_err(|_| ConnectError::Timeout { + addr: tr!("本机", "This Mac").into(), + })? + .map_err(|_| ConnectError::Unreachable { + addr: tr!("本机", "This Mac").into(), + })?; + Ok(ServerInfo { + core_version: s.version, + gateway_addr: s.gateway_addr, + }) + } + Target::Remote(r) => { + let (_, hello) = open(r).await?; + // 握手过了,剩下的是一次普通的控制面请求。**失败按「连接被关闭」说**: + // 握手刚通过就断,是对面在这一瞬间没了 + let s = ControlClient::to(target.clone()) + .status() + .await + .map_err(|_| ConnectError::Closed { addr: r.addr() })?; + Ok(ServerInfo { + core_version: if s.version.is_empty() { + hello.core_version + } else { + s.version + }, + gateway_addr: s.gateway_addr, + }) + } + } +} + +/// 打开一条到远程控制面的流:TCP 连上,再握手。控制面客户端每个请求都走它 +pub(crate) async fn open(r: &RemoteTarget) -> Result<(Box, Handshake), ConnectError> { + let addr = r.addr(); + let tcp = tokio::time::timeout( + CONNECT_WITHIN, + tokio::net::TcpStream::connect((r.host.trim_matches(['[', ']']), r.port)), + ) + .await + .map_err(|_| ConnectError::Timeout { addr: addr.clone() })? + .map_err(|e| { + tracing::debug!("连不上 {addr}:{e}"); + match e.kind() { + std::io::ErrorKind::TimedOut => ConnectError::Timeout { addr: addr.clone() }, + _ => ConnectError::Unreachable { addr: addr.clone() }, + } + })?; + let _ = tcp.set_nodelay(true); + tokio::time::timeout(CONNECT_WITHIN, handshake(tcp, &r.key, &addr)) + .await + .map_err(|_| ConnectError::Timeout { addr })? +} + +/// **P2 在这里接上握手。** +/// +/// 要做的:用 `tw-link` 的 Noise 握手(`Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s`,PSK 是 +/// `key` 的 32 字节)把 `io` 包成加密的流,第一条消息带上应用的控制面版本和应用版本; +/// 按对面第二条消息的结果返回: +/// +/// - 解不开、收到明文拒绝字节 → [`ConnectError::WrongKey`] +/// - `accept=false`(版本不一致)→ [`ConnectError::VersionMismatch`],`ours` 用 +/// [`REQUIRED_CORE`],`theirs` 用对面报的 core 版本 +/// - 对面一个字节不回就关掉 → [`ConnectError::Closed`] +/// +/// 返回的流交给 hyper 跑 HTTP/1.1,事件流(SSE)也走它。 +async fn handshake( + io: S, + key: &str, + addr: &str, +) -> Result<(Box, Handshake), ConnectError> +where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ + let _ = (io, key); + tracing::debug!("{addr}:TCP 已连上,这一版还不会远程握手"); + Err(ConnectError::NotYetAvailable) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ipv6_addresses_get_brackets() { + assert_eq!(display_addr("192.168.1.20", 8789), "192.168.1.20:8789"); + assert_eq!(display_addr("::1", 8789), "[::1]:8789"); + assert_eq!(display_addr("[::1]", 8789), "[::1]:8789"); + assert_eq!(display_addr("nas.local", 1), "nas.local:1"); + } + + /// 日志里不能出现密钥 + #[test] + fn debug_output_leaves_the_key_out() { + let r = RemoteTarget { + host: "h".into(), + port: 1, + key: "secret-secret".into(), + }; + assert!(!format!("{r:?}").contains("secret")); + } + + #[test] + fn the_required_core_is_the_locked_tag() { + assert!(!REQUIRED_CORE.is_empty()); + assert!(!REQUIRED_CORE.starts_with('v'), "{REQUIRED_CORE}"); + } + + /// 错误按种类交给界面,字段是事实不是句子 + #[test] + fn errors_go_out_tagged_by_kind() { + let v = serde_json::to_value(ConnectError::VersionMismatch { + ours: "0.48.0".into(), + theirs: "0.47.2".into(), + }) + .unwrap(); + assert_eq!(v["kind"], "version_mismatch"); + assert_eq!(v["theirs"], "0.47.2"); + assert_eq!( + serde_json::to_value(ConnectError::WrongKey).unwrap()["kind"], + "wrong_key" + ); + } + + /// 没人听的端口:说地址不通(或者超时),**不会卡住** + #[tokio::test] + async fn a_port_nobody_listens_on_is_unreachable() { + let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = l.local_addr().unwrap().port(); + drop(l); + let r = RemoteTarget { + host: "127.0.0.1".into(), + port, + key: "k".into(), + }; + let e = test(&Target::Remote(r)).await.unwrap_err(); + assert!( + matches!( + e, + ConnectError::Unreachable { .. } | ConnectError::Timeout { .. } + ), + "{e:?}" + ); + } + + /// 有人听、握手还没接上:说「这一版还不支持」,不是别的 + #[tokio::test] + async fn a_listening_port_reaches_the_handshake_seam() { + let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = l.local_addr().unwrap().port(); + tokio::spawn(async move { + let _ = l.accept().await; + }); + let r = RemoteTarget { + host: "127.0.0.1".into(), + port, + key: "k".into(), + }; + assert_eq!( + test(&Target::Remote(r)).await.unwrap_err(), + ConnectError::NotYetAvailable + ); + } +} diff --git a/src-tauri/src/connection/launch.rs b/src-tauri/src/connection/launch.rs new file mode 100644 index 00000000..f0b73f87 --- /dev/null +++ b/src-tauri/src/connection/launch.rs @@ -0,0 +1,101 @@ +//! 启动时先不连、先问连哪个的两种情形:按住 ⌥ 启动,和上两次启动都没走到就绪。 +//! +//! **后一条是失败保护。**远程连不上时界面照样能开,但要是某个连接让应用一启动就出事 +//! (卡在启动画面、崩掉),用户每打开一次都会再撞一次。和 core 连续启动失败就进安全 +//! 模式是同一个思路:连着两次没走到就绪,第三次先让人选。 + +use std::path::Path; + +/// 记着「这次启动还没走到就绪」的文件,里面是连着几次了 +const FILE: &str = "launch-attempts"; + +/// 连着几次没走到就绪,下一次就先让人选 +pub const PICK_AFTER: u32 = 2; + +/// 为什么先显示连接选择 +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Why { + /// 按住了 ⌥(Windows、Linux 上是 Alt) + Option, + /// 上次启动没能走到就绪 + Unfinished, +} + +/// 这次启动开始了:读出之前连着几次没走到就绪,再把这一次记上。 +pub fn begin(dir: &Path) -> u32 { + let before = std::fs::read_to_string(dir.join(FILE)) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(0); + let _ = std::fs::create_dir_all(dir); + let _ = std::fs::write(dir.join(FILE), (before + 1).to_string()); + before +} + +/// 走到就绪了(连上了):清掉记号 +pub fn ready(dir: &Path) { + let _ = std::fs::remove_file(dir.join(FILE)); +} + +/// 这次要不要先让人选,为什么 +pub fn why(before: u32, option_held: bool) -> Option { + if option_held { + Some(Why::Option) + } else if before >= PICK_AFTER { + Some(Why::Unfinished) + } else { + None + } +} + +/// 启动这一刻 ⌥ 是不是按着。 +/// +/// macOS 问 `NSEvent.modifierFlags`,Windows 问 Alt 键的状态。**Linux 上说不出来**: +/// Wayland 不让一个还没有窗口的进程读键盘,那边只有失败保护这一条 +pub fn option_held() -> bool { + #[cfg(target_os = "macos")] + { + use objc2_app_kit::{NSEvent, NSEventModifierFlags}; + NSEvent::modifierFlags_class().contains(NSEventModifierFlags::Option) + } + #[cfg(windows)] + { + use windows_sys::Win32::UI::Input::KeyboardAndMouse::{GetAsyncKeyState, VK_MENU}; + // SAFETY: 只读一个键的状态。最高位是「此刻按着」 + (unsafe { GetAsyncKeyState(VK_MENU as i32) } as u16 & 0x8000) != 0 + } + #[cfg(not(any(target_os = "macos", windows)))] + { + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp(name: &str) -> std::path::PathBuf { + let p = std::env::temp_dir().join(format!("tw-launch-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&p); + std::fs::create_dir_all(&p).unwrap(); + p + } + + /// 连着两次没走到就绪,第三次先让人选;走到过一次就从头数 + #[test] + fn two_unfinished_launches_make_the_third_one_ask() { + let dir = tmp("guard"); + assert_eq!(why(begin(&dir), false), None); + assert_eq!(why(begin(&dir), false), None); + assert_eq!(why(begin(&dir), false), Some(Why::Unfinished)); + ready(&dir); + assert_eq!(why(begin(&dir), false), None); + } + + #[test] + fn holding_option_always_asks() { + assert_eq!(why(0, true), Some(Why::Option)); + assert_eq!(why(5, true), Some(Why::Option)); + } +} diff --git a/src-tauri/src/connection/mod.rs b/src-tauri/src/connection/mod.rs new file mode 100644 index 00000000..02c7b1be --- /dev/null +++ b/src-tauri/src/connection/mod.rs @@ -0,0 +1,890 @@ +//! 连哪个 core:本机的,或者另一台机器上的。 +//! +//! 应用同一时间只连一个 core。**几条原则,都来自同一种要避开的失败**:远程连不上, +//! 界面就初始化不了,只能重装或者把远程修好才能切回本机。 +//! +//! - 界面外壳不依赖 core。连接列表归应用自己存(`store`),密钥另存一个只有自己能读的 +//! 文件(`secrets`)。 +//! - 「本机」内置、删不掉,任何状态下一步就能切回来。 +//! - 先试连,再切换(`connector::test`)。失败就留在原来的连接上,说明原因。 +//! - **绝不悄悄退回本机。**连不上远程时不自动拉起本机的 core:两边配置不同,客户端又 +//! 指着服务器,用户会分不清请求走的是哪边。 +//! - 远程模式下本机 core 停掉(等在途请求结束),本机数据原样保留,切回本机时再拉起。 +//! - 断线只报一次:一条系统通知,界面上一条横幅;连上后按现有的对账机制补齐。 + +pub mod connector; +pub mod launch; +pub mod secrets; +pub mod store; + +use std::sync::Arc; +use std::time::Duration; + +use tauri::{Emitter, Manager}; +use tokio::sync::{Notify, watch}; + +use crate::control::Target; +use crate::error::{CmdError, Out}; +use crate::supervisor::CoreState; +use crate::{AppState, data_dir, notices}; +use connector::{ConnectError, RemoteTarget, ServerInfo}; +use store::{LOCAL, Remote}; + +/// 此刻的连接走到了哪一步。 +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum LinkState { + /// 连的是本机:状态看守护(`core-state`),这一层不另说 + Local, + /// 还没开始连:连接选择开着 + Waiting, + /// 正在连,第几次。`ever`:这次运行里连上过它 + Connecting { + attempt: u32, + ever: bool, + }, + Connected { + info: ServerInfo, + }, + /// 连不上,隔一会儿再试。`retry_in_ms` 从 `at_ms` 算起 + Down { + error: ConnectError, + attempt: u32, + at_ms: u64, + retry_in_ms: u64, + ever: bool, + }, +} + +/// 当前连接是哪一条 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Current { + Local, + Remote(Remote), +} + +impl Current { + fn id(&self) -> &str { + match self { + Current::Local => LOCAL, + Current::Remote(r) => &r.id, + } + } +} + +pub struct Link { + app: tauri::AppHandle, + /// 本机 core 的控制面在哪、凭据是什么。切回本机时控制面客户端指回这里 + local: Target, + current: std::sync::Mutex, + state: watch::Sender, + /// 「立即重试」 + retry: Arc, + /// 事件流断了(连着远程时) + lost: Arc, + /// 连远程的那条循环 + task: std::sync::Mutex>>, + /// 切换一次做完再做下一次。**停本机 core 的那一步也拿它**:切走之后又马上切 + /// 回来时,不能出现「刚拉起来的 core 被上一次切换停掉」 + switching: tokio::sync::Mutex<()>, +} + +/// 远程断线重连的间隔:第几次失败之后等多久 +fn backoff(attempt: u32) -> Duration { + const STEPS: [u64; 6] = [2, 4, 8, 15, 30, 60]; + Duration::from_secs(STEPS[(attempt.saturating_sub(1) as usize).min(STEPS.len() - 1)]) +} + +/// 连着远程时多久问一次它还在不在,连着几次不答算断线 +const PING_EVERY: Duration = Duration::from_secs(5); +const PING_FAILS: u32 = 3; + +impl Link { + pub fn new(app: tauri::AppHandle, local: Target, current: Current) -> Self { + Self { + app, + local, + current: std::sync::Mutex::new(current), + state: watch::channel(LinkState::Local).0, + retry: Arc::new(Notify::new()), + lost: Arc::new(Notify::new()), + task: std::sync::Mutex::new(None), + switching: tokio::sync::Mutex::new(()), + } + } + + pub fn state(&self) -> LinkState { + self.state.borrow().clone() + } + + pub fn watch(&self) -> watch::Receiver { + self.state.subscribe() + } + + pub fn current(&self) -> Current { + self.current.lock().expect("锁未中毒").clone() + } + + pub fn is_remote(&self) -> bool { + matches!(self.current(), Current::Remote(_)) + } + + fn set(&self, s: LinkState) { + self.state.send_replace(s); + announce(&self.app); + } + + /// 「立即重试」:连着远程时马上再连一次,不等退避 + pub fn retry_now(&self) { + self.retry.notify_one(); + } + + /// 事件流断了。连着远程时它就是断线的信号 + pub fn stream_lost(&self) { + if matches!(self.state(), LinkState::Connected { .. }) && self.is_remote() { + self.lost.notify_one(); + } + } + + fn stop_task(&self) { + if let Some(h) = self.task.lock().expect("锁未中毒").take() { + h.abort(); + } + } + + /// 启动时先让人选:什么都不连,等选好 + pub fn wait_for_pick(&self) { + self.set(LinkState::Waiting); + } +} + +/// 状态变了:界面、菜单栏都说一声 +fn announce(app: &tauri::AppHandle) { + let _ = app.emit("connection", view(app)); + emit_core_state(app); + if let Some(st) = app.try_state::() { + st.menubar_now.notify_one(); + } +} + +/// 界面认得的那个 core 状态字符串,**按当前连接说**。 +/// +/// 连本机时就是守护的状态。连远程时只有两种:连上了是 `running:0`(各处见到 +/// `running:` 就去取数、对账,和本机 core 重启回来走同一条路),没连上是 `unlinked`, +/// 细节在 `connection` 事件里 +pub fn describe_active(link: &LinkState, sup: &CoreState) -> String { + match link { + LinkState::Local => crate::gateway::describe_state(sup), + LinkState::Connected { .. } => "running:0".into(), + _ => "unlinked".into(), + } +} + +pub(crate) fn emit_core_state(app: &tauri::AppHandle) { + if let Some(st) = app.try_state::() { + let _ = app.emit( + "core-state", + describe_active(&st.link.state(), &st.supervisor.state()), + ); + } +} + +/// 守护换了状态。**连本机时本机 core 起来了就算走到了就绪**,见 `launch` +pub(crate) fn on_supervisor(app: &tauri::AppHandle, s: &CoreState) { + if let Some(st) = app.try_state::() + && !st.link.is_remote() + && matches!(s, CoreState::Running { .. }) + { + launch::ready(&data_dir()); + } +} + +// ------------------------------------------------------------------ 界面看到的 + +/// 连接列表里的一条。**没有密钥** +#[derive(Debug, Clone, serde::Serialize)] +pub struct ProfileView { + pub id: String, + pub name: String, + pub local: bool, + pub host: Option, + pub port: Option, + /// `host:port`,本机没有 + pub addr: Option, + pub last_connected_at: Option, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct ConnView { + /// 本机在最前,其余按添加的顺序 + pub profiles: Vec, + pub current: String, + /// 上次用的那一条。启动时连接选择里标「上次使用」 + pub last_used: String, + pub startup: store::Startup, + pub link: LinkState, + /// 这一版应用配的 core 版本 + pub required_core: &'static str, + /// 数据目录,「本机」那一行的说明 + pub data_dir: String, +} + +fn profile(r: &Remote) -> ProfileView { + ProfileView { + id: r.id.clone(), + name: r.name.clone(), + local: false, + host: Some(r.host.clone()), + port: Some(r.port), + addr: Some(connector::display_addr(&r.host, r.port)), + last_connected_at: r.last_connected_at, + } +} + +/// 数据目录写成用户认得的样子:家目录下的写成 `~/…` +fn home_relative(p: &std::path::Path) -> String { + #[cfg(unix)] + if let Some(home) = std::env::var_os("HOME") + && let Ok(rest) = p.strip_prefix(&home) + { + return format!("~/{}", rest.display()); + } + p.display().to_string() +} + +pub fn view(app: &tauri::AppHandle) -> ConnView { + let c = store::load(&data_dir()); + let mut profiles = vec![ProfileView { + id: LOCAL.into(), + name: tr!("本机", "This Mac").into(), + local: true, + host: None, + port: None, + addr: None, + last_connected_at: None, + }]; + profiles.extend(c.remotes.iter().map(profile)); + let (current, link) = match app.try_state::() { + Some(st) => (st.link.current().id().to_string(), st.link.state()), + None => (LOCAL.to_string(), LinkState::Local), + }; + ConnView { + profiles, + current, + last_used: c.last_used, + startup: c.startup, + link, + required_core: connector::REQUIRED_CORE, + data_dir: home_relative(&data_dir()), + } +} + +// ------------------------------------------------------------------ 启动与切换 + +/// 还在等连接选择(启动时先让人选、还没选) +pub fn waiting(app: &tauri::AppHandle) -> bool { + app.try_state::() + .is_some_and(|st| st.link.state() == LinkState::Waiting) +} + +/// 启动时连上选定的那一个。**不在这里等**:守护、远程连接都在后台,窗口照常打开 +pub fn start(app: &tauri::AppHandle, id: &str) { + let Some(st) = app.try_state::() else { + return; + }; + let c = store::load(&data_dir()); + match c.remote(id).cloned() { + Some(r) => begin_remote(app, &st.link, r, None), + None => begin_local(app, &st.link), + } +} + +/// 选定了连哪个:启动时的连接选择要是还开着,就收起来 —— 别处(菜单栏的「连接」 +/// 子菜单)已经替它选了 +fn close_picker(app: &tauri::AppHandle) { + if let Some(w) = app.get_webview_window(PICKER_WINDOW) { + let _ = w.destroy(); + } +} + +fn begin_local(app: &tauri::AppHandle, link: &Link) { + close_picker(app); + link.stop_task(); + *link.current.lock().expect("锁未中毒") = Current::Local; + if let Some(st) = app.try_state::() { + st.control.retarget(link.local.clone()); + } + link.set(LinkState::Local); + if let Some(n) = app.try_state::>() { + n.ingest(notices::rules::remote_back(), notices::now_ms()); + } + // 守护没在跑就接回来(远程模式下它停了)。在跑的话什么都不做 + crate::gateway::ensure_supervising(app); +} + +/// 开始连一个远程。`first`:切换时刚试连成功的结果,不再连第二遍 +fn begin_remote(app: &tauri::AppHandle, link: &Link, r: Remote, first: Option) { + close_picker(app); + link.stop_task(); + *link.current.lock().expect("锁未中毒") = Current::Remote(r.clone()); + let key = secrets::load(&data_dir(), &r.id).unwrap_or_default(); + let target = RemoteTarget { + host: r.host.clone(), + port: r.port, + key, + }; + if let Some(st) = app.try_state::() { + st.control.retarget(Target::Remote(target.clone())); + } + link.set(LinkState::Connecting { + attempt: 1, + ever: false, + }); + let app2 = app.clone(); + let h = tauri::async_runtime::spawn(async move { remote_loop(app2, r, target, first).await }); + *link.task.lock().expect("锁未中毒") = Some(h); +} + +/// 连着一个远程:连上、看着、断了再连。**不会自己退回本机** +async fn remote_loop( + app: tauri::AppHandle, + remote: Remote, + target: RemoteTarget, + mut first: Option, +) { + let Some(st) = app.try_state::() else { + return; + }; + let link = &st.link; + let mut attempt = 0u32; + let mut ever = false; + loop { + let tried = match first.take() { + Some(info) => Ok(info), + None => { + attempt += 1; + link.set(LinkState::Connecting { attempt, ever }); + connector::test(&Target::Remote(target.clone())).await + } + }; + match tried { + Ok(info) => { + attempt = 0; + ever = true; + connected(&app, &remote.id); + link.set(LinkState::Connected { info }); + watch_until_lost(link, &st.control).await; + tracing::warn!("与 {} 的连接断开", remote.name); + if let Some(n) = app.try_state::>() { + n.ingest(notices::rules::remote_lost(&remote.name), notices::now_ms()); + } + } + Err(error) => { + let wait = backoff(attempt); + tracing::info!(?error, attempt, ?wait, "连不上 {}", remote.name); + link.set(LinkState::Down { + error, + attempt, + at_ms: notices::now_ms(), + retry_in_ms: wait.as_millis() as u64, + ever, + }); + tokio::select! { + _ = tokio::time::sleep(wait) => {} + _ = link.retry.notified() => {} + } + } + } + } +} + +/// 连着的时候看着它:事件流断了,或者连着几次问不到状态,就算断线 +async fn watch_until_lost(link: &Link, control: &crate::control::ControlClient) { + let mut fails = 0; + loop { + tokio::select! { + _ = link.lost.notified() => return, + _ = tokio::time::sleep(PING_EVERY) => { + match control.ping(Duration::from_secs(3)).await { + Ok(()) => fails = 0, + Err(_) => { + fails += 1; + if fails >= PING_FAILS { + return; + } + } + } + } + } + } +} + +/// 连上了:记下时刻、清掉断线那条提醒、这次启动算走到了就绪 +fn connected(app: &tauri::AppHandle, id: &str) { + let dir = data_dir(); + launch::ready(&dir); + let now = notices::now_ms(); + let id2 = id.to_string(); + if let Err(e) = store::update(&dir, |c| { + if let Some(r) = c.remotes.iter_mut().find(|r| r.id == id2) { + r.last_connected_at = Some(now); + } + }) { + tracing::warn!("连接列表写不进去:{e:#}"); + } + if let Some(n) = app.try_state::>() { + n.ingest(notices::rules::remote_back(), now); + } +} + +/// 切到另一个连接。 +/// +/// 切到本机:直接切,拉起本机 core。切到远程:**先试连**,通过了才提交 —— 没通过就 +/// 留在原来的连接上,把原因交给界面。提交之后本机 core 在手上的请求结束后停掉。 +pub async fn switch( + app: &tauri::AppHandle, + id: &str, + retarget_clients: bool, +) -> Result<(), SwitchError> { + let st = app.state::(); + let dir = data_dir(); + let c = store::load(&dir); + if id == LOCAL { + let _g = st.link.switching.lock().await; + begin_local(app, &st.link); + remember(&dir, LOCAL); + return Ok(()); + } + let r = c.remote(id).cloned().ok_or(SwitchError::Unknown)?; + let key = secrets::load(&dir, &r.id).map_err(|e| SwitchError::KeyUnreadable { + detail: format!("{e:#}"), + })?; + let target = RemoteTarget { + host: r.host.clone(), + port: r.port, + key, + }; + let info = connector::test(&Target::Remote(target)) + .await + .map_err(|error| SwitchError::Connect { error })?; + // 要改指向的话,趁本机 core 还在跑先把名单拿到 + let retarget = if retarget_clients { + info.gateway_addr.clone() + } else { + None + }; + { + let _g = st.link.switching.lock().await; + begin_remote(app, &st.link, r.clone(), Some(info)); + remember(&dir, &r.id); + } + if let Some(addr) = retarget { + match crate::clients::retarget_adopted_clients(app, &addr).await { + Ok(n) => tracing::info!(n, "已接管的客户端改为指向 {addr}"), + Err(e) => tracing::warn!("改指向没做成:{e:#}"), + } + } + stop_local_when_quiet(app.clone()); + Ok(()) +} + +fn remember(dir: &std::path::Path, id: &str) { + let id = id.to_string(); + if let Err(e) = store::update(dir, |c| c.last_used = id) { + tracing::warn!("连接列表写不进去:{e:#}"); + } +} + +/// 本机 core 在手上的请求结束后停掉,**并且守护不再拉它**。等的这段时间里又切回了 +/// 本机,就不停了 +fn stop_local_when_quiet(app: tauri::AppHandle) { + tauri::async_runtime::spawn(async move { + let Some(st) = app.try_state::() else { + return; + }; + if !matches!(st.supervisor.state(), CoreState::Running { .. }) { + // 没在跑(启动中、安全模式、起不来):直接停,不必等请求 + let _g = st.link.switching.lock().await; + if st.link.is_remote() { + st.supervisor.stop_and_wait(Duration::from_secs(5)).await; + } + return; + } + crate::updater::wait_for_quiet(st.supervisor.control(), |_| {}).await; + let _g = st.link.switching.lock().await; + if st.link.is_remote() { + tracing::info!("已切到远程,停掉本机 core"); + st.supervisor.stop_and_wait(Duration::from_secs(5)).await; + } + }); +} + +/// 切换没做成的原因 +#[derive(Debug, Clone, serde::Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum SwitchError { + /// 列表里没有这一条(别处刚删掉) + Unknown, + /// 保存的密钥读不出来 + KeyUnreadable { detail: String }, + /// 试连没通过 + Connect { error: ConnectError }, +} + +// ------------------------------------------------------------------ 命令 + +#[tauri::command] +pub fn connections(app: tauri::AppHandle) -> ConnView { + view(&app) +} + +#[tauri::command] +pub fn set_connection_startup(app: tauri::AppHandle, startup: store::Startup) -> Out { + store::update(&data_dir(), |c| c.startup = startup).map_err(saving)?; + announce(&app); + Ok(view(&app)) +} + +/// 对话框里填的 +#[derive(Debug, Clone, serde::Deserialize)] +pub struct ProfileInput { + /// 编辑时是那一条的 id,新建时没有 + pub id: Option, + pub name: String, + pub host: String, + pub port: u16, + /// 新建时必填;编辑时不填就是沿用已经保存的那一把 + pub key: Option, +} + +/// 对话框里的一项不对。`field` 是哪一格 +#[derive(Debug, Clone, serde::Serialize)] +pub struct Invalid { + pub field: &'static str, + pub reason: &'static str, +} + +fn check(c: &store::Connections, p: &ProfileInput) -> Result<(), Invalid> { + let bad = |field, reason| Err(Invalid { field, reason }); + if p.name.trim().is_empty() { + return bad("name", "empty"); + } + if c.name_taken(&p.name, p.id.as_deref()) { + return bad("name", "taken"); + } + let host = p.host.trim(); + if host.is_empty() || host.contains(char::is_whitespace) || host.contains('/') { + return bad("host", "invalid"); + } + if p.port == 0 { + return bad("port", "invalid"); + } + match &p.key { + Some(k) if secrets::normalize(k).is_none() => bad("key", "invalid"), + None if p.id.is_none() => bad("key", "empty"), + _ => Ok(()), + } +} + +/// 试连对话框里填的这一条。**还没存**:编辑时没换密钥,就用已经保存的那一把 +#[tauri::command] +pub async fn test_connection(input: ProfileInput) -> Out { + let key = match (&input.key, &input.id) { + (Some(k), _) => secrets::normalize(k).unwrap_or_default(), + (None, Some(id)) => secrets::load(&data_dir(), id).map_err(|e| key_error(&e))?, + (None, None) => String::new(), + }; + let target = RemoteTarget { + host: input.host.trim().to_string(), + port: input.port, + key, + }; + Ok(match connector::test(&Target::Remote(target)).await { + Ok(info) => Tested::Ok { info }, + Err(error) => Tested::Failed { error }, + }) +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(tag = "result", rename_all = "snake_case")] +pub enum Tested { + Ok { info: ServerInfo }, + Failed { error: ConnectError }, +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(tag = "result", rename_all = "snake_case")] +pub enum Saved { + Ok { profile: ProfileView }, + Invalid { invalid: Invalid }, +} + +/// 存一条连接:密钥先存好,名字和地址再进列表。**当前连着的那一条改了地址或 +/// 密钥,要重新连一次才生效** —— 这里顺手重连 +#[tauri::command] +pub async fn save_connection(app: tauri::AppHandle, input: ProfileInput) -> Out { + let dir = data_dir(); + let c = store::load(&dir); + if let Err(invalid) = check(&c, &input) { + return Ok(Saved::Invalid { invalid }); + } + let id = input.id.clone().unwrap_or_else(store::new_id); + if let Some(k) = input.key.as_deref().and_then(secrets::normalize) { + secrets::store(&dir, &id, &k).map_err(|e| key_error(&e))?; + } + let remote = Remote { + id: id.clone(), + name: input.name.trim().to_string(), + host: input.host.trim().to_string(), + port: input.port, + last_connected_at: c.remote(&id).and_then(|r| r.last_connected_at), + }; + let r2 = remote.clone(); + store::update(&dir, |c| { + match c.remotes.iter_mut().find(|r| r.id == r2.id) { + Some(old) => *old = r2, + None => c.remotes.push(r2), + } + }) + .map_err(saving)?; + let st = app.state::(); + if st.link.current().id() == id { + let _g = st.link.switching.lock().await; + begin_remote(&app, &st.link, remote.clone(), None); + } else { + announce(&app); + } + Ok(Saved::Ok { + profile: profile(&remote), + }) +} + +/// 删一条连接。**当前连着的删不掉**,要先切走;本机本来就删不掉 +#[tauri::command] +pub fn delete_connection(app: tauri::AppHandle, id: String) -> Out { + let st = app.state::(); + if id == LOCAL || st.link.current().id() == id { + return Err(CmdError::plain(tr!( + "当前连接不能删除,请先切换到其他连接。", + "The current connection cannot be deleted. Switch to another connection first." + ))); + } + store::update(&data_dir(), |c| { + c.remotes.retain(|r| r.id != id); + if c.last_used == id { + c.last_used = LOCAL.into(); + } + }) + .map_err(saving)?; + if let Err(e) = secrets::remove(&data_dir(), &id) { + tracing::warn!("保存的密钥没删掉:{e:#}"); + } + announce(&app); + Ok(view(&app)) +} + +/// 切过去之前要告诉用户的:这台机器上已接管、还指着本机网关的客户端 +#[tauri::command] +pub async fn switch_preflight(state: tauri::State<'_, AppState>) -> Out { + // 问本机的 core:已接管的客户端指着的是它。连着远程时它停着,数出来是 0 + Ok(crate::clients::adopted_pointing_at_local(state.supervisor.control()).await) +} + +#[tauri::command] +pub async fn switch_connection( + app: tauri::AppHandle, + id: String, + retarget_clients: bool, +) -> Result { + switch(&app, &id, retarget_clients).await?; + Ok(view(&app)) +} + +#[tauri::command] +pub fn retry_connection(state: tauri::State<'_, AppState>) { + state.link.retry_now(); +} + +fn saving(e: anyhow::Error) -> CmdError { + CmdError::plain(tr!( + format!("无法保存连接列表:{e:#}"), + format!("The connection list could not be saved: {e:#}") + )) +} + +fn key_error(e: &anyhow::Error) -> CmdError { + CmdError::plain(tr!( + format!("无法读写保存的密钥:{e:#}"), + format!("The saved key could not be read or written: {e:#}") + )) +} + +// ------------------------------------------------------------------ 连接选择 + +/// 连接选择的小窗口 +pub const PICKER_WINDOW: &str = "picker"; + +/// 启动时先显示连接选择。**不依赖 core 的任何数据**:列表来自应用自己存的那一份 +pub fn show_picker(app: &tauri::AppHandle, why: launch::Why) -> tauri::Result<()> { + let reason = match why { + launch::Why::Option => "option", + launch::Why::Unfinished => "unfinished", + }; + tauri::WebviewWindowBuilder::new(app, PICKER_WINDOW, tauri::WebviewUrl::default()) + .title(tr!("选择连接", "Choose a Connection")) + .initialization_script(format!( + "{} window.__TW_PICK__ = {:?};", + crate::i18n::init_script(), + reason + )) + .inner_size(PICKER_WIDTH, 300.0) + .resizable(false) + .minimizable(false) + .maximizable(false) + .center() + .visible(false) + .build()?; + Ok(()) +} + +pub(crate) const PICKER_WIDTH: f64 = 420.0; + +/// 连接选择里选好了:连它,打开主界面(`settings`:落到设置页的「连接」一节) +#[tauri::command] +pub fn pick_connection(app: tauri::AppHandle, id: String, then: Option) -> Out<()> { + remember(&data_dir(), &id); + start(&app, &id); + match then { + Some(view) => notices::open_view(&app, view), + None => { + crate::window::show_main_window(&app).map_err(|e| CmdError::plain(e.to_string()))? + } + } + Ok(()) +} + +#[tauri::command] +pub fn picker_fit(window: tauri::Window, height: f64) -> tauri::Result<()> { + crate::window::fit(&window, PICKER_WIDTH, height) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 交给界面的几种结果**序列化得出来**。内部标签的枚举里装一个字符串、或者装另一个 + /// 同样用 `kind` 当标签的枚举,serde 在运行时才报错 —— 界面收到的是一句「序列化失败」 + #[test] + fn results_for_the_interface_serialize() { + let e = ConnectError::Timeout { addr: "h:1".into() }; + let v = serde_json::to_value(SwitchError::Connect { error: e.clone() }).unwrap(); + assert_eq!(v["kind"], "connect"); + assert_eq!(v["error"]["kind"], "timeout"); + let v = serde_json::to_value(SwitchError::KeyUnreadable { detail: "x".into() }).unwrap(); + assert_eq!(v["detail"], "x"); + let v = serde_json::to_value(Tested::Failed { error: e }).unwrap(); + assert_eq!(v["result"], "failed"); + let v = serde_json::to_value(LinkState::Down { + error: ConnectError::WrongKey, + attempt: 2, + at_ms: 1, + retry_in_ms: 4000, + ever: true, + }) + .unwrap(); + assert_eq!(v["kind"], "down"); + assert_eq!(v["error"]["kind"], "wrong_key"); + let v = serde_json::to_value(Saved::Invalid { + invalid: Invalid { + field: "name", + reason: "taken", + }, + }) + .unwrap(); + assert_eq!(v["invalid"]["field"], "name"); + } + + #[test] + fn retries_back_off_and_stop_growing() { + assert_eq!(backoff(1), Duration::from_secs(2)); + assert_eq!(backoff(2), Duration::from_secs(4)); + assert_eq!(backoff(6), Duration::from_secs(60)); + assert_eq!(backoff(40), Duration::from_secs(60)); + assert_eq!(backoff(0), Duration::from_secs(2)); + } + + /// 连远程时界面只见到两种:连上了照「运行中」取数、对账;没连上是 `unlinked`, + /// **不会是守护的「已停止」** —— 本机 core 在远程模式下本来就停着 + #[test] + fn the_active_state_follows_the_connection() { + let stopped = CoreState::Stopped; + assert_eq!(describe_active(&LinkState::Local, &stopped), "stopped"); + let info = ServerInfo { + core_version: "0.48.0".into(), + gateway_addr: None, + }; + assert!(describe_active(&LinkState::Connected { info }, &stopped).starts_with("running:")); + let down = LinkState::Down { + error: ConnectError::WrongKey, + attempt: 1, + at_ms: 0, + retry_in_ms: 2000, + ever: false, + }; + assert_eq!(describe_active(&down, &stopped), "unlinked"); + assert_eq!(describe_active(&LinkState::Waiting, &stopped), "unlinked"); + } + + fn input( + id: Option<&str>, + name: &str, + host: &str, + port: u16, + key: Option<&str>, + ) -> ProfileInput { + ProfileInput { + id: id.map(Into::into), + name: name.into(), + host: host.into(), + port, + key: key.map(Into::into), + } + } + + #[test] + fn the_dialog_is_checked_field_by_field() { + let c = store::Connections::default(); + let key = "a".repeat(64); + let field = |p: ProfileInput| check(&c, &p).err().map(|i| (i.field, i.reason)); + assert_eq!( + field(input(None, "nas", "10.0.3.7", 8789, Some(&key))), + None + ); + assert_eq!( + field(input(None, " ", "h", 1, Some(&key))), + Some(("name", "empty")) + ); + assert_eq!( + field(input(None, "本机", "h", 1, Some(&key))), + Some(("name", "taken")) + ); + assert_eq!( + field(input(None, "n", "a b", 1, Some(&key))), + Some(("host", "invalid")) + ); + assert_eq!( + field(input(None, "n", "h", 0, Some(&key))), + Some(("port", "invalid")) + ); + assert_eq!( + field(input(None, "n", "h", 1, Some("short"))), + Some(("key", "invalid")) + ); + assert_eq!( + field(input(None, "n", "h", 1, None)), + Some(("key", "empty")) + ); + // 编辑时不填密钥就是沿用 + assert_eq!(field(input(Some("x"), "n", "h", 1, None)), None); + } +} diff --git a/src-tauri/src/connection/secrets.rs b/src-tauri/src/connection/secrets.rs new file mode 100644 index 00000000..f9bfa5f8 --- /dev/null +++ b/src-tauri/src/connection/secrets.rs @@ -0,0 +1,142 @@ +//! 远程连接的控制面密钥:存在这台电脑的数据目录里,一个只有自己能读的文件。 +//! +//! **不用系统钥匙串。**macOS 上钥匙串会为这一项弹框要登录密码(未签名的应用每次 +//! 更新之后还会再问),为一把随时能在服务器上重新抄一遍的密钥打断用户不值得;三个 +//! 平台各有一套钥匙串接口,也意味着三条各自出错、各自没人测的路。现在只有一条: +//! 数据目录(unix `0700`、Windows 受保护的 DACL,见 `crate::private_dir`)里的一个 +//! `0600` 文件 —— 和 config.yaml 里明文的上游 API key 是同一个保护等级。 +//! +//! **另放一个文件,不写进 `connections.json`**:那份列表时常被改写(上次连接的时刻、 +//! 上次用的是哪条),界面要的连接列表也从它来;密钥单独放,改列表的每一次写入都碰 +//! 不到它,读列表的代码也拿不到它。界面同样拿不到:添加、更换时从对话框递过来一次, +//! 之后再也不往回给 —— 编辑对话框里显示的是「已保存」。 + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +const FILE: &str = "connection-keys.json"; + +fn path(dir: &Path) -> PathBuf { + dir.join(FILE) +} + +/// 用户粘进来的那一串整理成密钥:去掉首尾空白、统一成小写。**不是 64 位十六进制就不收** +/// —— 服务器那边也只认这种,收下一把短的只会在连接时换来一句「密钥不正确」 +pub fn normalize(raw: &str) -> Option { + let k = raw.trim().to_ascii_lowercase(); + (k.len() == 64 && k.chars().all(|c| c.is_ascii_hexdigit())).then_some(k) +} + +/// 连接 id → 密钥。**文件不在就是空的**:还没添加过远程连接 +fn read_all(dir: &Path) -> anyhow::Result> { + match std::fs::read(path(dir)) { + Ok(b) => Ok(serde_json::from_slice(&b)?), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(BTreeMap::new()), + Err(e) => Err(e.into()), + } +} + +/// 整份写回。**从建出来那一刻就是 `0600`**:先写一个同样 `0600` 的临时文件,再改名 +/// 换上去 —— 不是写完再 `chmod`(中间有一个按 umask 谁都能读的窗口),写到一半断电 +/// 留下的也是旧的那份 +fn write_all(dir: &Path, keys: &BTreeMap) -> anyhow::Result<()> { + use std::io::Write; + crate::private_dir::create(dir)?; + let tmp = dir.join(format!("{FILE}.tmp")); + let _ = std::fs::remove_file(&tmp); + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + let mut f = opts.open(&tmp)?; + let mut text = serde_json::to_vec_pretty(keys)?; + text.push(b'\n'); + f.write_all(&text)?; + f.sync_all()?; + drop(f); + std::fs::rename(&tmp, path(dir))?; + Ok(()) +} + +pub fn store(dir: &Path, id: &str, key: &str) -> anyhow::Result<()> { + let mut all = read_all(dir)?; + all.insert(id.to_string(), key.to_string()); + write_all(dir, &all) +} + +pub fn load(dir: &Path, id: &str) -> anyhow::Result { + read_all(dir)? + .remove(id) + .ok_or_else(|| anyhow::anyhow!("no key is saved for connection {id}")) +} + +/// 删掉这一条。**本来就没有不算错** +pub fn remove(dir: &Path, id: &str) -> anyhow::Result<()> { + let mut all = read_all(dir)?; + if all.remove(id).is_some() { + write_all(dir, &all)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp(name: &str) -> PathBuf { + let p = std::env::temp_dir().join(format!("tw-secrets-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&p); + p + } + + #[test] + fn only_64_hex_characters_are_a_key() { + let k = "9F2C".repeat(16); + assert_eq!(normalize(&format!(" {k}\n")), Some(k.to_ascii_lowercase())); + assert_eq!(normalize("abc"), None); + assert_eq!(normalize(&"g".repeat(64)), None); + assert_eq!(normalize(&"a".repeat(65)), None); + } + + #[test] + fn keys_are_stored_per_connection_and_removed() { + let dir = tmp("trip"); + store(&dir, "a1", &"a".repeat(64)).unwrap(); + store(&dir, "b2", &"b".repeat(64)).unwrap(); + store(&dir, "a1", &"c".repeat(64)).unwrap(); + assert_eq!(load(&dir, "a1").unwrap(), "c".repeat(64)); + assert_eq!(load(&dir, "b2").unwrap(), "b".repeat(64)); + remove(&dir, "a1").unwrap(); + assert!(load(&dir, "a1").is_err()); + remove(&dir, "a1").unwrap(); + assert_eq!(load(&dir, "b2").unwrap(), "b".repeat(64)); + } + + /// 文件和(新建的)目录都只给自己 + #[cfg(unix)] + #[test] + fn the_file_is_private_from_the_start() { + use std::os::unix::fs::PermissionsExt; + let dir = tmp("mode").join("data"); + store(&dir, "a1", &"a".repeat(64)).unwrap(); + store(&dir, "a2", &"b".repeat(64)).unwrap(); + let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777; + assert_eq!(mode(&path(&dir)), 0o600); + assert_eq!(mode(&dir), 0o700); + assert!(!dir.join(format!("{FILE}.tmp")).exists()); + } + + /// 连接列表那份文件里没有密钥:两份分开放 + #[test] + fn the_list_file_never_sees_a_key() { + let dir = tmp("apart"); + store(&dir, "a1", &"e".repeat(64)).unwrap(); + crate::connection::store::save(&dir, &crate::connection::store::Connections::default()) + .unwrap(); + let list = std::fs::read_to_string(dir.join("connections.json")).unwrap(); + assert!(!list.contains(&"e".repeat(64))); + } +} diff --git a/src-tauri/src/connection/store.rs b/src-tauri/src/connection/store.rs new file mode 100644 index 00000000..d7228a06 --- /dev/null +++ b/src-tauri/src/connection/store.rs @@ -0,0 +1,222 @@ +//! 连接列表:存在应用自己的数据目录里,**不是网关的配置**。 +//! +//! 界面外壳不能依赖 core —— 远程连不上时,连接列表、切换入口照样要能显示、能操作, +//! 所以它归应用自己保存(和 `prefs.rs` 同一个目录,另一个文件)。**密钥不在这里**: +//! 另存一个只有自己能读的文件(`secrets.rs`),这个文件里只有名字和地址。 +//! +//! 「本机」不存:它是内置的、删不掉,列表里永远有它。 + +use std::path::{Path, PathBuf}; + +/// 设置文件,放在数据目录里 +const FILE: &str = "connections.json"; + +/// 「本机」的 id。**远程连接的 id 不会是它**:那些是随机生成的 +pub const LOCAL: &str = "local"; + +/// 一条远程连接 +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct Remote { + /// 随机生成,不随改名变。保存的密钥按它找 + pub id: String, + pub name: String, + /// 主机名或 IP + pub host: String, + /// 服务器配置里 `listen.control.remote.port` 的值 + pub port: u16, + /// 上一次连上是什么时候(毫秒)。没连上过是 None + pub last_connected_at: Option, +} + +/// 启动时连哪个 +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Startup { + /// 上次用的那个。**出厂是它**:以服务器为主的人不用每次再切一遍 + #[default] + Last, + /// 永远先连本机 + Local, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct Connections { + pub remotes: Vec, + /// 最近一次用的连接。切换成功才改它 + pub last_used: String, + pub startup: Startup, +} + +impl Default for Connections { + fn default() -> Self { + Self { + remotes: Vec::new(), + last_used: LOCAL.to_string(), + startup: Startup::Last, + } + } +} + +impl Connections { + pub fn remote(&self, id: &str) -> Option<&Remote> { + self.remotes.iter().find(|r| r.id == id) + } + + /// 这次启动该连哪个。上次用的那一条已经被删了,就是本机 + pub fn startup_target(&self) -> String { + match self.startup { + Startup::Local => LOCAL.to_string(), + Startup::Last if self.remote(&self.last_used).is_some() => self.last_used.clone(), + Startup::Last => LOCAL.to_string(), + } + } + + /// 名字有没有被别的连接用掉(大小写不分)。「本机」也算一个名字 + pub fn name_taken(&self, name: &str, except: Option<&str>) -> bool { + let n = name.trim().to_lowercase(); + let local_names = ["本机", "this mac", "this computer", "local"]; + local_names.contains(&n.as_str()) + || self + .remotes + .iter() + .any(|r| Some(r.id.as_str()) != except && r.name.trim().to_lowercase() == n) + } +} + +fn path(dir: &Path) -> PathBuf { + dir.join(FILE) +} + +/// 读列表。**读不出来就是只有本机**:文件不在(第一次运行)和文件坏了,对用户的意义 +/// 一样 —— 为一份列表让应用起不来,代价不对。密钥在另一个文件里,不会跟着丢 +pub fn load(dir: &Path) -> Connections { + std::fs::read(path(dir)) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()) + .unwrap_or_default() +} + +pub fn save(dir: &Path, c: &Connections) -> anyhow::Result<()> { + std::fs::create_dir_all(dir)?; + let mut text = serde_json::to_vec_pretty(c)?; + text.push(b'\n'); + // 先写临时文件再改名:写到一半断电,留下的是旧的那份而不是半份 + let tmp = dir.join(format!("{FILE}.tmp")); + std::fs::write(&tmp, text)?; + std::fs::rename(tmp, path(dir))?; + Ok(()) +} + +/// 改一处,其余照旧 +pub fn update(dir: &Path, f: impl FnOnce(&mut Connections) -> T) -> anyhow::Result { + let mut c = load(dir); + let out = f(&mut c); + save(dir, &c)?; + Ok(out) +} + +/// 新连接的 id:16 个十六进制字符 +pub fn new_id() -> String { + let mut b = [0u8; 8]; + rand::fill(&mut b); + b.iter().map(|x| format!("{x:02x}")).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp(name: &str) -> PathBuf { + let p = std::env::temp_dir().join(format!("tw-conns-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&p); + std::fs::create_dir_all(&p).unwrap(); + p + } + + fn remote(id: &str, name: &str) -> Remote { + Remote { + id: id.into(), + name: name.into(), + host: "192.168.1.20".into(), + port: 8789, + last_connected_at: None, + } + } + + #[test] + fn with_no_file_there_is_only_this_mac() { + let dir = tmp("none"); + let c = load(&dir); + assert!(c.remotes.is_empty()); + assert_eq!(c.startup_target(), LOCAL); + } + + #[test] + fn a_corrupt_file_falls_back_to_this_mac() { + let dir = tmp("corrupt"); + std::fs::write(path(&dir), b"{ nope").unwrap(); + assert_eq!(load(&dir), Connections::default()); + } + + #[test] + fn the_list_survives_a_round_trip() { + let dir = tmp("trip"); + let want = Connections { + remotes: vec![remote("a1", "home-server")], + last_used: "a1".into(), + startup: Startup::Last, + }; + save(&dir, &want).unwrap(); + assert_eq!(load(&dir), want); + } + + /// 出厂是「上次使用的连接」;上次那一条删掉了,就回到本机 + #[test] + fn startup_follows_the_last_used_connection_while_it_exists() { + let mut c = Connections { + remotes: vec![remote("a1", "home-server")], + last_used: "a1".into(), + startup: Startup::default(), + }; + assert_eq!(c.startup_target(), "a1"); + c.startup = Startup::Local; + assert_eq!(c.startup_target(), LOCAL); + c.startup = Startup::Last; + c.remotes.clear(); + assert_eq!(c.startup_target(), LOCAL); + } + + #[test] + fn names_are_unique_and_this_mac_is_taken() { + let c = Connections { + remotes: vec![remote("a1", "home-server")], + ..Default::default() + }; + assert!(c.name_taken("Home-Server ", None)); + assert!( + !c.name_taken("home-server", Some("a1")), + "改自己的名字不算重名" + ); + assert!(c.name_taken("本机", None)); + assert!(!c.name_taken("office-nas", None)); + } + + /// 密钥永远不进这个文件 + #[test] + fn the_file_has_no_key_field() { + let text = serde_json::to_string(&Connections { + remotes: vec![remote("a1", "x")], + ..Default::default() + }) + .unwrap(); + assert!(!text.contains("key"), "{text}"); + } + + #[test] + fn ids_are_random_and_never_local() { + let (a, b) = (new_id(), new_id()); + assert_eq!(a.len(), 16); + assert_ne!(a, b); + assert_ne!(a, LOCAL); + } +} diff --git a/src-tauri/src/control.rs b/src-tauri/src/control.rs index 14e72ff8..b779f56a 100644 --- a/src-tauri/src/control.rs +++ b/src-tauri/src/control.rs @@ -24,7 +24,10 @@ use tw_api::{Endpoint, Format, ep}; /// 两种传输各给一种,而**两种在所有平台上都编译**(只有 unix socket 那一支 /// 本身是 unix 专有的)。装箱是为了让上面那些函数只写一遍 —— 拿到流之后的 /// 每一件事,两边都该一模一样。 -trait Stream: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send {} +pub(crate) trait Stream: + tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send +{ +} impl Stream for T {} /// 控制面绑到了哪个端口。 @@ -65,15 +68,62 @@ fn refused(status: hyper::StatusCode, body: &[u8]) -> anyhow::Error { } } +/// 控制面在哪、凭什么进去。 +#[derive(Clone)] +pub enum Target { + /// 本机的 core:数据目录里的 socket(Windows 上是回环端口),凭据是这一次启动生成的 + Local { + at: tw_api::control::Address, + /// 这一次启动的凭据,见 `crate::token`。 + token: String, + }, + /// 另一台机器上的 core,见 `crate::connection` + Remote(crate::connection::connector::RemoteTarget), +} + +/// 控制面客户端。 +/// +/// **克隆出来的几份指着同一个地方。**应用同一时间只连一个 core,切换连接时 +/// [`ControlClient::retarget`] 换掉的是大家共用的那一份 —— 各页的命令、菜单栏、 +/// 事件桥手里拿的都是 `AppState.control` 的克隆,不用一个个去通知。守护和心跳 +/// 另外各建一份([`ControlClient::new`]),它们只管本机那个 core,切到远程也不跟着走。 +#[derive(Clone)] pub struct ControlClient { - at: tw_api::control::Address, - /// 这一次启动的凭据,见 `crate::token`。 - token: String, + target: std::sync::Arc>, + /// 换过几次地方。事件流靠它知道手上那条订阅已经连错了对象 + moved: std::sync::Arc>, } impl ControlClient { pub fn new(at: tw_api::control::Address, token: String) -> Self { - Self { at, token } + Self::to(Target::Local { at, token }) + } + + pub fn to(target: Target) -> Self { + Self { + target: std::sync::Arc::new(std::sync::RwLock::new(target)), + moved: std::sync::Arc::new(tokio::sync::watch::channel(0).0), + } + } + + /// 换一个 core 连。**所有克隆一起换**,正在订阅的事件流会被叫醒重连 + pub fn retarget(&self, target: Target) { + *self.target.write().expect("锁未中毒") = target; + self.moved.send_modify(|n| *n += 1); + } + + /// 连的是不是别的机器 + pub fn is_remote(&self) -> bool { + matches!(*self.target.read().expect("锁未中毒"), Target::Remote(_)) + } + + /// 换地方的通知 + pub fn moved(&self) -> tokio::sync::watch::Receiver { + self.moved.subscribe() + } + + fn target(&self) -> Target { + self.target.read().expect("锁未中毒").clone() } /// 连上控制面。 @@ -90,7 +140,17 @@ impl ControlClient { "core is not running, or has not finished starting" )) }; - match &self.at { + let at = match self.target() { + Target::Local { at, .. } => at, + // **远程那一档的门由握手把守**,见 `connection::connector` + Target::Remote(r) => { + return crate::connection::connector::open(&r) + .await + .map(|(stream, _)| stream) + .map_err(anyhow::Error::new); + } + }; + match &at { #[cfg(unix)] Address::Socket(path) => Ok(Box::new( tokio::net::UnixStream::connect(path) @@ -110,9 +170,12 @@ impl ControlClient { } } - /// 每个请求都带上的那个头。 + /// 每个请求都带上的那个头。**远程连接不带**:那边进门靠的是握手,不是这个头 fn auth(&self) -> String { - format!("Bearer {}", self.token) + match self.target() { + Target::Local { token, .. } => format!("Bearer {token}"), + Target::Remote(_) => String::new(), + } } /// 调一个端点。 diff --git a/src-tauri/src/gateway.rs b/src-tauri/src/gateway.rs index ba2b44d8..427b203a 100644 --- a/src-tauri/src/gateway.rs +++ b/src-tauri/src/gateway.rs @@ -185,11 +185,17 @@ pub async fn core_status(state: tauri::State<'_, AppState>) -> Out) -> Out { - // 连 core 都没找到时,守护状态说什么都没意义 —— 那句话才是答案 - if let Some(why) = &state.core_missing { + // 连 core 都没找到时,守护状态说什么都没意义 —— 那句话才是答案。**连着远程时 + // 不说这个**:用不着本机的 core + if let Some(why) = &state.core_missing + && !state.link.is_remote() + { return Ok(format!("missing:{why}")); } - Ok(describe_state(&state.supervisor.state())) + Ok(crate::connection::describe_active( + &state.link.state(), + &state.supervisor.state(), + )) } /// 把 core 拉起来。 @@ -206,6 +212,16 @@ pub async fn restart_core(app: tauri::AppHandle) -> Out<()> { pub(crate) async fn restart_gateway(app: &tauri::AppHandle) -> Out<()> { use std::sync::atomic::Ordering; let state = app.state::(); + // 启动时的连接选择还没选,就点了「重新启动网关」:那就是选了本机 + if crate::connection::waiting(app) { + crate::connection::start(app, crate::connection::store::LOCAL); + return Ok(()); + } + // **连着远程时不碰本机的 core**:「重新启动」在那时的意思是马上再连一次 + if state.link.is_remote() { + state.link.retry_now(); + return Ok(()); + } if let Some(why) = &state.core_missing { return Err(why.clone().into()); } @@ -222,6 +238,22 @@ pub(crate) async fn restart_gateway(app: &tauri::AppHandle) -> Out<()> { Ok(()) } +/// 守护没在跑就接回来;在跑就什么都不做。切回本机时用:远程模式下它停了 +pub(crate) fn ensure_supervising(app: &tauri::AppHandle) { + use std::sync::atomic::Ordering; + let state = app.state::(); + if state.core_missing.is_some() || state.supervising.swap(true, Ordering::SeqCst) { + return; + } + let sup = state.supervisor.clone(); + let flag = state.supervising.clone(); + let app = app.clone(); + tauri::async_runtime::spawn(async move { + supervise(sup, app).await; + flag.store(false, Ordering::SeqCst); + }); +} + /// 守护状态说成界面认得的那个字符串。 /// /// **只有这一处。**开窗时问一次、之后每次转换推一条,两条路必须说出 @@ -298,77 +330,110 @@ pub(crate) async fn heartbeat_loop( /// 断开的那一段里发生的事,事件流不会再说一遍 —— 界面要对一次账,否则那段时间 /// 里结束的请求,会一直显示成进行中。菜单栏的实时数是问 core 要的(`/live`), /// 叫醒它重收一次就对了。 -pub(crate) async fn bridge_events( - at: tw_api::control::Address, - token: String, - app: tauri::AppHandle, -) { +/// +/// **跟着当前连接走。**用的是 `AppState.control`:切换连接时它换了地方,这里手上那条 +/// 订阅随即作废、连新的那个。连着远程时事件流断了就是断线,告诉连接那一层 +pub(crate) async fn bridge_events(app: tauri::AppHandle) { + let Some(st) = app.try_state::() else { + return; + }; + let client = st.control.clone(); + let mut moved = client.moved(); + let mut link = st.link.watch(); let mut connected_before = false; loop { - let client = ControlClient::new(at.clone(), token.clone()); + moved.mark_unchanged(); + // **连着远程、还没连上时不去订阅**:连接那一层自己在按退避重连,这里再每秒 + // 连一次,就是往服务器上多打一份不带退避的连接。等它连上(或者换了连接) + while !matches!( + *link.borrow_and_update(), + crate::connection::LinkState::Local | crate::connection::LinkState::Connected { .. } + ) { + if link.changed().await.is_err() { + return; + } + } let opened = Arc::new(std::sync::atomic::AtomicBool::new(false)); let (a, b, o) = (app.clone(), app.clone(), opened.clone()); let resumed = connected_before; - let r = client - .subscribe_events( - move || { - o.store(true, std::sync::atomic::Ordering::SeqCst); - // **每次接上都按现状对一次账**:接上之前 core 报过的(启动时的凭据 - // 失效、断线那段里被拒的配置),事件流不会再说一遍 - reconcile_notices(&b); - if resumed { - let lost = tw_api::Event::EventsDropped { - id: 0, - count: 0, - at_ms: notices::now_ms(), - }; - if let Some(st) = b.try_state::() { - st.menubar.notify_one(); - } - let _ = b.emit("core-event", &lost); - } - }, - move |ev| { - // **在客户端弹批准提示的同一瞬间弹一条通知**。 - // 这是网关位置独有的能力:只有我们同时知道「这个调用长 - // 什么样」和「它来自哪个上游」。用户看到批准提示的同时 - // 看到这条,判断质量完全不一样。 - if let Some(n) = a.try_state::>() { - n.on_event(&ev); - } - // 菜单栏上那几个数只跟这几种事件有关:花了多少(请求 - // 落地之后存储层才算得出来)、额度还剩多少、进行中几个。 - // 别的事件叫醒它只是让它白跑一趟。**事件只是叫醒它,数 - // 由 core 给**(`/live`):掉过队的话(`EventsDropped`) - // 重收一次也就对上了 - if let Some(st) = a.try_state::() - && matches!( - ev, - tw_api::Event::RequestStarted { .. } - | tw_api::Event::RequestFinished { .. } - | tw_api::Event::RequestFailed { .. } - | tw_api::Event::RequestCancelled { .. } - | tw_api::Event::QuotaSeen { .. } - | tw_api::Event::ConfigReloaded { .. } - | tw_api::Event::EventsDropped { .. } - ) - { + let sub = client.subscribe_events( + move || { + o.store(true, std::sync::atomic::Ordering::SeqCst); + // **每次接上都按现状对一次账**:接上之前 core 报过的(启动时的凭据 + // 失效、断线那段里被拒的配置),事件流不会再说一遍 + reconcile_notices(&b); + if resumed { + let lost = tw_api::Event::EventsDropped { + id: 0, + count: 0, + at_ms: notices::now_ms(), + }; + if let Some(st) = b.try_state::() { st.menubar.notify_one(); } - // core 说这个订阅者掉过队:掉的那几条里可能有该提醒的事 - if matches!(ev, tw_api::Event::EventsDropped { .. }) { - reconcile_notices(&a); - } - let _ = a.emit("core-event", &ev); - }, - ) - .await; - if let Err(e) = r { - tracing::debug!("事件流断开:{e:#}"); + let _ = b.emit("core-event", &lost); + } + }, + move |ev| { + // **在客户端弹批准提示的同一瞬间弹一条通知**。 + // 这是网关位置独有的能力:只有我们同时知道「这个调用长 + // 什么样」和「它来自哪个上游」。用户看到批准提示的同时 + // 看到这条,判断质量完全不一样。 + if let Some(n) = a.try_state::>() { + n.on_event(&ev); + } + // 菜单栏上那几个数只跟这几种事件有关:花了多少(请求 + // 落地之后存储层才算得出来)、额度还剩多少、进行中几个。 + // 别的事件叫醒它只是让它白跑一趟。**事件只是叫醒它,数 + // 由 core 给**(`/live`):掉过队的话(`EventsDropped`) + // 重收一次也就对上了 + if let Some(st) = a.try_state::() + && matches!( + ev, + tw_api::Event::RequestStarted { .. } + | tw_api::Event::RequestFinished { .. } + | tw_api::Event::RequestFailed { .. } + | tw_api::Event::RequestCancelled { .. } + | tw_api::Event::QuotaSeen { .. } + | tw_api::Event::ConfigReloaded { .. } + | tw_api::Event::EventsDropped { .. } + ) + { + st.menubar.notify_one(); + } + // core 说这个订阅者掉过队:掉的那几条里可能有该提醒的事 + if matches!(ev, tw_api::Event::EventsDropped { .. }) { + reconcile_notices(&a); + } + let _ = a.emit("core-event", &ev); + }, + ); + let switched = tokio::select! { + r = sub => { + if let Err(e) = r { + tracing::debug!("事件流断开:{e:#}"); + } + false + } + _ = moved.changed() => true, + }; + let was_open = opened.load(std::sync::atomic::Ordering::SeqCst); + if was_open && !switched { + st.link.stream_lost(); + } + // 接通过才算连上过:core 重启期间那几轮连不上的不算。**换了连接从头算**: + // 新连上的那个 core 界面会整个重新取一遍,不用补报丢事件 + connected_before = if switched { + false + } else { + connected_before | was_open + }; + if !switched { + tokio::select! { + _ = tokio::time::sleep(std::time::Duration::from_secs(1)) => {} + _ = moved.changed() => {} + } } - // 接通过才算连上过:core 重启期间那几轮连不上的不算 - connected_before |= opened.load(std::sync::atomic::Ordering::SeqCst); - tokio::time::sleep(std::time::Duration::from_secs(1)).await; } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f535bf90..086e085e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,7 +6,7 @@ use std::path::PathBuf; use std::sync::Arc; -use tauri::{Emitter, Manager}; +use tauri::Manager; // 第一个声明:`tr!` 要在后面每个模块里都能用 #[macro_use] @@ -15,6 +15,7 @@ pub mod autostart; pub mod call; pub mod chatgpt; pub mod clients; +pub mod connection; pub mod control; pub mod core_text; pub mod dashboard; @@ -39,6 +40,8 @@ pub mod memcheck; pub mod menubar; pub mod notices; pub mod prefs; +/// 建只有自己能读的数据目录,见模块头上 +pub mod private_dir; pub mod settings; pub mod supervisor; pub mod theme; @@ -51,10 +54,7 @@ pub mod window; pub mod zai; use control::ControlClient; -use gateway::{ - CORE_EXE, bridge_events, control_address, describe_state, heartbeat_loop, locate_core, - supervise, -}; +use gateway::{CORE_EXE, bridge_events, control_address, heartbeat_loop, locate_core, supervise}; use settings::{check_autostart_path, maybe_notify_first_autostart}; use supervisor::Supervisor; use updater::{Updates, announce_update, update_loop}; @@ -63,7 +63,11 @@ use window::become_accessory; use window::{open_urls, show_main_window}; pub struct AppState { + /// 控制面客户端,**指着当前连接的那个 core**:本机的,或者远程的。切换连接时 + /// 由 `connection` 换掉它的目标,各处拿着的克隆一起跟着换 pub control: ControlClient, + /// 当前连哪个 core、连到了哪一步。见 `connection` + pub link: Arc, pub supervisor: Arc, /// 连 twcore 都没找到时,那句话。 /// @@ -155,6 +159,16 @@ pub fn run() { gateway::core_status, gateway::core_state, window::reveal_main_window, + connection::connections, + connection::set_connection_startup, + connection::test_connection, + connection::save_connection, + connection::delete_connection, + connection::switch_preflight, + connection::switch_connection, + connection::retry_connection, + connection::pick_connection, + connection::picker_fit, gateway::restart_core, dashboard::dashboard, upstreams::upstream_stats, @@ -280,8 +294,24 @@ pub fn run() { saved.notices, ); app.manage(notices.clone()); + // 这次连哪个。**按住 ⌥ 启动,或者上两次启动都没走到就绪**,先让人选 + let dir = data_dir(); + let attempts = connection::launch::begin(&dir); + let pick = connection::launch::why(attempts, connection::launch::option_held()); + let conns = connection::store::load(&dir); + let start_id = conns.startup_target(); + let local = control::Target::Local { + at: at.clone(), + token: token.clone(), + }; + let link = Arc::new(connection::Link::new( + handle.clone(), + local.clone(), + connection::Current::Local, + )); app.manage(AppState { - control: ControlClient::new(at.clone(), token.clone()), + control: ControlClient::to(local), + link: link.clone(), supervisor: sup.clone(), core_missing: located.as_ref().err().map(|e| format!("{e:#}")), supervising: supervising.clone(), @@ -303,14 +333,26 @@ pub fn run() { if let Some(n) = h.try_state::>() { n.on_core_state(&now); } - let _ = h.emit("core-state", describe_state(&now)); + connection::on_supervisor(&h, &now); + // 界面收到的是**当前连接**的状态:连着远程时本机 core 停下, + // 界面上不该出现「已停止」 + connection::emit_core_state(&h); } }); } // 守护循环。**它跑在后台任务里而不是阻塞 setup** —— core 起 // 不来的时候,界面必须还能打开,否则用户连错误都看不到。 - if located.is_ok() { + // + // **只在连本机时起。**连远程时本机的 core 不跑;要先让人选的话,选好了再起 + let remote_start = pick.is_some() || conns.remote(&start_id).is_some(); + if remote_start { + if pick.is_some() { + link.wait_for_pick(); + } else { + connection::start(&handle, &start_id); + } + } else if located.is_ok() { supervising.store(true, std::sync::atomic::Ordering::SeqCst); let h = handle.clone(); let sup_for_loop = sup.clone(); @@ -344,7 +386,12 @@ pub fn run() { // 静默启动:开机拉起来的时候屏幕上什么都不该出现, // 只有菜单栏多一个图标。**图标已经在上面建好了** —— 它不等 // core 就绪,否则用户开机后会有一段「到底启没启」的空白期。 - if autostart::launched_by_autostart(std::env::args()) { + if let Some(why) = pick { + // 连接选择先出来,主窗口等选好了再开。**开机自启时也出来**:只有 + // 连着两次没走到就绪才会走到这里,那时再悄悄撞一次不如问一句 + tracing::info!(?why, "启动时先显示连接选择"); + connection::show_picker(&handle, why)?; + } else if autostart::launched_by_autostart(std::env::args()) { tracing::info!("开机自启,不开窗口"); #[cfg(target_os = "macos")] become_accessory(&handle); @@ -389,9 +436,8 @@ pub fn run() { // 事件桥:控制面的 SSE → Tauri 事件 → 前端。 let h = handle.clone(); - let (at3, tok3) = (at.clone(), token.clone()); tauri::async_runtime::spawn(async move { - bridge_events(at3, tok3, h).await; + bridge_events(h).await; }); // 有没有新版本。**循环无条件起,开关在循环里读** —— 用户在 diff --git a/src-tauri/src/menubar/macos.rs b/src-tauri/src/menubar/macos.rs index 0e2c9001..1eee9e6f 100644 --- a/src-tauri/src/menubar/macos.rs +++ b/src-tauri/src/menubar/macos.rs @@ -412,6 +412,9 @@ fn fill_item(mtm: MainThreadMarker, ui: &mut Ui, item: &NSMenuItem, i: &super::m } }; for m in &i.submenu { + if m.sep_before { + sub.addItem(&NSMenuItem::separatorItem(mtm)); + } let one = NSMenuItem::new(mtm); one.setTitle(&NSString::from_str(&m.title)); one.setState(if m.checked { diff --git a/src-tauri/src/menubar/mod.rs b/src-tauri/src/menubar/mod.rs index 23d95a44..1a474238 100644 --- a/src-tauri/src/menubar/mod.rs +++ b/src-tauri/src/menubar/mod.rs @@ -249,7 +249,17 @@ struct Credits { async fn collect(app: &tauri::AppHandle, state: &AppState, credits: &mut Credits) -> Snapshot { let now_ms = notices::now_ms(); + let current = state.link.current(); + let remote = match ¤t { + crate::connection::Current::Remote(r) => Some(r.name.clone()), + crate::connection::Current::Local => None, + }; let gateway = match state.supervisor.state() { + // 连着远程:看的是那一条连接,本机的 core 本来就停着 + _ if remote.is_some() => match state.link.state() { + crate::connection::LinkState::Connected { .. } => Gateway::Running, + _ => Gateway::Unlinked, + }, _ if state.core_missing.is_some() => Gateway::Failed, CoreState::Running { .. } => Gateway::Running, CoreState::Starting | CoreState::Restarting { .. } => Gateway::Starting, @@ -264,6 +274,16 @@ async fn collect(app: &tauri::AppHandle, state: &AppState, credits: &mut Credits .is_some_and(|n| n.mode() != notices::Mode::Off), notices: notices.map(|n| unread(&n.list())).unwrap_or_default(), update: crate::updater::pending_update(app), + connections: crate::connection::view(app) + .profiles + .into_iter() + .map(|p| model::Connection { + current: p.id == current_id(¤t), + id: p.id, + name: p.name, + }) + .collect(), + remote, now_ms, gateway, ..Default::default() @@ -365,6 +385,13 @@ async fn collect(app: &tauri::AppHandle, state: &AppState, credits: &mut Credits snap } +fn current_id(c: &crate::connection::Current) -> &str { + match c { + crate::connection::Current::Local => crate::connection::store::LOCAL, + crate::connection::Current::Remote(r) => &r.id, + } +} + /// 未读的提醒,要紧的在前,同样要紧的新的在前 fn unread(list: &[notices::Notice]) -> Vec { let mut out: Vec<(model::NoticeLine, u64)> = list @@ -403,6 +430,11 @@ fn handle(app: &tauri::AppHandle, action: Action) { // 窗口可能是为这一下新建的,事件会错过:和落页一样存下来,界面挂上之后自己取 Action::AllNotices => notices::open_view(&app, "notices".to_string()), Action::InstallUpdate => crate::updater::show_pending_update(&app), + // 切到远程要先试连、再确认(确认框里要说哪些客户端还指着本机):交给主界面 + // 走和侧栏同一条路。切回本机不用确认,直接切 + Action::SwitchConnection(id) if id != crate::connection::store::LOCAL => { + notices::open_view(&app, format!("switch:{id}")) + } Action::Quit => quit(&app), other => { tauri::async_runtime::spawn(async move { background(&app, other).await }); @@ -432,6 +464,13 @@ async fn background(app: &tauri::AppHandle, action: Action) { Action::RestartGateway => crate::gateway::restart_gateway(app) .await .map_err(|e| e.to_string()), + Action::RetryConnection => { + st.link.retry_now(); + Ok(()) + } + Action::SwitchConnection(id) => crate::connection::switch(app, &id, false) + .await + .map_err(|e| format!("{e:?}")), Action::CheckUpdates => { check_updates(app).await; Ok(()) diff --git a/src-tauri/src/menubar/model.rs b/src-tauri/src/menubar/model.rs index 1179477e..9804798f 100644 --- a/src-tauri/src/menubar/model.rs +++ b/src-tauri/src/menubar/model.rs @@ -27,6 +27,8 @@ pub enum Gateway { Failed, /// 停下了,没有在重启 Stopped, + /// 连着远程 core,眼下连不上(正在重连) + Unlinked, } impl Gateway { @@ -61,9 +63,20 @@ pub struct Snapshot { pub undo_at_ms: Option, /// 查到了、还没装的新版本 pub update: Option, + /// 连接列表,本机在最前。「连接」子菜单照它列 + pub connections: Vec, + /// 连着远程时是那台服务器的名字。状态头写它,不写应用名 + pub remote: Option, pub now_ms: u64, } +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Connection { + pub id: String, + pub name: String, + pub current: bool, +} + #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct Today { pub requests: i64, @@ -145,6 +158,10 @@ pub enum Action { provider: String, }, RestartGateway, + /// 连着远程、连不上时:马上再连一次 + RetryConnection, + /// 切到这个连接。本机直接切;远程要先试连、确认,在主界面里做 + SwitchConnection(String), CheckUpdates, InstallUpdate, Quit, @@ -209,6 +226,8 @@ pub struct SubItem { pub title: String, pub checked: bool, pub action: Action, + /// 前面隔一条线 + pub sep_before: bool, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -435,6 +454,7 @@ fn state_text(g: &Gateway) -> &'static str { Gateway::SafeMode => tr!("未在转发", "Not Forwarding"), Gateway::Failed => tr!("无法启动", "Cannot Start"), Gateway::Stopped => tr!("未运行", "Not Running"), + Gateway::Unlinked => tr!("未连接", "Not Connected"), } } @@ -452,7 +472,21 @@ fn live_count(n: usize) -> String { fn rows(s: &Snapshot) -> Vec { let mut out = vec![header(s), Row::Separator]; if !s.gateway.running() { - if !matches!(s.gateway, Gateway::Starting) { + if s.gateway == Gateway::Unlinked { + out.push(Row::Item(Item::new( + "retry", + "arrow.clockwise", + tr!("立即重试", "Retry Now"), + Action::RetryConnection, + ))); + out.push(Row::Item(Item::new( + "why", + "info.circle", + tr!("查看原因…", "Show Details…"), + Action::OpenMain, + ))); + out.push(Row::Separator); + } else if !matches!(s.gateway, Gateway::Starting) { out.push(Row::Item(Item::new( "restart", "arrow.clockwise", @@ -601,6 +635,7 @@ fn rows(s: &Snapshot) -> Vec { group: g.name.clone(), provider: m.clone(), }, + sep_before: false, }) .collect(); out.push(Row::Item(item)); @@ -675,9 +710,18 @@ fn header(s: &Snapshot) -> Row { tr!("转发已停止。", "Forwarding has stopped.").to_string(), true, ), + Gateway::Unlinked => ( + StateTone::Bad, + tr!("正在重新连接。", "Reconnecting.").to_string(), + true, + ), }; Row::Header { - title: "ThinkWatch Lite".to_string(), + // 连着远程时写服务器的名字:菜单里的数字、提醒都是那台的 + title: s + .remote + .clone() + .unwrap_or_else(|| "ThinkWatch Lite".to_string()), state: state_text(&s.gateway).to_string(), tone, line2, @@ -699,6 +743,7 @@ fn app_items(s: &Snapshot, out: &mut Vec) { tr!("打开主界面", "Open ThinkWatch Lite"), Action::OpenMain, ))); + out.push(Row::Item(connections(s))); out.push(Row::Item( Item::new( "settings", @@ -737,6 +782,35 @@ fn app_items(s: &Snapshot, out: &mut Vec) { )); } +/// 「连接」子菜单:主窗口关着也能切。**本机永远在第一个**,任何状态下一步就能切回来 +fn connections(s: &Snapshot) -> Item { + let mut item = Item::new( + "connections", + "network", + tr!("连接", "Connection"), + Action::OpenMain, + ); + item.action = None; + let mut subs: Vec = s + .connections + .iter() + .map(|c| SubItem { + title: c.name.clone(), + checked: c.current, + action: Action::SwitchConnection(c.id.clone()), + sep_before: false, + }) + .collect(); + subs.push(SubItem { + title: tr!("管理连接…", "Manage Connections…").to_string(), + checked: false, + action: Action::Settings, + sep_before: true, + }); + item.submenu = subs; + item +} + fn window_row(w: &Window, now_ms: u64) -> WindowRow { if !current(w, now_ms) { return WindowRow { diff --git a/src-tauri/src/menubar/tray.rs b/src-tauri/src/menubar/tray.rs index 4ab0b75c..607066eb 100644 --- a/src-tauri/src/menubar/tray.rs +++ b/src-tauri/src/menubar/tray.rs @@ -230,6 +230,18 @@ fn build(app: &tauri::AppHandle, rows: &[Row]) -> tauri::Result } else { let mut subs: Vec>> = Vec::new(); for m in &i.submenu { + // 隔在线后面的是动作(「管理连接…」),不是几个里选一个:不画成勾选项 + if m.sep_before { + subs.push(Box::new(PredefinedMenuItem::separator(app)?)); + subs.push(Box::new(MenuItem::with_id( + app, + id(&m.action), + &m.title, + true, + None::<&str>, + )?)); + continue; + } subs.push(Box::new(CheckMenuItem::with_id( app, id(&m.action), diff --git a/src-tauri/src/notices/rules.rs b/src-tauri/src/notices/rules.rs index 20d25b86..3f8afeff 100644 --- a/src-tauri/src/notices/rules.rs +++ b/src-tauri/src/notices/rules.rs @@ -66,7 +66,8 @@ pub fn default_view(key: &str) -> &'static str { /// **网关不在服务时不必再说它下面每一家怎么了** —— 那时用户要做的只有一件事。 pub fn suppresses(key: &str) -> &'static [&'static str] { match key { - "gateway" => &[ + // 连着的远程 core 断了:和本机网关停了一样,它下面每一家怎么了都不必再说 + "gateway" | "remote" => &[ "upstream:", "quota:", "credential:", @@ -644,6 +645,33 @@ pub fn wedged() -> Signal { .now() } +/// 和远程 core 的连接断了。**只报一次**:总线按键去重,重连的每一次失败都不再说; +/// 连上之后由 [`remote_back`] 收起 +pub fn remote_lost(name: &str) -> Signal { + Signal::raised( + "remote", + Level::Critical, + tr!( + format!("与 {name} 的连接已断开"), + format!("Disconnected from “{name}”") + ), + ) + .body( + tr!( + "正在重新连接。连接恢复前,应用中的内容停留在断开时的状态。", + "Reconnecting. Until the connection is restored, the app shows the state at the time of the disconnect." + ) + .to_string(), + ) + .view(SETTINGS) + .now() + .suppressing(suppresses("remote")) +} + +pub fn remote_back() -> Signal { + Signal::cleared("remote") +} + /// `5h` / `weekly` → 「5 小时」「每周」(英文是 `5-hour`、`weekly`)。认不出来的原样用 fn window_label(w: &str) -> String { match w { diff --git a/src-tauri/src/private_dir.rs b/src-tauri/src/private_dir.rs new file mode 100644 index 00000000..c0a3b05a --- /dev/null +++ b/src-tauri/src/private_dir.rs @@ -0,0 +1,218 @@ +//! 建数据目录,**只给自己看**。 +//! +//! 和 core 的 `tw_config::private_dir` 是同一个做法(那边建数据目录用它;这里 +//! 不为这一个函数去依赖整个 tw-config):unix 上 `mkdir` 那一刻就是 `0700`,Windows +//! 上带一份受保护的 DACL(只有当前用户和 SYSTEM),里面新建的文件跟着继承。 +//! **已经存在的目录不去动** —— 用户自己放宽过的,是他的决定。 +//! +//! 应用这边要它,是因为远程连接的密钥落在这个目录里(见 `connection::secrets`), +//! 而用户可能在 core 第一次起来、建出目录之前就先添加了一条远程连接。 + +use std::path::Path; + +/// 建出数据目录。已经在了就什么都不做。 +pub fn create(dir: &Path) -> std::io::Result<()> { + if dir.exists() { + return Ok(()); + } + // 上级按常规建。**密钥在叶子目录里**,而叶子目录上一个受保护的 DACL + // 已经挡住了读它内容的人 —— 上级宽松只是让人知道有这么个目录。 + if let Some(parent) = dir.parent() + && !parent.as_os_str().is_empty() + { + std::fs::create_dir_all(parent)?; + } + imp::create_private(dir) +} + +#[cfg(not(windows))] +mod imp { + use std::path::Path; + + /// unix:建成 `0700`。`mode` 是在 `mkdir(2)` 那一刻给的,没有先宽后收的窗口。 + pub fn create_private(dir: &Path) -> std::io::Result<()> { + use std::os::unix::fs::DirBuilderExt; + match std::fs::DirBuilder::new().mode(0o700).create(dir) { + // 查过不在、建的时候却在了:别的进程抢先一步,和 `create_dir_all` 一样不算错 + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists && dir.is_dir() => Ok(()), + r => r, + } + } +} + +#[cfg(windows)] +mod imp { + use std::path::Path; + + use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, LocalFree}; + use windows_sys::Win32::Security::Authorization::{ + ConvertSidToStringSidW, ConvertStringSecurityDescriptorToSecurityDescriptorW, + SDDL_REVISION_1, + }; + use windows_sys::Win32::Security::{ + GetTokenInformation, SECURITY_ATTRIBUTES, TOKEN_QUERY, TOKEN_USER, TokenUser, + }; + use windows_sys::Win32::Storage::FileSystem::CreateDirectoryW; + use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + + pub fn create_private(dir: &Path) -> std::io::Result<()> { + let sid = current_user_sid()?; + // `D:P` —— 一份**受保护的** DACL:不继承父目录那些条目,否则 + // `D:\` 上那条给 `Users` 的就跟着进来了,而那正是要挡的东西。 + // `OICI` 让里面新建的文件和子目录跟着继承这两条。 + // `FA` 完全控制:当前用户,以及 SYSTEM(`SY`,不给它的话备份、 + // 索引这类系统服务会在这个目录上报错)。 + let sddl = format!("D:P(A;OICI;FA;;;{sid})(A;OICI;FA;;;SY)"); + with_security_attributes(&sddl, |sa| { + let path = wide(dir); + // SAFETY: 路径是以 NUL 结尾的 UTF-16,`sa` 在这次调用期间有效。 + let ok = unsafe { CreateDirectoryW(path.as_ptr(), sa) }; + if ok == 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }) + } + + fn wide(p: &Path) -> Vec { + use std::os::windows::ffi::OsStrExt; + p.as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect() + } + + /// 当前用户的 SID,写成 `S-1-5-21-…` 那种字符串。 + /// + /// **问令牌要,不写死任何东西。**SDDL 里有 `CO`(Creator Owner)这类 + /// 简写,但它们是给可继承条目当占位符用的,放在对象自己的 DACL 上 + /// 不代表「建它的那个人」。 + fn current_user_sid() -> std::io::Result { + struct Token(HANDLE); + impl Drop for Token { + fn drop(&mut self) { + // SAFETY: 拿到过的句柄,只关一次 + unsafe { CloseHandle(self.0) }; + } + } + + let mut raw: HANDLE = std::ptr::null_mut(); + // SAFETY: 出参是本地变量 + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut raw) } == 0 { + return Err(std::io::Error::last_os_error()); + } + let token = Token(raw); + + // 先问要多大,再按那个大小要一次 + let mut len: u32 = 0; + // SAFETY: 传空缓冲区只为问尺寸;失败是预期的(ERROR_INSUFFICIENT_BUFFER) + unsafe { GetTokenInformation(token.0, TokenUser, std::ptr::null_mut(), 0, &mut len) }; + if len == 0 { + return Err(std::io::Error::last_os_error()); + } + // `TOKEN_USER` 后面跟着 SID 的字节,所以缓冲区要按它对齐 —— + // `Vec` 只保证 1 字节对齐,而我们要把它当成那个结构体读。 + let words = (len as usize).div_ceil(std::mem::size_of::()); + let mut buf = vec![0u64; words.max(1)]; + // SAFETY: 缓冲区至少 `len` 字节,且对齐得下 TOKEN_USER + let ok = unsafe { + GetTokenInformation( + token.0, + TokenUser, + buf.as_mut_ptr() as *mut std::ffi::c_void, + len, + &mut len, + ) + }; + if ok == 0 { + return Err(std::io::Error::last_os_error()); + } + + // SAFETY: 调用成功,缓冲区开头是一个 TOKEN_USER + let sid = unsafe { (*(buf.as_ptr() as *const TOKEN_USER)).User.Sid }; + let mut s: *mut u16 = std::ptr::null_mut(); + // SAFETY: `sid` 指向刚拿到的那份数据,出参是本地变量 + if unsafe { ConvertSidToStringSidW(sid, &mut s) } == 0 { + return Err(std::io::Error::last_os_error()); + } + // SAFETY: 转换成功,`s` 是一段以 NUL 结尾的 UTF-16,由 LocalFree 释放 + let out = unsafe { + let mut n = 0usize; + while *s.add(n) != 0 { + n += 1; + } + let out = String::from_utf16_lossy(std::slice::from_raw_parts(s, n)); + LocalFree(s as *mut std::ffi::c_void); + out + }; + Ok(out) + } + + /// 把一段 SDDL 变成 `SECURITY_ATTRIBUTES`,用完释放。 + fn with_security_attributes( + sddl: &str, + f: impl FnOnce(*const SECURITY_ATTRIBUTES) -> std::io::Result, + ) -> std::io::Result { + let text: Vec = sddl.encode_utf16().chain(std::iter::once(0)).collect(); + let mut sd: *mut std::ffi::c_void = std::ptr::null_mut(); + // SAFETY: `text` 是以 NUL 结尾的 UTF-16;出参是本地变量 + let ok = unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + text.as_ptr(), + SDDL_REVISION_1, + &mut sd, + std::ptr::null_mut(), + ) + }; + if ok == 0 { + return Err(std::io::Error::last_os_error()); + } + let sa = SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: sd, + bInheritHandle: 0, + }; + let r = f(&sa); + // SAFETY: `sd` 由上面那次转换分配,只释放一次 + unsafe { LocalFree(sd) }; + r + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp(name: &str) -> std::path::PathBuf { + let p = std::env::temp_dir().join(format!("tw-private-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&p); + p + } + + #[test] + fn it_creates_the_directory_and_its_parents() { + let dir = tmp("parents").join("a/b/data"); + create(&dir).unwrap(); + assert!(dir.is_dir()); + } + + /// 已经在了就不动它 + #[test] + fn creating_one_that_is_already_there_is_not_an_error() { + let dir = tmp("again"); + create(&dir).unwrap(); + std::fs::write(dir.join("x"), "1").unwrap(); + create(&dir).unwrap(); + assert_eq!(std::fs::read_to_string(dir.join("x")).unwrap(), "1"); + } + + #[cfg(unix)] + #[test] + fn a_new_directory_is_private_on_unix() { + use std::os::unix::fs::PermissionsExt; + let dir = tmp("mode").join("data"); + create(&dir).unwrap(); + let mode = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o700, "{mode:o}"); + } +} diff --git a/src-tauri/src/supervisor/mod.rs b/src-tauri/src/supervisor/mod.rs index 92259a1e..f1b5db3e 100644 --- a/src-tauri/src/supervisor/mod.rs +++ b/src-tauri/src/supervisor/mod.rs @@ -356,6 +356,12 @@ impl Supervisor { args } + /// 本机 core 的控制面客户端。**永远指着本机**,切到远程也不跟着走:等本机手上的 + /// 请求结束、再停掉它,问的都是本机这一个 + pub fn control(&self) -> &crate::control::ControlClient { + &self.control + } + pub fn binary(&self) -> &PathBuf { &self.binary } diff --git a/src-tauri/src/updater.rs b/src-tauri/src/updater.rs index d1795d3e..8e797ac6 100644 --- a/src-tauri/src/updater.rs +++ b/src-tauri/src/updater.rs @@ -179,40 +179,10 @@ pub(crate) fn show_update_window(app: &tauri::AppHandle) -> tauri::Result<()> { Ok(()) } -/// 把更新窗口调成网页量出来的那么高。 -/// -/// **窗口有多高,不等于网页有多高。**Tauri 在 macOS 上建的是一扇 -/// `FullSizeContentView` 的窗:内容视图铺满整扇窗户,标题栏盖在它上面, -/// webview 只摆在标题栏底下那一块。而 `set_size` 说的是整扇窗户 -/// (`inner_size()` 和 `outer_size()` 在这里报的也是同一个数,所以标题栏 -/// 有多高,从它们之间也减不出来)—— 照着网页量出来的高度设下去,网页拿到 -/// 的就少了一条标题栏,内容的最后一截被窗口下沿切掉:底部留白没了,那排 -/// 按钮只剩上半截。 -/// -/// 标题栏多高不写死 —— 各版本不一样(Tahoe 上是 32 点),没有标题栏的窗 -/// 是 0。`contentLayoutRect` 给的正是没被标题栏盖住的那一块,和整扇窗户 -/// 一减就是要补上的数。 +/// 把更新窗口调成网页量出来的那么高。理由见 [`crate::window::fit`] #[tauri::command] pub fn update_fit(window: tauri::Window, height: f64) -> tauri::Result<()> { - #[cfg(target_os = "macos")] - { - let w = window.clone(); - // **走 Tauri 的主线程队列,不走 GCD。**紧跟在这之后网页会把窗口 - // 亮出来,那一步排在同一条队列上;换一条队列,用户就会先看见一扇 - // 大小还没调好的窗 - window.run_on_main_thread(move || { - let Ok(ptr) = w.ns_window() else { return }; - // SAFETY: `ns_window()` 给的是这扇窗的 NSWindow,这里在主线程上 - let ns = unsafe { &*ptr.cast::() }; - let titlebar = ns.frame().size.height - ns.contentLayoutRect().size.height; - ns.setContentSize(objc2_foundation::NSSize::new( - UPDATE_WIDTH, - height + titlebar, - )); - }) - } - #[cfg(not(target_os = "macos"))] - window.set_size(tauri::LogicalSize::new(UPDATE_WIDTH, height)) + crate::window::fit(&window, UPDATE_WIDTH, height) } /// 更新那条系统通知的键。点开它拉起的是更新窗口,不是主界面的某一页 @@ -347,43 +317,35 @@ pub(crate) enum Step { /// /// 问不到(core 不在跑、控制面没答应)就不等 —— 没有网关,也就没有要保护 /// 的请求。 -pub(crate) async fn wait_for_quiet(app: &tauri::AppHandle, control: &ControlClient) { +pub(crate) async fn wait_for_quiet(control: &ControlClient, on_wait: impl Fn(usize)) { let started = std::time::Instant::now(); let mut waited = false; loop { let s = match control.status().await { Ok(s) => s, Err(e) => { - tracing::info!("更新:问不到网关的状态,不等({e:#})"); + tracing::info!("等请求结束:问不到网关的状态,不等({e:#})"); return; } }; if s.in_flight == 0 { if waited { - tracing::info!("更新:请求都结束了,等了 {:?}", started.elapsed()); + tracing::info!("等请求结束:都结束了,等了 {:?}", started.elapsed()); } return; } if started.elapsed() >= DRAIN_LIMIT { tracing::warn!( - "更新:等了 {DRAIN_LIMIT:?} 还有 {} 个请求没结束,照常重启", + "等请求结束:等了 {DRAIN_LIMIT:?} 还有 {} 个请求没结束,不再等", s.in_flight ); return; } if !waited { - tracing::info!( - "更新:网关手上还有 {} 个请求,等它们结束再重启", - s.in_flight - ); + tracing::info!("等请求结束:网关手上还有 {} 个请求", s.in_flight); waited = true; } - let _ = app.emit( - "update-step", - Step::Waiting { - in_flight: s.in_flight, - }, - ); + on_wait(s.in_flight); tokio::time::sleep(std::time::Duration::from_millis(200)).await; } } @@ -471,7 +433,11 @@ pub async fn update_install( ) })?; - wait_for_quiet(&app, &state.control).await; + // 问的是本机的 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); // 起来之后说一声换到了哪一版。写不进去不影响更新本身。 diff --git a/src-tauri/src/window.rs b/src-tauri/src/window.rs index 2e660215..a8ba8ff3 100644 --- a/src-tauri/src/window.rs +++ b/src-tauri/src/window.rs @@ -101,9 +101,15 @@ pub(crate) fn show_main_window(app: &tauri::AppHandle) -> tauri::Result<()> { // 热启动还藏着的时候又点了一次,也是这里:不等了,直接出来 return reveal(app, &w); } + // 「core 已经在跑」按当前连接算:连着远程时本机的 core 本来就停着 let warm = app .try_state::() - .is_some_and(|s| matches!(s.supervisor.state(), CoreState::Running { .. })); + .is_some_and(|s| match s.link.state() { + crate::connection::LinkState::Local => { + matches!(s.supervisor.state(), CoreState::Running { .. }) + } + state => matches!(state, crate::connection::LinkState::Connected { .. }), + }); let b = WebviewWindowBuilder::new(app, "main", WebviewUrl::default()) .title("ThinkWatch Lite") .initialization_script(format!( @@ -166,6 +172,38 @@ pub(crate) fn reveal(app: &tauri::AppHandle, w: &tauri::WebviewWindow) -> tauri: w.set_focus() } +/// 把一扇小窗调成网页量出来的那么大(更新窗口、连接选择)。 +/// +/// **窗口有多高,不等于网页有多高。**Tauri 在 macOS 上建的是一扇 +/// `FullSizeContentView` 的窗:内容视图铺满整扇窗户,标题栏盖在它上面, +/// webview 只摆在标题栏底下那一块。而 `set_size` 说的是整扇窗户 +/// (`inner_size()` 和 `outer_size()` 在这里报的也是同一个数,所以标题栏 +/// 有多高,从它们之间也减不出来)—— 照着网页量出来的高度设下去,网页拿到 +/// 的就少了一条标题栏,内容的最后一截被窗口下沿切掉:底部留白没了,那排 +/// 按钮只剩上半截。 +/// +/// 标题栏多高不写死 —— 各版本不一样(Tahoe 上是 32 点),没有标题栏的窗 +/// 是 0。`contentLayoutRect` 给的正是没被标题栏盖住的那一块,和整扇窗户 +/// 一减就是要补上的数。 +pub(crate) fn fit(window: &tauri::Window, width: f64, height: f64) -> tauri::Result<()> { + #[cfg(target_os = "macos")] + { + let w = window.clone(); + // **走 Tauri 的主线程队列,不走 GCD。**紧跟在这之后网页会把窗口 + // 亮出来,那一步排在同一条队列上;换一条队列,用户就会先看见一扇 + // 大小还没调好的窗 + window.run_on_main_thread(move || { + let Ok(ptr) = w.ns_window() else { return }; + // SAFETY: `ns_window()` 给的是这扇窗的 NSWindow,这里在主线程上 + let ns = unsafe { &*ptr.cast::() }; + let titlebar = ns.frame().size.height - ns.contentLayoutRect().size.height; + ns.setContentSize(objc2_foundation::NSSize::new(width, height + titlebar)); + }) + } + #[cfg(not(target_os = "macos"))] + window.set_size(tauri::LogicalSize::new(width, height)) +} + /// 没有窗口时退回菜单栏应用:不占 Dock、不进 ⌘Tab。 #[cfg(target_os = "macos")] pub(crate) fn become_accessory(app: &tauri::AppHandle) { diff --git a/src/App.tsx b/src/App.tsx index 7208cfcf..71ba5fdc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -78,6 +78,11 @@ import { NativeSelect, NativeSelectOption } from "@/ui/native-select"; import { trouble } from "./launch/trouble"; import { LaunchScreen } from "./launch/LaunchScreen"; import { warm } from "./launch/warm"; +import { ConnectionProvider, useConnections } from "./connection/ConnectionProvider"; +import { Switcher } from "./connection/Switcher"; +import { Unlinked } from "./connection/Unlinked"; +import { currentProfile } from "./connection/api"; +import { connText } from "./connection/connection.i18n"; import { Sidebar, SidebarContent, @@ -108,6 +113,13 @@ const MAX_TRIES = 8; /** 概览的几个重读理由挨着到时,等这么久再读:一串只读一次 */ const COALESCE_MS = 100; +/** + * 启动画面最多等多久。**过了就照常打开窗口**,内容区显示「未连接」那一页、后台接着 + * 重连 —— 而不是一直转圈。界面外壳不该依赖 core 连没连上:连接管理在设置里,那一页 + * 这时必须能用 + */ +const LAUNCH_CAP_MS = 8_000; + /** core 的状态字符串来自 Rust 侧的 CoreState,见 supervisor/mod.rs。 */ /** * 主窗口的几个面。 @@ -265,8 +277,40 @@ function describeCore(raw: string): { */ const drag = isMac ? { "data-tauri-drag-region": true } : {}; +/** + * 主窗口。 + * + * **换了连接,主界面整个重挂。**流量、会话、概览、各页的缓存都属于原来那个 core —— + * 两边的请求编号还会重叠 —— 一样样去清,漏一处就是把一台机器的数据安在另一台头上。 + * 连接的对话框挂在重挂的那一层外面(`ConnectionProvider`),切换那一下不跟着消失。 + */ export default function App() { + return ( + + + + ); +} + +function PerConnection() { + const { view } = useConnections(); + // 第一次读到的那个连接不算「换了」:那是启动时本来就要连的 + const seen = useRef<{ id: string | null; n: number }>({ id: null, n: 0 }); + if (view && seen.current.id !== view.current) { + if (seen.current.id !== null) seen.current.n += 1; + seen.current.id = view.current; + } + return ; +} + +function Shell({ first }: { first: boolean }) { const t = useText(appText); + const ct = useText(connText); + const conn = useConnections(); + const profile = conn.view ? currentProfile(conn.view) : undefined; + /** 连着的是远程 core */ + const remote = profile !== undefined && !profile.local; + const link = conn.view?.link ?? null; const common = useText(commonText); /** * 守护状态。**先当它在起**:第一次读到之前画「已停止」的话,启动画面会先 @@ -417,7 +461,18 @@ export default function App() { * 启动画面还在。**只在冷启动、这次开窗第一次交接之前**,见 `LaunchScreen`。 * 热启动没有这一面,窗口藏到交接那一刻才出现(见 `warm`) */ - const [launching, setLaunching] = useState(!warm); + const [launching, setLaunching] = useState(!warm && first); + /** 启动画面等够了(或者远程说了一个等多久都不会好的原因):不再等,交出主界面 */ + const [gaveUp, setGaveUp] = useState(false); + useEffect(() => { + const h = setTimeout(() => setGaveUp(true), LAUNCH_CAP_MS); + return () => clearTimeout(h); + }, []); + const lasting = + link?.kind === "down" && + (link.error.kind === "wrong_key" || + link.error.kind === "version_mismatch" || + link.error.kind === "not_yet_available"); /** 概览那一页的第一份数据到了。启动画面等它,交接时数字已经是对的 */ const [landed, setLanded] = useState(false); /** 连上之后首屏迟迟取不齐:不再等,交给那一页自己的骨架 */ @@ -574,6 +629,11 @@ export default function App() { } // `requests:42`:打开流量页并展开那一条(菜单栏里点了一个进行中的请求) const [page, id] = view.split(":"); + // `switch:`:菜单栏里选了一条远程连接。试连和确认在这里做,和侧栏同一条路 + if (page === "switch" && id) { + switchTo.current(id); + return; + } setTab(page as Surface); if (page === "requests" && id) setOpen(Number(id)); }; @@ -584,11 +644,19 @@ export default function App() { go(e.payload); void invoke("take_pending_view").catch(() => {}); }); + // 连接那一层(切换器里的「管理连接…」)要落页时发的 + const local = (e: Event) => go((e as CustomEvent).detail); + window.addEventListener("tw-open-view", local); return () => { un.then((f) => f()); + window.removeEventListener("tw-open-view", local); }; }, []); + /** 切换连接的入口。**用 ref**:落页那个 effect 只挂一次,而连接列表是后来才读到的 */ + const switchTo = useRef(conn.switchTo); + switchTo.current = conn.switchTo; + /** * 列表的键盘导航。 * @@ -827,8 +895,15 @@ export default function App() { }, [reloads, nudge, health, models, listening, upstreamState, core, setStatus, setOv]); const c = describeCore(core); - /** 连上过、又断了。**只在这时候挂那条带子** */ - const lost = linked && tries > 0 ? trouble(core, tries) : null; + /** 连上过、又断了。**只在这时候挂那条带子**。连着远程时断线另有一条(见下面) */ + const lost = linked && tries > 0 && !remote ? trouble(core, tries) : null; + /** + * 连着远程、连上过、现在断了(设计稿 ⑥ 右)。**不换成「未连接」整页**:已有的内容 + * 留着、置为只读,顶上一条横幅。连上后按现有的对账机制补齐(`core-state` 回到 + * `running:` 时各处自己对账) + */ + const remoteLost = remote && linked && link !== null && link.kind !== "connected"; + const remoteAttempt = link?.kind === "down" ? link.attempt : link?.kind === "connecting" ? link.attempt : 0; useEffect(() => { if (!linked) return; @@ -840,7 +915,10 @@ export default function App() { 被盖着;交接时概览(和落地页那一页的数据)已经在了,数字不会在眼前从零 跳成实际值。取不齐就不等了(`waited`),那一页有自己的骨架。 */ - const handover = linked && ((ov !== null && (tab !== "dashboard" || landed)) || waited); + const handover = + (linked && ((ov !== null && (tab !== "dashboard" || landed)) || waited)) || + gaveUp || + lasting; // 热启动:交接的那一刻就是窗口出现的那一刻。Rust 那边也有保底,这里只管早到 useEffect(() => { @@ -932,9 +1010,13 @@ export default function App() { const badge = it.id === "mcp" ? alerts.length : 0; const Icon = it.icon; const label = t.surfaces[it.id]; + // 没连上时需要 core 数据的几页置灰;设置始终可用,连接管理在那里 + const off = !linked && it.id !== "settings"; return ( setTab(it.id)} aria-current={on ? "page" : undefined} @@ -980,48 +1062,29 @@ export default function App() { className="border-t" style={{ borderColor: "var(--chrome-hair)" }} > - - {status?.gateway_addr - ? `${c.text} · ${status.gateway_addr}` - : c.text} - {/* 监听设置没换成:上面的地址是还在服务的旧地址,原因写在这里 */} - {status?.listen_error && ( -
{t.listenStale(coreText(status.listen_error))}
- )} - - } - > -
- - {/* 展开时写全,收起时 80px 也放得下「运行中」四个字 */} - - {c.text} - - - {c.short} - -
-
- {status?.gateway_addr && ( - - {status.gateway_addr} - - )} + {/* + 连接名在状态点和网关地址上面,点开是连接列表(见 `Switcher`)。连本机时 + 状态和地址照旧来自守护;连远程时来自连接那一层 + */} + + {status?.gateway_addr + ? `${c.text} · ${status.gateway_addr}` + : c.text} + {/* 监听设置没换成:上面的地址是还在服务的旧地址,原因写在这里 */} + {status?.listen_error && ( +
{t.listenStale(coreText(status.listen_error))}
+ )} + + ), + }} + /> @@ -1234,6 +1297,27 @@ export default function App() { 起来还是新鲜的。一条常驻的带子两件事都解决:数字留着,旁边写着 它们为什么不动了。 */} + {/* 连着远程时断线:一条横幅,内容留着、只读(设计稿 ⑥ 右) */} + {remoteLost && profile && ( +
+ + {ct.lostBanner(profile.name, remoteAttempt)} + + +
+ )} + {linked && tries > 0 && lost && (
@@ -1263,6 +1347,18 @@ export default function App() { **每一面自己滚。**工具栏钉在上面不动,这一层只负责给出高度; 真正滚的是下面这个容器(请求页是它自己的那一层,见下面)。 */} + {/* + 断线时整块只读:`fieldset disabled` 让里面的按钮、输入框、下拉一起失效, + 读、滚动、悬停说明照旧 —— 用户要看的是断开前的样子,改不了的东西就不该 + 让人点下去再报错 + */} +
setNudge((n) => n + 1)} /> + ) : ( + + ) + ) : tab === "dashboard" ? ( +
@@ -1715,6 +1820,7 @@ export default function App() { {launching && ( (null); return (
+ {/* 最上面:连不上的时候用户就是来这里的 */} + + diff --git a/src/connection/ConnectionProvider.tsx b/src/connection/ConnectionProvider.tsx new file mode 100644 index 00000000..9e38480c --- /dev/null +++ b/src/connection/ConnectionProvider.tsx @@ -0,0 +1,121 @@ +import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { toast } from "sonner"; +import { errorText } from "@/i18n/core.i18n"; +import { LOCAL, connApi, type ConnView, type Profile, type ServerInfo } from "./api"; +import { ProfileDialog } from "./ProfileDialog"; +import { SwitchDialog } from "./SwitchDialog"; +import { useConnection } from "./useConnection"; + +interface Actions { + /** 连接列表和当前状态。还没读到是 null */ + view: ConnView | null; + add: () => void; + edit: (p: Profile) => void; + /** 打开设置页的「连接」一节 */ + manage: () => void; + /** 切到这一条。本机直接切;远程先试连、再确认 */ + switchTo: (id: string) => void; +} + +const Ctx = createContext({ + view: null, + add: () => {}, + edit: () => {}, + manage: () => {}, + switchTo: () => {}, +}); + +/** + * 让主界面落到某一页。主界面在按连接重挂的那一层里面,够不着它的状态,所以和菜单栏、 + * 通知一样发一条 `open-view`(见 `App` 里的 `go`) + */ +export function openView(view: string) { + window.dispatchEvent(new CustomEvent("tw-open-view", { detail: view })); +} + +export function useConnections(): Actions { + return useContext(Ctx); +} + +/** + * 连接的对话框和动作,**挂在主界面外层**:侧栏的切换器、设置里的「连接」一节、未连接 + * 页、菜单栏转过来的切换,打开的是同一个对话框。 + * + * 它在按连接重挂的那一层外面(见 `App`):切换成功、主界面整个换掉的时候,这里不跟着 + * 重来。 + */ +export function ConnectionProvider({ children }: { children: ReactNode }) { + const view = useConnection(); + const [editing, setEditing] = useState<{ profile: Profile | null } | null>(null); + const [switching, setSwitching] = useState<{ target: Profile; tested: ServerInfo | null } | null>( + null, + ); + + /** + * 列表还没读到时要切的那一条。菜单栏里选了一条远程连接、窗口是为这一下新建的: + * 落页比列表先到 + */ + const pending = useRef(null); + + const actions = useMemo( + () => ({ + view, + add: () => setEditing({ profile: null }), + manage: () => openView("settings"), + edit: (p) => { + setSwitching(null); + setEditing({ profile: p }); + }, + switchTo: (id) => { + if (!view) { + pending.current = id; + return; + } + if (id === view.current) return; + if (id === LOCAL) { + // 切回本机不用确认:本机 core 拉起来,指着服务器的客户端不受影响 + connApi.switchTo(LOCAL, false).catch((e) => toast.error(errorText(e))); + return; + } + const target = view.profiles.find((p) => p.id === id); + if (target) setSwitching({ target, tested: null }); + }, + }), + [view], + ); + + useEffect(() => { + if (!view || pending.current === null) return; + const id = pending.current; + pending.current = null; + actions.switchTo(id); + }, [view, actions]); + + return ( + + {children} + {editing && ( + setEditing(null)} + onSaved={(p, andSwitch) => { + setEditing(null); + if (andSwitch) setSwitching({ target: p, tested: andSwitch }); + }} + /> + )} + {switching && ( + setSwitching(null)} + onEdit={(p) => { + setSwitching(null); + setEditing({ profile: p }); + }} + /> + )} + + ); +} diff --git a/src/connection/ConnectionsSection.tsx b/src/connection/ConnectionsSection.tsx new file mode 100644 index 00000000..d51da181 --- /dev/null +++ b/src/connection/ConnectionsSection.tsx @@ -0,0 +1,181 @@ +import { useState } from "react"; +import { toast } from "sonner"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/ui/alert-dialog"; +import { Badge } from "@/ui/badge"; +import { Button } from "@/ui/button"; +import { Segmented } from "@/ui/segmented"; +import { IconLocal, IconRemote } from "@/ui/icons"; +import { useLang, useText } from "@/i18n"; +import { commonText } from "@/i18n/common.i18n"; +import { errorText } from "@/i18n/core.i18n"; +import { FormRow, FormRows } from "@/settings/form"; +import { connApi, type ConnView, type Profile, type Startup } from "./api"; +import { connText } from "./connection.i18n"; +import { useConnections } from "./ConnectionProvider"; +import { profileName } from "./describe"; + +/** 「9 月 20 日」/「Sep 20」 */ +function day(ms: number, lang: "zh" | "en"): string { + return new Date(ms).toLocaleDateString(lang === "zh" ? "zh-CN" : "en-US", { + month: lang === "zh" ? "long" : "short", + day: "numeric", + }); +} + +/** + * 设置 → 连接(设计稿 ②)。**放在设置页最上方。** + * + * 新建和编辑一律走对话框(③),页面上不做内联表单。当前连接不能删除,要先切走; + * 本机内置、删不掉。「启动时连接」是几个里选一个,用分段控件,和「外观」一样。 + * + * **不依赖 core 的任何数据**:远程连不上时这一节照样能用 —— 这正是用户这时要来的地方。 + */ +export function ConnectionsSection() { + const t = useText(connText); + const { view, add, edit, switchTo } = useConnections(); + const [deleting, setDeleting] = useState(null); + if (!view) return null; + + async function setStartup(s: Startup) { + try { + await connApi.setStartup(s); + } catch (e) { + toast.error(errorText(e)); + } + } + + return ( +
+
+

{t.title}

+ +
+

{t.intro}

+ +
    + {view.profiles.map((p) => ( + switchTo(p.id)} + onEdit={() => edit(p)} + onDelete={() => setDeleting(p)} + /> + ))} +
+ +

{t.startupGroup}

+ + + + value={view.startup} + label={t.startupLabel} + options={[ + { id: "last", label: t.startupLast }, + { id: "local", label: t.startupLocal }, + ]} + onChange={(v) => void setStartup(v)} + /> + + + + setDeleting(null)} /> +
+ ); +} + +function Row({ + p, + view, + onSwitch, + onEdit, + onDelete, +}: { + p: Profile; + view: ConnView; + onSwitch: () => void; + onEdit: () => void; + onDelete: () => void; +}) { + const t = useText(connText); + const common = useText(commonText); + const lang = useLang(); + const current = p.id === view.current; + const Icon = p.local ? IconLocal : IconRemote; + let desc: string; + if (p.local) desc = t.localNote(view.data_dir); + else if (current && view.link.kind === "connected") + desc = `${p.addr} · ${t.coreVersion(view.link.info.core_version)}`; + else if (p.last_connected_at != null) desc = `${p.addr} · ${t.lastConnected(day(p.last_connected_at, lang))}`; + else desc = `${p.addr} · ${t.neverConnected}`; + + return ( +
  • +
    +

    + + {profileName(p)} + {current && {t.current}} +

    +

    {desc}

    +
    +
    + {!current && ( + + )} + {!p.local && ( + + )} + {/* 当前连着的删不掉,要先切走:按钮不出现,而不是点了才说不行 */} + {!p.local && !current && ( + + )} +
    +
  • + ); +} + +function DeleteDialog({ profile, onClose }: { profile: Profile | null; onClose: () => void }) { + const t = useText(connText); + const common = useText(commonText); + return ( + !o && onClose()}> + + + {profile ? t.deleteTitle(profile.name) : ""} + {t.deleteBody} + + + {common.cancel} + { + if (profile) connApi.remove(profile.id).catch((e) => toast.error(errorText(e))); + onClose(); + }} + > + {common.delete} + + + + + ); +} diff --git a/src/connection/PickerWindow.tsx b/src/connection/PickerWindow.tsx new file mode 100644 index 00000000..dbe587f6 --- /dev/null +++ b/src/connection/PickerWindow.tsx @@ -0,0 +1,150 @@ +import { useEffect, useRef, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { getCurrentWindow } from "@tauri-apps/api/window"; +import { Badge } from "@/ui/badge"; +import { Button } from "@/ui/button"; +import { IconLocal, IconRemote } from "@/ui/icons"; +import { cn } from "@/lib/utils"; +import { useText } from "@/i18n"; +import { errorText } from "@/i18n/core.i18n"; +import { connApi, type ConnView } from "./api"; +import { connText } from "./connection.i18n"; +import { profileName } from "./describe"; + +declare global { + interface Window { + /** 为什么先显示连接选择:按住了 ⌥,或者上次启动没能完成。Rust 建窗时注入 */ + __TW_PICK__?: "option" | "unfinished"; + } +} + +/** + * 启动时的连接选择(设计稿 ⑦ 右)。 + * + * 按住 ⌥ 启动,或者连着两次启动没能走到就绪时,**先显示这个小窗口,主窗口等选好了再开**。 + * 它不依赖 core 的任何数据:列表来自应用自己存的那一份。 + * + * 「连接」连选中的那一个、打开主界面;「管理连接…」同样连选中的那一个,主界面落到设置页 + * 的「连接」一节。**没有「不连」**:关掉这扇窗,菜单栏里的「连接」子菜单还能选。 + */ +export default function PickerWindow() { + const t = useText(connText); + const [view, setView] = useState(null); + const [chosen, setChosen] = useState(null); + const [busy, setBusy] = useState(false); + const [failed, setFailed] = useState(null); + const body = useRef(null); + const shown = useRef(false); + + useEffect(() => { + void connApi.view().then((v) => { + setView(v); + setChosen(v.profiles.some((p) => p.id === v.last_used) ? v.last_used : "local"); + }); + }, []); + + // 量出内容多高再定窗口,第一次量完才亮出来(和更新窗口一样,理由见 `window::fit`) + useEffect(() => { + const el = body.current; + if (!view || !el) return; + const fit = () => { + const height = Math.ceil(el.getBoundingClientRect().height); + void invoke("picker_fit", { height }) + .then(async () => { + if (shown.current) return; + shown.current = true; + const w = getCurrentWindow(); + await w.show(); + await w.setFocus(); + }) + .catch(() => {}); + }; + fit(); + const ro = new ResizeObserver(fit); + ro.observe(el); + return () => ro.disconnect(); + }, [view]); + + async function go(then: string | null) { + if (!chosen) return; + setBusy(true); + setFailed(null); + try { + await connApi.pick(chosen, then); + } catch (e) { + setFailed(errorText(e)); + setBusy(false); + } + } + + // 回车等于「连接」,上下键挪选中 + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (!view) return; + const ids = view.profiles.map((p) => p.id); + const i = ids.indexOf(chosen ?? ""); + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + const next = ids[Math.min(ids.length - 1, Math.max(0, i + (e.key === "ArrowDown" ? 1 : -1)))]; + if (next) setChosen(next); + } else if (e.key === "Enter" && !busy) { + e.preventDefault(); + void go(null); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }); + + if (!view) return null; + const why = window.__TW_PICK__ === "option" ? t.pickOption : t.pickUnfinished; + + return ( +
    +
    +
    +

    {t.pickTitle}

    +

    {why}

    +
    +
    + {view.profiles.map((p) => { + const on = p.id === chosen; + const Icon = p.local ? IconLocal : IconRemote; + return ( + + ); + })} +
    + {failed &&

    {failed}

    } +
    + + +
    +
    +
    + ); +} diff --git a/src/connection/ProfileDialog.tsx b/src/connection/ProfileDialog.tsx new file mode 100644 index 00000000..783ca832 --- /dev/null +++ b/src/connection/ProfileDialog.tsx @@ -0,0 +1,292 @@ +import { useState } from "react"; +import { CheckIcon, XIcon } from "lucide-react"; +import { Button } from "@/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/ui/dialog"; +import { Input } from "@/ui/input"; +import { Spinner } from "@/ui/spinner"; +import { cn } from "@/lib/utils"; +import { useText } from "@/i18n"; +import { commonText } from "@/i18n/common.i18n"; +import { errorText } from "@/i18n/core.i18n"; +import { FormRow, FormRows } from "@/settings/form"; +import { connApi, type ConnectError, type Invalid, type Profile, type ServerInfo } from "./api"; +import { connText } from "./connection.i18n"; +import { code, describeError } from "./describe"; + +/** 密钥:64 位十六进制。和 Rust 那边 `secrets::normalize` 同一条规矩 */ +const KEY_RE = /^[0-9a-fA-F]{64}$/; + +/** + * 添加、编辑一条远程连接(设计稿 ③)。 + * + * **编辑时密钥不回显。**它存在这台电脑上,界面拿不回来:显示「已保存」,点 + * 「更换」才填新的;不换就沿用。 + * + * 「保存并切换」**先试连**,没通过就停在这里显示原因,不存也不切。通过了才存, + * 然后交给切换确认(④)—— 那一步要说哪些客户端还指着本机。 + */ +export function ProfileDialog({ + editing, + isCurrent, + onClose, + onSaved, +}: { + /** 编辑的那一条;新建时是 null */ + editing: Profile | null; + /** 编辑的是当前连着的那一条:没有「保存并切换」,存完就按新的地址重连 */ + isCurrent: boolean; + onClose: () => void; + /** 存好了。`andSwitch`:点的是「保存并切换」,带着刚才试连的结果 */ + onSaved: (p: Profile, andSwitch: ServerInfo | null) => void; +}) { + const t = useText(connText); + const common = useText(commonText); + const [name, setName] = useState(editing?.name ?? ""); + const [host, setHost] = useState(editing?.host ?? ""); + const [port, setPort] = useState(editing?.port != null ? String(editing.port) : ""); + /** 编辑时点过「更换」没有 */ + const [replacing, setReplacing] = useState(editing === null); + const [key, setKey] = useState(""); + const [busy, setBusy] = useState<"test" | "save" | "switch" | null>(null); + const [result, setResult] = useState< + { ok: true; info: ServerInfo } | { ok: false; error: ConnectError } | null + >(null); + const [invalid, setInvalid] = useState(null); + const [error, setError] = useState(null); + + const portNum = Number(port.trim()); + const portOk = /^\d+$/.test(port.trim()) && portNum >= 1 && portNum <= 65535; + const keyOk = !replacing || KEY_RE.test(key.trim()); + /** 还差什么。页脚左边那一句,和别的对话框一个样 */ + const missing = + name.trim() === "" + ? t.enterName + : host.trim() === "" + ? t.enterHost + : !portOk + ? t.badPort + : replacing && key.trim() === "" + ? t.enterKey + : !keyOk + ? t.badKey + : null; + /** Rust 那边查出来的(重名、地址格式) */ + const refusals: Record = { + "name:taken": t.nameTaken, + "name:empty": t.enterName, + "host:invalid": t.badHost, + "port:invalid": t.badPort, + "key:invalid": t.badKey, + "key:empty": t.enterKey, + }; + const refused = invalid && (refusals[`${invalid.field}:${invalid.reason}`] ?? t.badHost); + + function input() { + return { + id: editing?.id ?? null, + name: name.trim(), + host: host.trim(), + port: portNum, + key: replacing ? key.trim() : null, + }; + } + + // 改了哪一格,上一次的结果就不作数了 + function touched(set: (v: T) => void) { + return (v: T) => { + set(v); + setResult(null); + setInvalid(null); + }; + } + + async function test(): Promise { + setResult(null); + const r = await connApi.test(input()); + if (r.result === "ok") { + setResult({ ok: true, info: r.info }); + return r.info; + } + setResult({ ok: false, error: r.error }); + return null; + } + + async function run(kind: "test" | "save" | "switch") { + setBusy(kind); + setError(null); + try { + let info: ServerInfo | null = null; + if (kind !== "save") { + info = await test(); + if (!info || kind === "test") return; + } + const saved = await connApi.save(input()); + if (saved.result === "invalid") { + setInvalid(saved.invalid); + return; + } + onSaved(saved.profile, kind === "switch" ? info : null); + } catch (e) { + setError(errorText(e)); + } finally { + setBusy(null); + } + } + + const hint = missing ?? refused; + + return ( + !open && onClose()}> + + + {editing ? t.editTitle : t.addTitle} + {t.dialogDesc} + + + + + touched(setName)(e.target.value)} + /> + + + touched(setHost)(e.target.value)} + /> + + + touched(setPort)(e.target.value)} + /> + + + {replacing ? ( + touched(setKey)(e.target.value)} + /> + ) : ( +
    + + +
    + )} +
    +
    + + + + {error &&

    {error}

    } + + + {/* 测试在左边,和保存那一组分开:它不改任何东西 */} + + {hint && {hint}} + + + {!isCurrent && ( + + )} + +
    +
    + ); +} + +/** 试连的结果:成功一行,失败两行(发生了什么、下一步) */ +export function TestResult({ + result, + testing, +}: { + result: { ok: true; info: ServerInfo } | { ok: false; error: ConnectError } | null; + testing: boolean; +}) { + const t = useText(connText); + if (testing) { + return ( +
    + + {t.connecting}… +
    + ); + } + if (!result) return null; + if (result.ok) { + return ( +
    + + {t.testOk(result.info.core_version, result.info.gateway_addr)} +
    + ); + } + const d = describeError(result.error); + return ( +
    + +
    +

    {d.title}

    +

    {d.next}

    +
    +
    + ); +} diff --git a/src/connection/SwitchDialog.tsx b/src/connection/SwitchDialog.tsx new file mode 100644 index 00000000..e4ac5c9d --- /dev/null +++ b/src/connection/SwitchDialog.tsx @@ -0,0 +1,187 @@ +import { useEffect, useRef, useState } from "react"; +import { InfoIcon, TriangleAlertIcon } from "lucide-react"; +import { Button } from "@/ui/button"; +import { Checkbox } from "@/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/ui/dialog"; +import { Spinner } from "@/ui/spinner"; +import { useText } from "@/i18n"; +import { commonText } from "@/i18n/common.i18n"; +import { errorText } from "@/i18n/core.i18n"; +import { connApi, type Adopted, type ConnectError, type Profile, type ServerInfo, type SwitchError } from "./api"; +import { connText } from "./connection.i18n"; +import { TestResult } from "./ProfileDialog"; + +type Stage = + | { kind: "testing" } + | { kind: "failed"; error: ConnectError | null; text: string | null } + | { kind: "confirm"; info: ServerInfo; adopted: Adopted | null }; + +/** + * 切到一条远程连接(设计稿 ④)。 + * + * **先试连,通过了才出现确认。**选中另一项不会立即切换:试连没通过就说原因,留在 + * 原来的连接上。确认里说清切换的代价:还指着本机网关的客户端会失败、本机 core + * 要停、之后各页改的是服务器上的配置。 + * + * 从远程切回本机不走这里,直接切:本机 core 拉起来,指着服务器的客户端不受影响。 + */ +export function SwitchDialog({ + target, + tested, + onClose, + onEdit, +}: { + target: Profile; + /** 刚在编辑对话框里试连过:不再试一遍,直接到确认 */ + tested: ServerInfo | null; + onClose: () => void; + onEdit: (p: Profile) => void; +}) { + const t = useText(connText); + const common = useText(commonText); + const [stage, setStage] = useState( + tested ? { kind: "confirm", info: tested, adopted: null } : { kind: "testing" }, + ); + const [retarget, setRetarget] = useState(false); + const [busy, setBusy] = useState(false); + const alive = useRef(true); + useEffect(() => { + alive.current = true; + return () => { + alive.current = false; + }; + }, []); + + // 试连,同时数一数还指着本机的客户端 + useEffect(() => { + void (async () => { + const adopted = connApi.preflight().catch(() => null); + if (!tested) { + try { + const r = await connApi.test({ + id: target.id, + name: target.name, + host: target.host ?? "", + port: target.port ?? 0, + key: null, + }); + if (!alive.current) return; + if (r.result === "failed") { + setStage({ kind: "failed", error: r.error, text: null }); + return; + } + setStage({ kind: "confirm", info: r.info, adopted: await adopted }); + } catch (e) { + if (alive.current) setStage({ kind: "failed", error: null, text: errorText(e) }); + } + return; + } + const a = await adopted; + if (alive.current) setStage((s) => (s.kind === "confirm" ? { ...s, adopted: a } : s)); + })(); + }, [target, tested]); + + async function commit() { + setBusy(true); + try { + await connApi.switchTo(target.id, retarget); + onClose(); + } catch (e) { + const err = e as SwitchError; + if (err && typeof err === "object" && "kind" in err) { + setStage({ + kind: "failed", + error: err.kind === "connect" ? err.error : null, + text: err.kind === "key_unreadable" ? t.keyUnreadable : err.kind === "unknown" ? t.gone : null, + }); + } else { + setStage({ kind: "failed", error: null, text: errorText(e) }); + } + } finally { + if (alive.current) setBusy(false); + } + } + + const title = + stage.kind === "testing" + ? t.testingTitle(target.name) + : stage.kind === "failed" + ? t.failedTitle(target.name) + : t.confirmTitle(target.name); + const adopted = stage.kind === "confirm" ? stage.adopted : null; + + return ( + !open && onClose()}> + + + {title} + + + {stage.kind === "testing" && } + + {stage.kind === "failed" && + (stage.error ? ( + + ) : ( +

    {stage.text}

    + ))} + + {stage.kind === "confirm" && ( +
      + {adopted && adopted.count > 0 && ( +
    • + +
      +

      + + {t.adoptedWarn(adopted.count, adopted.local_addr ?? "127.0.0.1:8788")} + + {t.adoptedWarnNext(target.name)} +

      + +
      +
    • + )} +
    • + +

      + {t.localStops} + {t.localKept} +

      +
    • +
    • + +

      {t.remoteConfig(target.name)}

      +
    • +
    + )} + + + {stage.kind === "failed" && ( + + )} + + {stage.kind === "confirm" && ( + + )} + +
    +
    + ); +} diff --git a/src/connection/Switcher.tsx b/src/connection/Switcher.tsx new file mode 100644 index 00000000..01955bfb --- /dev/null +++ b/src/connection/Switcher.tsx @@ -0,0 +1,156 @@ +import type { ReactNode } from "react"; +import { CheckIcon, ChevronsUpDownIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/ui/dropdown-menu"; +import { IconLocal, IconRemote } from "@/ui/icons"; +import { Tip } from "@/ui/tip"; +import { textOf, useText } from "@/i18n"; +import { currentProfile, type ConnView, type Profile } from "./api"; +import { connText } from "./connection.i18n"; +import { useConnections } from "./ConnectionProvider"; +import { profileName } from "./describe"; + +export type Tone = "ok" | "warn" | "bad"; + +/** 远程连接此刻怎么说:已连接 / 正在连接 / 未连接 */ +export function remoteStatus(v: ConnView): { text: string; tone: Tone; addr: string | null } { + const t = textOf(connText); + const p = currentProfile(v); + switch (v.link.kind) { + case "connected": + // 连上之后写服务器的网关地址:客户端要连的是它 + return { text: t.connected, tone: "ok", addr: v.link.info.gateway_addr ?? p?.addr ?? null }; + case "connecting": + return { text: t.connecting, tone: "warn", addr: p?.addr ?? null }; + default: + return { text: t.unlinked, tone: "bad", addr: p?.addr ?? null }; + } +} + +/** + * 侧栏底部:当前连接、它的状态、网关地址(设计稿 ①)。 + * + * **在原来的状态点和网关地址上方加一行连接名**,点开是连接列表。选中另一条不会立即 + * 切换:本机直接切,远程先试连、再确认(④)。侧栏收起成 80px 时只剩图标和状态点。 + * + * 连本机时状态和地址照旧来自守护(`local`);连远程时来自连接那一层。 + */ +export function Switcher({ + local, +}: { + /** 本机 core 的状态:几个字、收起时的短写、颜色、地址、悬停说明 */ + local: { text: string; short: string; tone: Tone; addr: string | null; tip: ReactNode }; +}) { + const t = useText(connText); + const { view, add, manage, switchTo } = useConnections(); + const p = view ? currentProfile(view) : undefined; + const remote = p && !p.local && view ? remoteStatus(view) : null; + const status = remote + ? { text: remote.text, short: remote.text, tone: remote.tone, addr: remote.addr, tip: remote.text } + : local; + const name = p ? profileName(p) : t.local; + const Icon = p && !p.local ? IconRemote : IconLocal; + + return ( +
    + {view && ( + + + + + {name} + + + + +

    {t.menuTitle}

    + {view.profiles.map((it) => ( + switchTo(it.id)} + /> + ))} + + {t.addRemote} + + {t.manage} + +
    +
    + )} + +
    + + {/* 展开时写全,收起时 80px 也放得下的短写 */} + {status.text} + {status.short} +
    +
    + {status.addr && ( + + {status.addr} + + )} +
    + ); +} + +function Choice({ + p, + current, + sub, + onSelect, +}: { + p: Profile; + current: boolean; + sub: string; + onSelect: () => void; +}) { + const Icon = p.local ? IconLocal : IconRemote; + return ( + + {current && } + + + + {profileName(p)} + + {sub} + + + ); +} diff --git a/src/connection/Unlinked.tsx b/src/connection/Unlinked.tsx new file mode 100644 index 00000000..f3d89de3 --- /dev/null +++ b/src/connection/Unlinked.tsx @@ -0,0 +1,164 @@ +import { useState, type ReactNode } from "react"; +import { PlugZapIcon, TriangleAlertIcon } from "lucide-react"; +import { invoke } from "@tauri-apps/api/core"; +import { toast } from "sonner"; +import { Button } from "@/ui/button"; +import { Spinner } from "@/ui/spinner"; +import { cn } from "@/lib/utils"; +import { useText } from "@/i18n"; +import { errorText } from "@/i18n/core.i18n"; +import { useNow } from "@/useNow"; +import { trouble } from "@/launch/trouble"; +import { troubleText } from "@/launch/trouble.i18n"; +import { LOCAL, connApi, currentProfile, type ConnView } from "./api"; +import { connText } from "./connection.i18n"; +import { useConnections } from "./ConnectionProvider"; +import { shortReason } from "./describe"; + +/** + * 没连上时内容区里的那一页(设计稿 ⑤、⑥ 左)。 + * + * **启动画面最多等约 8 秒**,之后照常打开窗口显示这一页,而不是一直转圈:需要 core + * 数据的导航项置灰,设置始终可用(连接管理在那里),侧栏的切换器照常可点。 + * + * 连着远程时说连不上的原因、第几次、多久后再试,给「立即重试 / 编辑连接 / 切换到本机」。 + * 「切换到本机」直接切,不走确认:这时远程本来就不可用。版本不一致单独一页,给出两个 + * 版本号和在服务器上要执行的命令。 + * + * 连着本机时说本机 core 怎么了(和启动画面同一套说法),给「重新启动」。 + */ +export function Unlinked({ core }: { core: string }) { + const { view } = useConnections(); + const p = view ? currentProfile(view) : undefined; + if (!view || !p || p.local) return ; + if (view.link.kind === "down" && view.link.error.kind === "version_mismatch") + return ; + return ; +} + +function Page({ icon, tone, title, children }: { icon: ReactNode; tone: "bad" | "warn"; title: string; children: ReactNode }) { + return ( +
    +
    +
    + {icon} +
    +

    {title}

    + {children} +
    +
    + ); +} + +function Facts({ rows }: { rows: [string, ReactNode][] }) { + return ( +
    + {rows.map(([k, v]) => ( +
    +
    {k}
    +
    {v}
    +
    + ))} +
    + ); +} + +function RemoteDown({ view }: { view: ConnView }) { + const t = useText(connText); + const { edit, switchTo } = useConnections(); + const now = useNow(1_000); + const p = currentProfile(view)!; + const link = view.link; + const retry = + link.kind === "down" + ? t.retryIn(link.attempt + 1, Math.max(0, Math.ceil((link.at_ms + link.retry_in_ms - now) / 1000))) + : link.kind === "connecting" + ? t.retrying(link.attempt) + : null; + return ( + } tone="bad" title={link.kind === "connecting" && link.attempt <= 1 ? t.connectingTo(p.name) : t.cannotConnect(p.name)}> + {p.addr}], + ...(link.kind === "down" ? ([[t.reason, shortReason(link.error)]] as [string, ReactNode][]) : []), + ...(retry ? ([[t.retry, retry]] as [string, ReactNode][]) : []), + ]} + /> +
    + + + +
    +

    {t.autoResume}

    +
    + ); +} + +function Mismatch({ view, ours, theirs }: { view: ConnView; ours: string; theirs: string }) { + const t = useText(connText); + const { switchTo } = useConnections(); + const p = currentProfile(view)!; + return ( + } tone="warn" title={t.mismatchTitle(p.name)}> + core {theirs}], + [t.appNeeds, core {ours}], + ]} + /> +

    {t.runOnServer}

    +
    {t.upgradeCommand}
    +
    + + +
    +
    + ); +} + +/** 连着本机、本机 core 没起来(启动画面等过了 8 秒之后) */ +function LocalDown({ core }: { core: string }) { + const t = useText(connText); + const tt = useText(troubleText); + const [busy, setBusy] = useState(false); + const tr = trouble(core, 0); + return ( + } tone="bad" title={tr.what}> +

    {tr.next}

    + {tr.retry && ( +
    + +
    + )} +

    {t.autoResume}

    +
    + ); +} diff --git a/src/connection/api.ts b/src/connection/api.ts new file mode 100644 index 00000000..9cbfe2db --- /dev/null +++ b/src/connection/api.ts @@ -0,0 +1,116 @@ +/** + * 连接:连哪个 core。**列表和状态都来自 Rust 侧**(`src-tauri/src/connection/`), + * 不经 core —— 远程连不上时这些照样要能显示、能操作。 + * + * 密钥不在这里:添加、更换时从对话框递过去一次,之后再也拿不回来。 + */ +import { invoke } from "@tauri-apps/api/core"; + +/** 本机那一条的 id */ +export const LOCAL = "local"; + +export interface Profile { + id: string; + /** 本机那一条的名字由界面按语言写,不用这里的 */ + name: string; + local: boolean; + host: string | null; + port: number | null; + /** `host:port` */ + addr: string | null; + last_connected_at: number | null; +} + +export interface ServerInfo { + core_version: string; + /** 服务器的网关地址。客户端要连的是它 */ + gateway_addr: string | null; +} + +/** 连不上的原因,按种类。文案见 `describe.ts` */ +export type ConnectError = + | { kind: "unreachable"; addr: string } + | { kind: "closed"; addr: string } + | { kind: "wrong_key" } + | { kind: "version_mismatch"; ours: string; theirs: string } + | { kind: "timeout"; addr: string } + | { kind: "not_yet_available" }; + +export type LinkState = + /** 连的是本机:状态看 `core-state` */ + | { kind: "local" } + /** 还没开始连:连接选择开着 */ + | { kind: "waiting" } + | { kind: "connecting"; attempt: number; ever: boolean } + | { kind: "connected"; info: ServerInfo } + | { + kind: "down"; + error: ConnectError; + attempt: number; + at_ms: number; + retry_in_ms: number; + /** 这次运行里连上过它:运行中断线,不是启动时连不上 */ + ever: boolean; + }; + +export type Startup = "last" | "local"; + +export interface ConnView { + profiles: Profile[]; + current: string; + last_used: string; + startup: Startup; + link: LinkState; + /** 这一版应用配的 core 版本 */ + required_core: string; + /** 本机的数据目录,`~/.thinkwatch` */ + data_dir: string; +} + +export interface ProfileInput { + id: string | null; + name: string; + host: string; + port: number; + /** 新建时必填;编辑时不填就沿用已经保存的那一把 */ + key: string | null; +} + +export type Tested = { result: "ok"; info: ServerInfo } | { result: "failed"; error: ConnectError }; + +export type Invalid = { + field: "name" | "host" | "port" | "key"; + reason: "empty" | "taken" | "invalid"; +}; + +export type Saved = { result: "ok"; profile: Profile } | { result: "invalid"; invalid: Invalid }; + +/** 切换没做成:列表里已经没有这一条、保存的密钥读不出来、试连没通过 */ +export type SwitchError = + | { kind: "unknown" } + | { kind: "key_unreadable"; detail: string } + | { kind: "connect"; error: ConnectError }; + +/** 切过去之前要说的:这台机器上已接管、还指着本机网关的客户端 */ +export interface Adopted { + count: number; + local_addr: string | null; +} + +export const connApi = { + view: () => invoke("connections"), + setStartup: (startup: Startup) => invoke("set_connection_startup", { startup }), + test: (input: ProfileInput) => invoke("test_connection", { input }), + save: (input: ProfileInput) => invoke("save_connection", { input }), + remove: (id: string) => invoke("delete_connection", { id }), + preflight: () => invoke("switch_preflight"), + switchTo: (id: string, retargetClients: boolean) => + invoke("switch_connection", { id, retargetClients }), + retry: () => invoke("retry_connection"), + pick: (id: string, then: string | null) => invoke("pick_connection", { id, then }), +}; + +/** 当前那一条 */ +export function currentProfile(v: ConnView): Profile | undefined { + return v.profiles.find((p) => p.id === v.current); +} diff --git a/src/connection/connection.i18n.tsx b/src/connection/connection.i18n.tsx new file mode 100644 index 00000000..87cb4b34 --- /dev/null +++ b/src/connection/connection.i18n.tsx @@ -0,0 +1,295 @@ +import type { ReactNode } from "react"; +import { messages } from "@/i18n"; +import { isLinux, isMac } from "@/platform"; + +/** 句中嵌着的一段代码(配置项、命令)。怎么画由组件决定 */ +type Code = (text: string) => ReactNode; + +/** 启动时按住哪个键 */ +const holdZh = isMac ? "Option" : "Alt"; + +/** + * 连接:侧栏的切换器、设置里的「连接」一节、添加与编辑、切换确认、未连接页、 + * 断线横幅、启动时的连接选择。 + */ +export const connText = messages( + { + /** 内置的那一条 */ + local: "本机", + localNote: (dir: string) => `${dir} · 内置,不可删除`, + localCore: "本地 core", + localStopped: "本地 core · 已停止", + + // 侧栏 + switcherTip: "切换连接", + menuTitle: "连接", + addRemote: "添加远程连接…", + manage: "管理连接…", + connected: "已连接", + connecting: "正在连接", + unlinked: "未连接", + + // 设置 → 连接 + title: "连接", + add: "添加远程连接", + intro: isMac + ? "应用同一时间连接一个 core。连接到远程 core 时,本机 core 停止运行,本机数据保留。" + : "应用同一时间连接一个 core。连接到远程 core 时,本机的 core 停止运行,本机数据保留。", + current: "当前", + switchTo: "切换到此连接", + lastConnected: (when: string) => `上次连接 ${when}`, + neverConnected: "尚未连接", + coreVersion: (v: string) => `core ${v}`, + currentOnlyDelete: "当前连接不能删除,请先切换到其他连接。", + startupGroup: "启动", + startupLabel: "启动时连接", + startupLast: "上次使用的连接", + startupLocal: "本机", + startupHint: isLinux + ? "连续两次启动未能完成时,下次启动先显示连接选择。" + : `按住 ${holdZh} 键启动应用时,先显示连接选择。`, + deleteTitle: (name: string) => `删除连接「${name}」?`, + deleteBody: "保存在本机的密钥一并删除。服务器上的 core 不受影响。", + + // 添加与编辑 + addTitle: "添加远程连接", + editTitle: "编辑连接", + dialogDesc: "连接到运行在另一台机器上的 ThinkWatch core。", + name: "名称", + namePlaceholder: "例如 home-server", + host: "地址", + hostHint: "主机名或 IP 地址。", + port: "控制端口", + portHint: (code: Code) => <>服务器配置中 {code("listen.control.remote.port")} 的值。, + key: "密钥", + keyHint: (code: Code) => ( + <> + 在服务器上执行 {code("twcore control-key")} 获取。仅保存在本机,只有当前用户可以读取。 + + ), + keySaved: "已保存在本机", + replaceKey: "更换", + test: "测试连接", + testHint: "完成握手并读取服务器 core 的版本。", + testOk: (version: string, gateway: string | null) => + `连接成功 · core ${version}${gateway ? ` · 网关 ${gateway}` : ""}`, + saveAndSwitch: "保存并切换", + enterName: "填写名称", + nameTaken: "已有同名的连接", + enterHost: "填写地址", + badHost: "地址格式不正确", + badPort: "端口应为 1–65535 之间的整数", + enterKey: "填写密钥", + badKey: "密钥应为 64 位十六进制字符", + + // 连接失败的几种说法:标题一句,下一步一句 + unreachable: (addr: string) => `无法连接到 ${addr}`, + unreachableNext: (code: Code) => ( + <>检查地址和端口,以及服务器配置中 {code("listen.control.remote")} 是否已启用。 + ), + timeoutNext: (code: Code) => ( + <>连接超时。检查地址和端口,以及服务器配置中 {code("listen.control.remote")} 是否已启用。 + ), + closed: "连接被服务器关闭", + closedNext: (code: Code) => ( + <>本机地址可能不在服务器配置 {code("listen.control.remote.allow_from")} 中。 + ), + wrongKey: "密钥不正确", + wrongKeyNext: (code: Code) => <>在服务器上执行 {code("twcore control-key")} 查看当前密钥。, + mismatch: (theirs: string, ours: string) => `版本不一致:服务器 core ${theirs},本应用需要 ${ours}`, + mismatchNext: "在服务器上升级 core 后再连接。", + unsupported: "此版本尚不支持连接远程 core", + unsupportedNext: "服务器可以访问。更新应用后即可连接。", + reasonTimeout: "连接超时", + reasonUnreachable: "无法访问该地址", + + // 切换 + testingTitle: (name: string) => `正在连接 ${name}…`, + failedTitle: (name: string) => `无法切换到 ${name}`, + confirmTitle: (name: string) => `切换到 ${name}?`, + adoptedWarn: (n: number, addr: string) => `已接管的 ${n} 个客户端仍指向本机网关 ${addr}。`, + adoptedWarnNext: (name: string) => + `切换后本机网关停止,这些客户端的请求会失败,直到重新连接本机,或在客户端页将它们改为指向 ${name}。`, + retarget: (name: string) => `同时将这些客户端改为指向 ${name}`, + localStops: "本机 core 将在在途请求结束后停止", + localKept: ",本机的配置、密钥和请求历史保留。", + remoteConfig: (name: string) => `切换后,上游、路由、密钥等页面显示和修改的是 ${name} 上的配置。`, + switchAction: "切换", + editConnection: "编辑连接", + retargetFailed: "客户端未能改为指向服务器,可在客户端页逐个修改。", + keyUnreadable: "无法读取保存在本机的密钥,请在编辑连接时重新填写。", + gone: "该连接已被删除。", + + // 未连接页 + cannotConnect: (name: string) => `无法连接到 ${name}`, + connectingTo: (name: string) => `正在连接 ${name}`, + address: "地址", + reason: "原因", + retry: "重试", + retryIn: (attempt: number, secs: number) => `第 ${attempt} 次,${secs} 秒后`, + retrying: (attempt: number) => `第 ${attempt} 次,正在连接`, + retryNow: "立即重试", + switchToLocal: "切换到本机", + autoResume: "连接恢复后自动回到上次打开的页面。", + mismatchTitle: (name: string) => `${name} 上的 core 版本与本应用不一致`, + server: "服务器", + appNeeds: "本应用需要", + runOnServer: "在服务器上执行:", + upgradeCommand: "twcore upgrade", + reconnect: "重新连接", + localDown: "本机 core 未在运行", + restartLocal: "重新启动", + + // 断线横幅 + lostBanner: (name: string, attempt: number) => + attempt > 0 + ? `与 ${name} 的连接已断开,正在重连(第 ${attempt} 次)。页面内容为断开前的状态,暂不可修改。` + : `与 ${name} 的连接已断开,正在重连。页面内容为断开前的状态,暂不可修改。`, + + // 启动时的连接选择 + pickTitle: "选择连接", + pickOption: `按住 ${holdZh} 键启动。选择这次要连接的 core。`, + pickUnfinished: "上次启动未能完成。选择这次要连接的 core。", + lastUsed: "上次使用", + connect: "连接", + }, + { + local: isMac ? "This Mac" : "This computer", + localNote: (dir: string) => `${dir} · Built in, cannot be deleted`, + localCore: "Local core", + localStopped: "Local core · Stopped", + + switcherTip: "Switch connection", + menuTitle: "Connection", + addRemote: "Add remote connection…", + manage: "Manage connections…", + connected: "Connected", + connecting: "Connecting", + unlinked: "Not connected", + + title: "Connection", + add: "Add remote connection", + intro: isMac + ? "The app connects to one core at a time. While it is connected to a remote core, the core on this Mac stops running; its data is kept." + : "The app connects to one core at a time. While it is connected to a remote core, the core on this computer stops running; its data is kept.", + current: "Current", + switchTo: "Switch to this connection", + lastConnected: (when: string) => `Last connected ${when}`, + neverConnected: "Never connected", + coreVersion: (v: string) => `core ${v}`, + currentOnlyDelete: "The current connection cannot be deleted. Switch to another connection first.", + startupGroup: "Startup", + startupLabel: "Connect at startup", + startupLast: "Last used connection", + startupLocal: isMac ? "This Mac" : "This computer", + startupHint: isLinux + ? "After two startups in a row fail to finish, the next startup shows the connection choice first." + : `Hold ${holdZh} while opening the app to choose a connection first.`, + deleteTitle: (name: string) => `Delete the connection “${name}”?`, + deleteBody: "The key saved on this computer is deleted as well. The core on the server is not affected.", + + addTitle: "Add remote connection", + editTitle: "Edit connection", + dialogDesc: "Connect to a ThinkWatch core running on another machine.", + name: "Name", + namePlaceholder: "e.g. home-server", + host: "Address", + hostHint: "Host name or IP address.", + port: "Control port", + portHint: (code: Code) => <>The value of {code("listen.control.remote.port")} in the server's config., + key: "Key", + keyHint: (code: Code) => ( + <> + Run {code("twcore control-key")} on the server to get it. Saved only on this computer, readable by the current user alone. + + ), + keySaved: "Saved on this computer", + replaceKey: "Replace", + test: "Test connection", + testHint: "Completes the handshake and reads the server's core version.", + testOk: (version: string, gateway: string | null) => + `Connected · core ${version}${gateway ? ` · gateway ${gateway}` : ""}`, + saveAndSwitch: "Save and switch", + enterName: "Enter a name", + nameTaken: "A connection with this name already exists", + enterHost: "Enter an address", + badHost: "The address is not valid", + badPort: "The port must be a whole number from 1 to 65535", + enterKey: "Enter the key", + badKey: "The key must be 64 hexadecimal characters", + + unreachable: (addr: string) => `Cannot connect to ${addr}`, + unreachableNext: (code: Code) => ( + <>Check the address and port, and whether {code("listen.control.remote")} is enabled in the server's config. + ), + timeoutNext: (code: Code) => ( + <> + The connection timed out. Check the address and port, and whether {code("listen.control.remote")} is + enabled in the server's config. + + ), + closed: "The server closed the connection", + closedNext: (code: Code) => ( + <>This computer's address may be missing from {code("listen.control.remote.allow_from")} in the server's config. + ), + wrongKey: "The key is not correct", + wrongKeyNext: (code: Code) => <>Run {code("twcore control-key")} on the server to see the current key., + mismatch: (theirs: string, ours: string) => + `Version mismatch: the server runs core ${theirs}; this app needs ${ours}`, + mismatchNext: "Upgrade core on the server, then connect again.", + unsupported: "This version cannot connect to a remote core yet", + unsupportedNext: "The server is reachable. Connecting becomes possible after an app update.", + reasonTimeout: "The connection timed out", + reasonUnreachable: "The address is not reachable", + + testingTitle: (name: string) => `Connecting to ${name}…`, + failedTitle: (name: string) => `Cannot switch to ${name}`, + confirmTitle: (name: string) => `Switch to ${name}?`, + adoptedWarn: (n: number, addr: string) => + n === 1 + ? `1 connected client still points to the local gateway ${addr}.` + : `${n} connected clients still point to the local gateway ${addr}.`, + adoptedWarnNext: (name: string) => + `After the switch, the local gateway stops and their requests fail until the app switches back, or until they are pointed at ${name} on the Clients page.`, + retarget: (name: string) => `Also point these clients at ${name}`, + localStops: "The local core stops once in-progress requests finish", + localKept: "; its config, keys and request history are kept.", + remoteConfig: (name: string) => + `After the switch, the Upstreams, Routing, Keys and other pages show and change the config on ${name}.`, + switchAction: "Switch", + editConnection: "Edit connection", + retargetFailed: "The clients could not be pointed at the server. They can be changed one by one on the Clients page.", + keyUnreadable: "The key saved on this computer could not be read. Enter it again by editing the connection.", + gone: "This connection has been deleted.", + + cannotConnect: (name: string) => `Cannot connect to ${name}`, + connectingTo: (name: string) => `Connecting to ${name}`, + address: "Address", + reason: "Reason", + retry: "Retry", + retryIn: (attempt: number, secs: number) => `Attempt ${attempt}, in ${secs} s`, + retrying: (attempt: number) => `Attempt ${attempt}, connecting`, + retryNow: "Retry now", + switchToLocal: isMac ? "Switch to this Mac" : "Switch to this computer", + autoResume: "Once the connection is restored, the app returns to the page that was open.", + mismatchTitle: (name: string) => `The core on ${name} does not match this app`, + server: "Server", + appNeeds: "This app needs", + runOnServer: "Run on the server:", + upgradeCommand: "twcore upgrade", + reconnect: "Reconnect", + localDown: "The local core is not running", + restartLocal: "Restart", + + lostBanner: (name: string, attempt: number) => + attempt > 0 + ? `Disconnected from ${name}. Reconnecting (attempt ${attempt}). The page shows the state before the disconnect and cannot be changed for now.` + : `Disconnected from ${name}. Reconnecting. The page shows the state before the disconnect and cannot be changed for now.`, + + pickTitle: "Choose a connection", + pickOption: `${holdZh} was held at startup. Choose the core to connect to this time.`, + pickUnfinished: "The last startup did not finish. Choose the core to connect to this time.", + lastUsed: "Last used", + connect: "Connect", + }, +); diff --git a/src/connection/describe.test.tsx b/src/connection/describe.test.tsx new file mode 100644 index 00000000..ae8746db --- /dev/null +++ b/src/connection/describe.test.tsx @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { setLang } from "@/i18n"; +import type { ConnectError } from "./api"; +import { describeError, shortReason } from "./describe"; + +const ALL: ConnectError[] = [ + { kind: "unreachable", addr: "192.168.1.20:8789" }, + { kind: "timeout", addr: "192.168.1.20:8789" }, + { kind: "closed", addr: "192.168.1.20:8789" }, + { kind: "wrong_key" }, + { kind: "version_mismatch", ours: "0.48.0", theirs: "0.47.2" }, + { kind: "not_yet_available" }, +]; + +describe("连接失败的原因", () => { + /** 每一种都要说出发生了什么和下一步:设计稿 ③ 的四种说法,外加还没接上的那一种 */ + it("每一种都有标题和下一步,两种语言都是", () => { + for (const lang of ["zh", "en"] as const) { + setLang(lang); + for (const e of ALL) { + const d = describeError(e); + expect(d.title, `${lang} ${e.kind}`).toBeTruthy(); + expect(d.next, `${lang} ${e.kind}`).toBeTruthy(); + expect(shortReason(e), `${lang} ${e.kind}`).toBeTruthy(); + } + } + setLang("zh"); + }); + + it("版本不一致说出两个版本号", () => { + setLang("zh"); + const d = describeError({ kind: "version_mismatch", ours: "0.48.0", theirs: "0.47.2" }); + expect(d.title).toBe("版本不一致:服务器 core 0.47.2,本应用需要 0.48.0"); + }); + + it("超时和地址不通标题一样,下一步不一样", () => { + setLang("zh"); + const a = describeError({ kind: "timeout", addr: "h:1" }); + const b = describeError({ kind: "unreachable", addr: "h:1" }); + expect(a.title).toBe("无法连接到 h:1"); + expect(a.title).toBe(b.title); + expect(shortReason({ kind: "timeout", addr: "h:1" })).toBe("连接超时"); + }); +}); diff --git a/src/connection/describe.tsx b/src/connection/describe.tsx new file mode 100644 index 00000000..513196c9 --- /dev/null +++ b/src/connection/describe.tsx @@ -0,0 +1,57 @@ +import type { ReactNode } from "react"; +import { textOf } from "@/i18n"; +import type { ConnectError } from "./api"; +import { connText } from "./connection.i18n"; + +/** 句中的配置项、命令:等宽 */ +export function code(text: string): ReactNode { + return {text}; +} + +/** + * 连不上的原因说成两句:**发生了什么**,和**下一步做什么**。 + * + * 四种都给出下一步(设计稿 ③)。「被关闭」只能说「可能」:core 对允许列表之外的地址 + * accept 之后直接关、一个字节不回,应用分不出那是不是唯一的原因。 + */ +export function describeError(e: ConnectError): { title: string; next: ReactNode } { + const t = textOf(connText); + switch (e.kind) { + case "unreachable": + return { title: t.unreachable(e.addr), next: t.unreachableNext(code) }; + case "timeout": + return { title: t.unreachable(e.addr), next: t.timeoutNext(code) }; + case "closed": + return { title: t.closed, next: t.closedNext(code) }; + case "wrong_key": + return { title: t.wrongKey, next: t.wrongKeyNext(code) }; + case "version_mismatch": + return { title: t.mismatch(e.theirs, e.ours), next: t.mismatchNext }; + case "not_yet_available": + return { title: t.unsupported, next: t.unsupportedNext }; + } +} + +/** 未连接页「原因」那一格:一个短语,不带下一步(下一步是页面上的按钮) */ +export function shortReason(e: ConnectError): string { + const t = textOf(connText); + switch (e.kind) { + case "timeout": + return t.reasonTimeout; + case "unreachable": + return t.reasonUnreachable; + case "closed": + return t.closed; + case "wrong_key": + return t.wrongKey; + case "version_mismatch": + return t.mismatch(e.theirs, e.ours); + case "not_yet_available": + return t.unsupported; + } +} + +/** 本机那一条的名字按界面语言写,远程的用用户起的名字 */ +export function profileName(p: { local: boolean; name: string }): string { + return p.local ? textOf(connText).local : p.name; +} diff --git a/src/connection/useConnection.ts b/src/connection/useConnection.ts new file mode 100644 index 00000000..577ad5ae --- /dev/null +++ b/src/connection/useConnection.ts @@ -0,0 +1,31 @@ +import { useEffect, useState } from "react"; +import { listen } from "@tauri-apps/api/event"; +import { connApi, type ConnView } from "./api"; + +/** + * 连接列表和当前连接的状态。**推过来的**(`connection` 事件),挂上时先读一次。 + * + * 先挂监听再读,顺序不能反:两者之间发生的那一次变化会丢。 + */ +export function useConnection(): ConnView | null { + const [view, setView] = useState(null); + useEffect(() => { + let alive = true; + const un = listen("connection", (e) => { + if (alive) setView(e.payload); + }); + void un + .then(() => connApi.view()) + .then((v) => { + if (alive) setView(v); + }) + .catch(() => { + /* 不在应用里(隔离预览没 mock 它):没有连接这一层 */ + }); + return () => { + alive = false; + void un.then((f) => f()); + }; + }, []); + return view; +} diff --git a/src/launch/LaunchScreen.tsx b/src/launch/LaunchScreen.tsx index 2977dbb5..1b65d8d8 100644 --- a/src/launch/LaunchScreen.tsx +++ b/src/launch/LaunchScreen.tsx @@ -8,6 +8,7 @@ import { errorText } from "@/i18n/core.i18n"; import { troubleText } from "./trouble.i18n"; import { launchText } from "./LaunchScreen.i18n"; import { launchPhase } from "./phase"; +import { connText } from "@/connection/connection.i18n"; /** * 至少停多久。**冷启动每次都停**:秒开也不跳过,四笔要画完、亮一下。 @@ -49,6 +50,7 @@ const STROKES: [string, number][] = [ * 用户本来在看数据,整窗盖住比留着旧值加一句说明更糟。 */ export function LaunchScreen({ + remote, state, linked, tries, @@ -56,6 +58,12 @@ export function LaunchScreen({ ready, onGone, }: { + /** + * 连的是远程 core 时是它的名字。**那时这一面只说「正在连接」**:本机 core 的那几种 + * 状态(起不来、安全模式)和它无关,重启本机 core 也不是出路 —— 连不上的原因和出路 + * 在交接之后的「未连接」页上 + */ + remote: string | null; /** `core_state` 的字符串 */ state: string; /** 状态读到过了 */ @@ -71,6 +79,7 @@ export function LaunchScreen({ }) { const t = useText(launchText); const tt = useText(troubleText); + const ct = useText(connText); const born = useRef(performance.now()); const [leaving, setLeaving] = useState(false); const [elapsed, setElapsed] = useState(0); @@ -98,7 +107,10 @@ export function LaunchScreen({ return () => clearInterval(h); }, [leaving]); - const { what, problem } = launchPhase(state, linked, tries, linkError); + const { what, problem } = + remote !== null && !linked + ? { what: ct.connectingTo(remote), problem: null } + : launchPhase(state, linked, tries, linkError); const sub = failedAction ?? problem?.next ?? diff --git a/src/main.tsx b/src/main.tsx index ba179a01..0951e756 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,6 +4,7 @@ import { getCurrentWindow } from "@tauri-apps/api/window"; import { listen } from "@tauri-apps/api/event"; import App from "./App"; import UpdateWindow from "./UpdateWindow"; +import PickerWindow from "./connection/PickerWindow"; import { setLang, type Lang } from "./i18n"; import "./index.css"; @@ -23,7 +24,8 @@ function windowLabel(): string { } } -const Root = windowLabel() === "update" ? UpdateWindow : App; +const label = windowLabel(); +const Root = label === "update" ? UpdateWindow : label === "picker" ? PickerWindow : App; ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index cad21008..e09a7a36 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -30,3 +30,5 @@ export { SlidersHorizontal as IconSettings } from "lucide-react"; // 设置 — export { PanelLeft as IconSidebar } from "lucide-react"; // 收起/展开源列表 export { Copy as IconCopy } from "lucide-react"; // 复制 —— 两张叠着的纸 export { Check as IconCopied } from "lucide-react"; // 已复制 —— 一个勾 +export { Monitor as IconLocal } from "lucide-react"; // 本机这一条连接 —— 一台显示器 +export { Network as IconRemote } from "lucide-react"; // 远程连接 —— 连到另一台机器