Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,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 上已经拉进来的那一份,不多编一套
Expand Down
29 changes: 29 additions & 0 deletions src-tauri/build.rs
Original file line number Diff line number Diff line change
@@ -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())
);
}
13 changes: 13 additions & 0 deletions src-tauri/capabilities/picker.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
76 changes: 57 additions & 19 deletions src-tauri/src/clients/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,14 @@ fn backups() -> PathBuf {
/// 客户端要连的那台机器。
///
/// **本地模式下是回环**:网关就在这台机器上,监听地址(可能是 `0.0.0.0`)填进
/// 客户端配置里是连不上的。连远程 core 时,这里换成服务器的地址 —— 客户端页
/// 写进配置、复制出去的地址都从这一个地方来。
pub(crate) fn gateway_host(_state: &AppState) -> String {
"127.0.0.1".to_string()
/// 客户端配置里是连不上的。连远程 core 时是连接里填的那个服务器地址 —— 这台机器
/// 就是用它够到服务器的(服务器自己报的监听地址可能是 `0.0.0.0`)。客户端页写进
/// 配置、复制出去的地址都从这一个地方来。
pub(crate) fn gateway_host(state: &AppState) -> String {
match state.link.current() {
crate::connection::Current::Remote(r) => r.host,
crate::connection::Current::Local => "127.0.0.1".to_string(),
}
}

/// 客户端该连的地址:`http://主机:网关端口`。端口按 core 的配置(`listen.gateway.port`),
Expand Down Expand Up @@ -183,24 +187,23 @@ pub async fn reveal_client_config(id: String) -> Out<()> {
Ok(reveal::reveal(&d.real.display().to_string())?)
}

/// 把这台机器上接管着的客户端全部重新指一次:指向 `control` 那个 core 的网关,
/// 用它为每个客户端发的那把密钥。
/// 把这台机器上接管着、**还指着本机网关**的客户端改为指向 `control` 那个 core 的
/// 网关(`host` 是它的地址),用它为每个客户端发的那把密钥。
///
/// 从本机切到远程 core 时,确认框里「同时将这些客户端改为指向服务器」勾上就走这里
/// (`host` 是服务器的地址)。**一个失败不影响其余的**,逐个报。
pub async fn retarget_adopted(
control: &ControlClient,
host: &str,
) -> Out<(Vec<wire::KeySynced>, Vec<wire::KeySyncFailed>)> {
/// 从本机切到远程 core 时,确认框里「同时将这些客户端改为指向…」勾上就走这里;连着
/// 远程时客户端页上的「改为指向服务器」也是这一步。**一个失败不影响其余的**,逐个报。
pub async fn retarget_adopted(control: &ControlClient, host: &str) -> Out<wire::Retargeted> {
let base = gateway_base(control, host).await?;
let home = home_dir();
let mut done = Vec::new();
let mut failed = Vec::new();
for c in ops::adopted(&home) {
let mut out = wire::Retargeted {
synced: Vec::new(),
failed: Vec::new(),
};
for (c, _) in ops::adopted_on_this_machine(&home) {
let key = match prepare_key(control, c.id).await {
Ok(k) => k.key,
Err(e) => {
failed.push(wire::KeySyncFailed {
out.failed.push(wire::KeySyncFailed {
client: c.id.to_string(),
name: c.name.to_string(),
error: e.into_msg(),
Expand All @@ -209,11 +212,46 @@ pub async fn retarget_adopted(
}
};
match ops::repoint(&home, &backups(), &c, &base, &key) {
Ok(s) => done.push(s),
Err(f) => failed.push(f),
Ok(s) => out.synced.push(s),
Err(f) => out.failed.push(f),
}
}
Ok((done, failed))
Ok(out)
}

/// 客户端页上的「改为指向服务器」:还指着本机网关的,改为指向此刻连着的那个 core。
/// **连着本机时什么都不做** —— 指着本机网关本来就是对的
#[tauri::command]
pub async fn retarget_clients(state: tauri::State<'_, AppState>) -> Out<wire::Retargeted> {
if !state.link.is_remote() {
return Ok(wire::Retargeted {
synced: Vec::new(),
failed: Vec::new(),
});
}
retarget_adopted(&state.control, &gateway_host(&state)).await
}

/// 这台机器上已接管、还指着本机网关的客户端。切到远程之前的确认里说(「已接管的 3 个
/// 客户端仍指向本机网关 127.0.0.1:8788」)
#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct Adopted {
pub count: usize,
/// 它们指着的那个地址(`127.0.0.1:8788`)
pub local_addr: Option<String>,
}

/// 数一数这台机器上已接管、还指着本机网关的客户端。**按这台机器上的文件数**,不问
/// 哪个 core:连着远程、本机 core 停着的时候也数得出来
pub fn adopted_pointing_at_local() -> Adopted {
let here = ops::adopted_on_this_machine(&home_dir());
Adopted {
count: here.len(),
local_addr: here
.first()
.and_then(|(_, e)| ops::host_port(e))
.map(str::to_string),
}
}

/// 更换密钥之后,把新值写进正在用它的那个客户端的配置。
Expand Down
96 changes: 96 additions & 0 deletions src-tauri/src/clients/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,47 @@ pub fn adopted(home: &Path) -> Vec<Client> {
.collect()
}

/// 此刻接管着、**配置里指着这台机器上的网关**的客户端,连同它此刻的端点。
///
/// 连着远程 core 时,它们的请求落到一个已经停了的网关上:客户端页把它们单独标出来,
/// 切换确认里说有几个,「改为指向服务器」改的也正是这几个。指着别处的(已经改过去
/// 了、或者用户自己指到了别的机器)不在里面。
pub fn adopted_on_this_machine(home: &Path) -> Vec<(Client, String)> {
clients::adoptable()
.into_iter()
.filter_map(|c| {
let d = detect::detect_one(&c, home);
d.adopted_at_ms?;
let endpoint = d.endpoint?;
is_loopback(&endpoint).then_some((c, endpoint))
})
.collect()
}

/// 一个端点指的是不是这台机器:`127.0.0.1`、`localhost`、`[::1]`
pub fn is_loopback(endpoint: &str) -> bool {
host_port(endpoint).is_some_and(|hp| {
let host = match hp.rsplit_once(':') {
// `[::1]:8788` 这种,方括号里才是主机
Some((h, p)) if p.chars().all(|c| c.is_ascii_digit()) => h,
_ => hp,
};
let host = host.trim_start_matches('[').trim_end_matches(']');
host == "localhost"
|| host == "::1"
|| host
.parse::<std::net::Ipv4Addr>()
.is_ok_and(|a| a.is_loopback())
})
}

/// `http://127.0.0.1:8788/v1` → `127.0.0.1:8788`
pub fn host_port(endpoint: &str) -> Option<&str> {
let rest = endpoint.split_once("://").map_or(endpoint, |(_, r)| r);
let hp = rest.split(['/', '?', '#']).next()?;
(!hp.is_empty()).then_some(hp)
}

/// 这把密钥是为某个客户端生成的,而那个客户端此刻正被接管着吗。返回那个客户端。
///
/// 接管状态在对方配置旁边的记录里,读它要走文件系统 —— 所以这件事只有这台机器
Expand Down Expand Up @@ -616,6 +657,61 @@ mod tests {
assert!(adopted_owner(home.path(), &keys, "default").is_none());
}

#[test]
fn a_loopback_endpoint_is_this_machine_and_a_server_is_not() {
for e in [
"http://127.0.0.1:8788",
"http://127.0.0.1:8788/v1",
"http://localhost:8788/v1",
"http://[::1]:8788",
] {
assert!(is_loopback(e), "{e}");
}
for e in [
"http://192.168.1.20:8788/v1",
"http://nas.local:8788",
"",
"http://",
] {
assert!(!is_loopback(e), "{e}");
}
assert_eq!(
host_port("http://127.0.0.1:8788/v1"),
Some("127.0.0.1:8788")
);
}

/// 接管着、指着本机网关的才算;指到了服务器上的不算
#[test]
fn only_clients_pointing_at_this_machine_count() {
let home = home_with_claude();
std::fs::create_dir_all(home.path().join(".codex")).unwrap();
adopt(
home.path(),
&backups(&home),
"claude-code",
"http://127.0.0.1:8788",
"tw-c",
)
.unwrap();
adopt(
home.path(),
&backups(&home),
"codex",
"http://192.168.1.20:8788",
"tw-x",
)
.unwrap();
let here: Vec<_> = adopted_on_this_machine(home.path())
.into_iter()
.map(|(c, e)| (c.id, e))
.collect();
assert_eq!(
here,
vec![("claude-code", "http://127.0.0.1:8788".to_string())]
);
}

/// 换了密钥之后重新指一次:新值写进它的配置
#[test]
fn repointing_writes_the_new_key_into_the_clients_config() {
Expand Down
Loading
Loading