Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,19 @@ impl AccountStore {
public.insert(key.to_string(), record.get(key).cloned().unwrap_or(Value::Null));
}
public.insert("enabled".to_string(), Value::Bool(record.enabled()));
public.insert("hasRefreshToken".to_string(), Value::Bool(false));
// ── 手动「刷新 Token」入口的开关,按插件申报的续期能力给 ────────
// 原先恒写 `false`,于是**插件家一律没有刷新按钮** —— 哪怕插件
// 申报了 `refresh: true` 并且实现了 `token.refresh`
// (OfficeAce / CodeArts 两家都是),用户在凭证过期时只剩
// 「重新登录」一条路。界面读的就是这个字段
// (`hasRefreshToken ? <刷新项> : …`),点下去走
// `POST /api/accounts/refresh` → 适配器的 `refresh_access_token`。
public.insert(
"hasRefreshToken".to_string(),
Value::Bool(crate::server::core::plugin_host::refresh_capable(
&record.provider(),
)),
);
public.insert("tokenTail".to_string(), Value::Null);
// 插件自己的展示字段(uid / 邮箱 / 余额……):原样并进来。撞键时
// 插件赢 —— 它比网关更清楚自己那条账号该显示什么。
Expand All @@ -311,6 +323,47 @@ impl AccountStore {
public.insert(key.clone(), value.clone());
}
}
// ── 但**用户设过的备注名**要赢回来,这是上面「插件赢」的唯一例外 ──
// 备注名走 `PATCH /api/accounts/{id}`,只写记录顶层的 `name` +
// `nameCustom`(见 `apply_patch`);而 12 家插件都会往
// `pluginPublic.name` 里写它按自己口径算的展示名(邮箱、app key 前缀…)。
// 不把备注名补回来,用户改完就在列表里纹丝不动 —— 前端 `displayNameOf`
// 在 `nameCustom` 为真时读的正是这个 `name`,读到的是插件那份旧的。
//
// 判据用 `nameCustom` 而不是「顶层 name 非空」:插件在添加账号时写的
// 种子名也落在顶层,拿它当用户意图会把默认名误判成备注名。
if record
.get("nameCustom")
.and_then(Value::as_bool)
.unwrap_or(false)
{
if let Some(name) = record.get("name") {
public.insert("name".to_string(), name.clone());
}
}
// ── 有效期:按插件**申报的键名**从 secret 取值 ────────────────
// 协议里 `AccountFeatures::expiry` 就是为这一列准备的(各家键名真的
// 不同:OrcaTerm 是 `tokenExpireAt`、比别家少一个 s),插件在握手时
// 申报「我用哪个键表示到期」,网关在这里按名取值放上公开视图。
//
// 之前没人读这个申报,于是插件家的有效期列**永远是空的** —— 而续期
// 其实已经把新值写进 secret 了(见 `plugin_update_secret` 的
// `expiresAt`),只是从没暴露到公开视图这一层。
//
// 只在申报了键名时才读:没申报的家(旧插件 / 没有到期概念的家)保持
// 原有形态,界面按自己的兜底表显示「—」,不凭空猜一个字段名出来。
// 值放在**申报的那个键名**下(不是统一改名 `expiresAt`)—— 前端
// `expiry` 表就是按各家自己的键名取的。
if let Some(features) =
crate::server::core::plugin_host::account_features_of(&record.provider())
{
let secret = secret_of(record);
for key in &features.expiry {
if let Some(value) = secret.get(key).filter(|value| !value.is_null()) {
public.insert(key.clone(), value.clone());
}
}
}
Value::Object(public)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,25 @@ impl AccountStore {
"limiters".to_string(),
crate::server::core::limiter::effective_rules_json_in(record.fields()),
);
// ── 出网代理:跨家统一注入(插件家否则**永远显示「直连」**)──
// 与 `maxConcurrent` 完全同一个坑,而且更隐蔽:它是「值对了但
// 读不到」而不是「值丢了」。`apply_patch` 把 `proxy` 正确写进
// 记录(`record.set_proxy`),但公开形态原先只有各家的
// `to_*_public_account` 里各自投影一份 —— 插件家走
// `to_plugin_public_account`,那个骨架里没有 `proxy`,于是:
// · 库里明明存着 `{source:"pool",proxyId:"…"}`;
// · 界面读到 `proxy: undefined` → 整列显示「直连」;
// · 再选一次同一条时 `apply_patch` 判「值没变」→ `changes: []`
// → 前端仍弹「✅ 代理已更新」(假成功),用户点了无数次
// 也不会变 —— 实测在 Cline Free 上复现。
//
// 恒为「对象或 null」而不是缺键:`null` 就是直连(与写入侧
// `normalize_account_proxy` 的 `None → Value::Null` 同一口径),
// 前端 `ProxyCell` 按 `proxy?.config?.source` 读,两种形态都能吃。
fields.insert(
"proxy".to_string(),
describe_account_proxy(Some(&record.proxy())),
);
// 跨家统一注入的**账号展示名**:与前端 `displayNameOf` 同口径的
// 唯一后端实现(规则见本文件底部的 `display_name_of`)。
// 恒为非空字符串(`displayName` 全兜底失效时退 id),请求日志 /
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@
//! 展示。真正会抛错的只有「整个请求都发不出去」那一层(由 HTTP 客户端负责)。
//!
//! ── 锁与 panic 的两条硬约束 ─────────────────────────────────
//! 1. **不持锁跨 await**:账号快照与逐账号的临期判定都是同步的(各自的取数
//! 函数内部取锁即释放),网络刷新在锁外逐个 await;
//! 1. **不持锁跨 await**:账号快照取完即释放锁;临期判定会 await(插件家
//! 要问它自己的存储,见下),但那些取数函数**内部取锁即释放**,await
//! 期间本模块不持有任何锁;网络刷新同样在锁外逐个 await;
//! 2. **不 unwrap/expect**:本模块在后台任务与 HTTP handler 两条路径上跑,
//! 而 release 是 `panic = "abort"`,任何一处 panic 都会带走整个应用。
//!
Expand Down Expand Up @@ -139,7 +140,15 @@ pub async fn refresh_expiring_accounts(store: &AccountStore) -> Vec<Value> {
}
// 临期判定的详细理由见 trait 的 `credentials_expiring`:
// 判不出来(没有过期信息 / 账号不存在 / 没有 refreshToken)一律 false
if !adapter.credentials_expiring(store, &id) {
//
// ── 必须走**异步**版(插件家的凭证要问它自己)────────────────
// 同步版在插件适配器上是硬编码 `false`(`RpcAdapter::credentials_expiring`),
// 它的实现说明写着「真正的实现在 `credentials_expiring_async`」。
// 调同步版会让**所有插件家**在这一步被判成「未临期」而永远跳过 ——
// 症状是凭证过期后每轮维护都报「无需刷新」,接口一路 401/过期错误,
// 而真正能续期的 `token.expiring` RPC 一次都没被问过。
// 内置家的异步版默认转调同步版(见 trait),所以这一改对它们无影响。
if !adapter.credentials_expiring_async(store, &id).await {
slots[index] = Some(skipped(&id, provider, "凭证未过期或临期"));
continue;
}
Expand Down
24 changes: 24 additions & 0 deletions desktop-tauri/src-tauri/server/src/server/core/plugin_host/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,30 @@ pub fn account_features_of(provider: &str) -> Option<proto::AccountFeatures> {
found
}

/// 某一家申报的「有没有续期能力」(没装 / 没这一家 = `false`)。
///
/// ── 为什么要有这个 store 层的读数入口 ──────────────────────────
/// 「手动刷新 Token」这个入口由界面按公开视图的 `hasRefreshToken` 决定是否
/// 渲染,而公开视图在 `account_store` 层拼装 —— 那里拿不到适配器(插件家
/// 的 id 也不在 `ProviderKind` 枚举里)。`plugin_update_secret` 那边的
/// `hasRefreshToken` 原先恒写 `false`,于是**插件家一律没有刷新按钮**,
/// 哪怕插件明明白白申报了 `refresh: true` 并且实现了 `token.refresh`。
///
/// 与 [`account_features_of`] 同一形态:读握手带回的 `ProviderInfo`。
pub fn refresh_capable(provider: &str) -> bool {
let Some(host) = current() else {
return false;
};
let Ok(guard) = host.providers.lock() else {
return false;
};
guard
.iter()
.find(|info| info.id.as_str() == provider)
.map(|info| info.capabilities.refresh)
.unwrap_or(false)
}

/// 某一家申报的签到能力(没申报 = `None`)。
pub fn checkin_of(provider: &str) -> Option<proto::CheckinInfo> {
let host = current()?;
Expand Down
Loading