fix(security): authenticate marketplace writes with GitHub OAuth (#816)

* fix(security): authenticate marketplace writes with OAuth

* fix(security): harden marketplace OAuth lifecycle

* fix(security): enforce marketplace credential invalidation

* fix(security): reject public marketplace redirects

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
TRIPandsim authored and GitHub committed 2026-07-15 14:20:29 +08:00
1 parent 9991b325b8
commit 472b914985
14 files changed
+2482 -320

No files matched your search

@@ -1,24 +1,15 @@
use super::*;
// ─────────────────────── GitHub OAuth Device Flow (Phase 1) ───────────────────────
//
// 客户端直连 GitHub 拿 access_token + login,前端自动把 login 写进
// prefs.marketplaceDevLogin。marketplace backend 完全不动(依然 X-Dev-User)。
// Phase 2 才会让 backend 验证 GitHub identity(JWT 签发 + 防伪造)。
//
// 配置 client_id 的两种方式(OAuth App client_id 非敏感,可硬编码):
// 1. 在下方 GITHUB_OAUTH_CLIENT_ID 常量填值(生产推荐 — 直接 bake 进二进制)
// 2. 启动前设置环境变量 GITHUB_OAUTH_CLIENT_ID=<your_client_id>(dev 方便)
//
// 注册 OAuth App:
// https://github.com/settings/applications/new
// - Application name: OpenLess (or your fork)
// - Homepage URL: https://openless.top (or任意)
// - Authorization callback URL: https://openless.top (Device Flow 不真用,但表单要求填)
// - 创建后在 General 页面勾选 "Enable Device Flow"
// - 抄 client_id 填到本常量
use parking_lot::Mutex;
use std::sync::OnceLock;
use std::time::{Duration, Instant};
const GITHUB_OAUTH_CLIENT_ID: &str = "Ov23liyv3nEucG7oMHNE";
const GITHUB_DEVICE_CODE_URL: &str = "https://github.com/login/device/code";
const GITHUB_ACCESS_TOKEN_URL: &str = "https://github.com/login/oauth/access_token";
const GITHUB_USER_URL: &str = "https://api.github.com/user";
const FLOW_CANCELLED: &str = "OAuth 登录已取消,请重新发起登录";
const FLOW_EXPIRED: &str = "OAuth 设备码已过期,请重新发起登录";
fn get_github_oauth_client_id() -> Result<String, String> {
if let Ok(env_id) = std::env::var("GITHUB_OAUTH_CLIENT_ID") {
@@ -30,60 +21,28 @@ fn get_github_oauth_client_id() -> Result<String, String> {
if !GITHUB_OAUTH_CLIENT_ID.is_empty() {
return Ok(GITHUB_OAUTH_CLIENT_ID.to_string());
}
Err(
"GitHub OAuth 未配置。请去 https://github.com/settings/applications/new 注册一个 OAuth App\
(必须勾 Enable Device Flow),把 client_id 填到 \
openless-all/app/src-tauri/src/commands.rs 的 GITHUB_OAUTH_CLIENT_ID 常量,\
或在启动前设置环境变量 GITHUB_OAUTH_CLIENT_ID=<your_client_id>。"
.to_string(),
)
Err("GitHub OAuth 未配置".to_string())
}
#[derive(Clone)]
struct SecretDeviceCode(String);
impl std::fmt::Debug for SecretDeviceCode {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("[REDACTED]")
}
}
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GithubDeviceStartResponse {
pub device_code: String,
pub flow_id: String,
pub user_code: String,
pub verification_uri: String,
pub interval: u32,
pub expires_in: u32,
}
#[tauri::command]
pub async fn github_device_flow_start() -> Result<GithubDeviceStartResponse, String> {
let client_id = get_github_oauth_client_id()?;
let resp = net::send_with_retry(|| {
net::http()
.post("https://github.com/login/device/code")
.header("Accept", "application/json")
.header("User-Agent", "OpenLess")
.timeout(std::time::Duration::from_secs(15))
.form(&[("client_id", client_id.as_str()), ("scope", "read:user")])
})
.await
.map_err(|e| format!("调用 GitHub /login/device/code 失败:{e}"))?;
let status = resp.status();
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("解析 device/code 响应失败:{e}"))?;
if !status.is_success() {
let err = body["error"].as_str().unwrap_or("unknown_error");
let desc = body["error_description"].as_str().unwrap_or("");
return Err(format!("GitHub device/code {status} {err}: {desc}"));
}
Ok(GithubDeviceStartResponse {
device_code: body["device_code"].as_str().unwrap_or("").to_string(),
user_code: body["user_code"].as_str().unwrap_or("").to_string(),
verification_uri: body["verification_uri"]
.as_str()
.unwrap_or("https://github.com/login/device")
.to_string(),
interval: body["interval"].as_u64().unwrap_or(5) as u32,
expires_in: body["expires_in"].as_u64().unwrap_or(900) as u32,
})
}
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum GithubDevicePollResult {
@@ -93,62 +52,703 @@ pub enum GithubDevicePollResult {
Error { message: String },
}
#[derive(Clone)]
struct ActiveGithubDeviceFlow {
flow_id: String,
generation: u64,
device_code: SecretDeviceCode,
expires_at: Instant,
interval: Duration,
last_poll_at: Option<Instant>,
}
#[derive(Clone)]
struct GithubDevicePollLease {
flow_id: String,
generation: u64,
device_code: SecretDeviceCode,
}
enum PollPermit {
Ready(GithubDevicePollLease),
TooSoon,
Invalid(&'static str),
}
#[derive(Default)]
struct GithubDeviceFlowRegistry {
generation: u64,
active: Option<ActiveGithubDeviceFlow>,
}
impl GithubDeviceFlowRegistry {
fn begin_start(&mut self) -> u64 {
self.generation = self.generation.wrapping_add(1);
self.active = None;
self.generation
}
fn activate(
&mut self,
generation: u64,
flow_id: String,
device_code: SecretDeviceCode,
expires_at: Instant,
interval: Duration,
) -> Result<(), String> {
if generation != self.generation {
return Err(FLOW_CANCELLED.to_string());
}
self.active = Some(ActiveGithubDeviceFlow {
flow_id,
generation,
device_code,
expires_at,
interval,
last_poll_at: None,
});
Ok(())
}
fn invalidate_generation(&mut self, generation: u64) {
if self.generation == generation {
self.generation = self.generation.wrapping_add(1);
self.active = None;
}
}
fn cancel(&mut self, flow_id: Option<&str>) {
let should_cancel = match (flow_id, self.active.as_ref()) {
(Some(expected), Some(active)) => active.flow_id == expected,
(Some(_), None) => false,
(None, _) => true,
};
if should_cancel {
self.generation = self.generation.wrapping_add(1);
self.active = None;
}
}
fn poll_permit(&mut self, flow_id: &str, now: Instant) -> PollPermit {
let Some(active) = self.active.as_mut() else {
return PollPermit::Invalid(FLOW_CANCELLED);
};
if active.flow_id != flow_id {
return PollPermit::Invalid(FLOW_CANCELLED);
}
if now >= active.expires_at {
self.generation = self.generation.wrapping_add(1);
self.active = None;
return PollPermit::Invalid(FLOW_EXPIRED);
}
if active
.last_poll_at
.is_some_and(|last| now.saturating_duration_since(last) < active.interval)
{
return PollPermit::TooSoon;
}
active.last_poll_at = Some(now);
PollPermit::Ready(GithubDevicePollLease {
flow_id: active.flow_id.clone(),
generation: active.generation,
device_code: active.device_code.clone(),
})
}
fn lease_is_active(&mut self, lease: &GithubDevicePollLease, now: Instant) -> bool {
let Some(active) = self.active.as_ref() else {
return false;
};
if now >= active.expires_at {
self.generation = self.generation.wrapping_add(1);
self.active = None;
return false;
}
active.generation == lease.generation && active.flow_id == lease.flow_id
}
fn apply_slow_down(&mut self, lease: &GithubDevicePollLease, now: Instant) -> bool {
if !self.lease_is_active(lease, now) {
return false;
}
if let Some(active) = self.active.as_mut() {
active.interval = active.interval.saturating_add(Duration::from_secs(5));
}
true
}
fn consume_if_active(
&mut self,
lease: &GithubDevicePollLease,
now: Instant,
save: impl FnOnce() -> Result<(), String>,
) -> Result<(), String> {
if !self.lease_is_active(lease, now) {
return Err(FLOW_CANCELLED.to_string());
}
// Save while holding the flow lock. Cancellation and successful
// consumption therefore have one atomic ordering point.
save()?;
self.generation = self.generation.wrapping_add(1);
self.active = None;
Ok(())
}
}
fn github_device_flows() -> &'static Mutex<GithubDeviceFlowRegistry> {
static FLOWS: OnceLock<Mutex<GithubDeviceFlowRegistry>> = OnceLock::new();
FLOWS.get_or_init(|| Mutex::new(GithubDeviceFlowRegistry::default()))
}
#[derive(Clone, Copy)]
struct GithubOAuthEndpoints<'a> {
device_code: &'a str,
access_token: &'a str,
user: &'a str,
}
const GITHUB_ENDPOINTS: GithubOAuthEndpoints<'static> = GithubOAuthEndpoints {
device_code: GITHUB_DEVICE_CODE_URL,
access_token: GITHUB_ACCESS_TOKEN_URL,
user: GITHUB_USER_URL,
};
fn reject_authenticated_redirect(
status: reqwest::StatusCode,
operation: &str,
) -> Result<(), String> {
if status.is_redirection() {
return Err(format!("{operation} rejected redirect"));
}
Ok(())
}
fn parse_device_start_response(
status: reqwest::StatusCode,
body: &serde_json::Value,
) -> Result<(SecretDeviceCode, String, String, u32, u32), String> {
reject_authenticated_redirect(status, "GitHub device flow")?;
if !status.is_success() {
return Err(format!("GitHub device flow HTTP {status}"));
}
let required = |name: &str| {
body[name]
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.ok_or_else(|| format!("GitHub device flow malformed response: missing {name}"))
};
let device_code = SecretDeviceCode(required("device_code")?);
let user_code = required("user_code")?;
let verification_uri = required("verification_uri")?;
let verification_url = reqwest::Url::parse(&verification_uri)
.map_err(|_| "GitHub device flow malformed verification URI".to_string())?;
if verification_url.scheme() != "https" {
return Err("GitHub device flow requires an HTTPS verification URI".to_string());
}
let interval = body["interval"]
.as_u64()
.unwrap_or(5)
.try_into()
.map_err(|_| "GitHub device flow invalid interval".to_string())?;
let expires_in = body["expires_in"]
.as_u64()
.and_then(|value| u32::try_from(value).ok())
.filter(|value| *value > 0)
.ok_or_else(|| "GitHub device flow invalid expiry".to_string())?;
if interval == 0 {
return Err("GitHub device flow invalid interval".to_string());
}
Ok((
device_code,
user_code,
verification_uri,
interval,
expires_in,
))
}
async fn github_device_flow_start_with(
endpoints: GithubOAuthEndpoints<'_>,
flows: &Mutex<GithubDeviceFlowRegistry>,
) -> Result<GithubDeviceStartResponse, String> {
let generation = flows.lock().begin_start();
let result = async {
let client_id = get_github_oauth_client_id()?;
let resp = net::send_with_retry(|| {
net::credential_http()
.post(endpoints.device_code)
.header("Accept", "application/json")
.timeout(Duration::from_secs(15))
.form(&[("client_id", client_id.as_str()), ("scope", "read:user")])
})
.await
.map_err(|_| "GitHub device flow request failed".to_string())?;
let status = resp.status();
reject_authenticated_redirect(status, "GitHub device flow")?;
let body: serde_json::Value = resp
.json()
.await
.map_err(|_| "GitHub device flow malformed response".to_string())?;
let (device_code, user_code, verification_uri, interval, expires_in) =
parse_device_start_response(status, &body)?;
let flow_id = uuid::Uuid::new_v4().to_string();
flows.lock().activate(
generation,
flow_id.clone(),
device_code,
Instant::now() + Duration::from_secs(expires_in.into()),
Duration::from_secs(interval.into()),
)?;
Ok(GithubDeviceStartResponse {
flow_id,
user_code,
verification_uri,
interval,
expires_in,
})
}
.await;
if result.is_err() {
flows.lock().invalidate_generation(generation);
}
result
}
#[tauri::command]
pub async fn github_device_flow_poll(
device_code: String,
pub async fn github_device_flow_start() -> Result<GithubDeviceStartResponse, String> {
github_device_flow_start_with(GITHUB_ENDPOINTS, github_device_flows()).await
}
fn github_login_from_verified_response(
status: reqwest::StatusCode,
body: &serde_json::Value,
) -> Result<String, String> {
reject_authenticated_redirect(status, "GitHub user verification")?;
if !status.is_success() {
return Err(format!("GitHub user verification HTTP {status}"));
}
let login = body["login"].as_str().unwrap_or("").trim();
if login.is_empty() {
return Err("GitHub user verification returned no login".to_string());
}
Ok(login.to_string())
}
async fn github_device_flow_poll_with(
flow_id: String,
endpoints: GithubOAuthEndpoints<'_>,
flows: &Mutex<GithubDeviceFlowRegistry>,
save_token: impl FnOnce(&str) -> Result<(), String>,
) -> Result<GithubDevicePollResult, String> {
let lease = match flows.lock().poll_permit(&flow_id, Instant::now()) {
PollPermit::Ready(lease) => lease,
PollPermit::TooSoon => return Ok(GithubDevicePollResult::Pending),
PollPermit::Invalid(message) => {
return Ok(GithubDevicePollResult::Error {
message: message.to_string(),
})
}
};
let client_id = get_github_oauth_client_id()?;
let token_resp = net::send_with_retry(|| {
net::http()
.post("https://github.com/login/oauth/access_token")
net::credential_http()
.post(endpoints.access_token)
.header("Accept", "application/json")
.header("User-Agent", "OpenLess")
.timeout(std::time::Duration::from_secs(15))
.timeout(Duration::from_secs(15))
.form(&[
("client_id", client_id.as_str()),
("device_code", device_code.as_str()),
("device_code", lease.device_code.0.as_str()),
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
])
})
.await
.map_err(|e| format!("调用 GitHub /login/oauth/access_token 失败:{e}"))?;
let body: serde_json::Value = token_resp
.json()
.await
.map_err(|e| format!("解析 access_token 响应失败:{e}"))?;
.map_err(|_| "GitHub token request failed".to_string())?;
let token_status = token_resp.status();
if let Err(message) = reject_authenticated_redirect(token_status, "GitHub token exchange") {
flows.lock().cancel(Some(&flow_id));
return Ok(GithubDevicePollResult::Error { message });
}
let body: serde_json::Value = match token_resp.json().await {
Ok(body) => body,
Err(_) => {
flows.lock().cancel(Some(&flow_id));
return Ok(GithubDevicePollResult::Error {
message: "GitHub token exchange malformed response".to_string(),
});
}
};
if !token_status.is_success() {
flows.lock().cancel(Some(&flow_id));
return Ok(GithubDevicePollResult::Error {
message: format!("GitHub token exchange HTTP {token_status}"),
});
}
if !flows.lock().lease_is_active(&lease, Instant::now()) {
return Ok(GithubDevicePollResult::Error {
message: FLOW_CANCELLED.to_string(),
});
}
if let Some(token) = body["access_token"].as_str() {
if let Some(token) = body["access_token"]
.as_str()
.filter(|token| !token.trim().is_empty())
{
if !flows.lock().lease_is_active(&lease, Instant::now()) {
return Ok(GithubDevicePollResult::Error {
message: FLOW_CANCELLED.to_string(),
});
}
let user_resp = net::send_with_retry(|| {
net::http()
.get("https://api.github.com/user")
.header("User-Agent", "OpenLess")
net::credential_http()
.get(endpoints.user)
.header("Accept", "application/vnd.github+json")
.timeout(std::time::Duration::from_secs(15))
.timeout(Duration::from_secs(15))
.bearer_auth(token)
})
.await
.map_err(|e| format!("调用 GitHub /user 失败:{e}"))?;
let user_body: serde_json::Value = user_resp
.json()
.await
.map_err(|e| format!("解析 /user 响应失败:{e}"))?;
let login = user_body["login"].as_str().unwrap_or("").to_string();
if login.is_empty() {
.map_err(|_| "GitHub user verification request failed".to_string())?;
let user_status = user_resp.status();
if let Err(message) = reject_authenticated_redirect(user_status, "GitHub user verification")
{
flows.lock().cancel(Some(&flow_id));
return Ok(GithubDevicePollResult::Error { message });
}
let user_body: serde_json::Value = match user_resp.json().await {
Ok(body) => body,
Err(_) => {
flows.lock().cancel(Some(&flow_id));
return Ok(GithubDevicePollResult::Error {
message: "GitHub user verification malformed response".to_string(),
});
}
};
if !flows.lock().lease_is_active(&lease, Instant::now()) {
return Ok(GithubDevicePollResult::Error {
message: "GitHub /user 返回空 login".to_string(),
message: FLOW_CANCELLED.to_string(),
});
}
let login = match github_login_from_verified_response(user_status, &user_body) {
Ok(login) => login,
Err(message) => {
flows.lock().cancel(Some(&flow_id));
return Ok(GithubDevicePollResult::Error { message });
}
};
let persisted = flows
.lock()
.consume_if_active(&lease, Instant::now(), || save_token(token));
if let Err(message) = persisted {
return Ok(GithubDevicePollResult::Error { message });
}
return Ok(GithubDevicePollResult::Authorized { login });
}
let err = body["error"].as_str().unwrap_or("");
let msg = match err {
"authorization_pending" => return Ok(GithubDevicePollResult::Pending),
"slow_down" => return Ok(GithubDevicePollResult::SlowDown),
"expired_token" => "OAuth 设备码已过期,请重新发起登录".to_string(),
"access_denied" => "你在 GitHub 上拒绝了授权".to_string(),
other if !other.is_empty() => format!("OAuth 错误:{other}"),
_ => "未知 OAuth 错误(access_token 缺失)".to_string(),
};
Ok(GithubDevicePollResult::Error { message: msg })
match body["error"].as_str().unwrap_or("") {
"authorization_pending" => Ok(GithubDevicePollResult::Pending),
"slow_down" => {
if flows.lock().apply_slow_down(&lease, Instant::now()) {
Ok(GithubDevicePollResult::SlowDown)
} else {
Ok(GithubDevicePollResult::Error {
message: FLOW_CANCELLED.to_string(),
})
}
}
"expired_token" => {
flows.lock().cancel(Some(&flow_id));
Ok(GithubDevicePollResult::Error {
message: FLOW_EXPIRED.to_string(),
})
}
"access_denied" => {
flows.lock().cancel(Some(&flow_id));
Ok(GithubDevicePollResult::Error {
message: "GitHub authorization was denied".to_string(),
})
}
_ => {
flows.lock().cancel(Some(&flow_id));
Ok(GithubDevicePollResult::Error {
message: "GitHub token exchange malformed response".to_string(),
})
}
}
}
#[tauri::command]
pub async fn github_device_flow_poll(flow_id: String) -> Result<GithubDevicePollResult, String> {
github_device_flow_poll_with(flow_id, GITHUB_ENDPOINTS, github_device_flows(), |token| {
CredentialsVault::set_marketplace_github_token(token)
.map_err(|error| format!("save Marketplace credential failed: {error}"))
})
.await
}
#[tauri::command]
pub fn github_device_flow_cancel(flow_id: Option<String>) {
github_device_flows().lock().cancel(flow_id.as_deref());
}
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MarketplaceAuthStatus {
pub signed_in: bool,
}
#[tauri::command]
pub fn marketplace_auth_status() -> Result<MarketplaceAuthStatus, String> {
let signed_in = CredentialsVault::get_marketplace_github_token()
.map_err(|error| format!("read Marketplace sign-in status failed: {error}"))?
.is_some();
Ok(MarketplaceAuthStatus { signed_in })
}
#[tauri::command]
pub fn marketplace_logout(coord: CoordinatorState<'_>) -> Result<(), String> {
super::marketplace::clear_marketplace_authentication(&coord)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::oneshot;
fn activate_test_flow(
flows: &Mutex<GithubDeviceFlowRegistry>,
now: Instant,
interval: Duration,
lifetime: Duration,
) -> String {
let mut flows = flows.lock();
let generation = flows.begin_start();
let flow_id = "opaque-flow-id".to_string();
flows
.activate(
generation,
flow_id.clone(),
SecretDeviceCode("raw-device-secret".to_string()),
now + lifetime,
interval,
)
.unwrap();
flow_id
}
#[test]
fn start_response_and_secret_debug_never_expose_device_code() {
let response = GithubDeviceStartResponse {
flow_id: "opaque".to_string(),
user_code: "ABCD-EFGH".to_string(),
verification_uri: "https://github.com/login/device".to_string(),
interval: 7,
expires_in: 600,
};
let serialized = serde_json::to_string(&response).unwrap();
assert!(!serialized.contains("deviceCode"));
assert!(!serialized.contains("raw-device-secret"));
assert_eq!(
format!("{:?}", SecretDeviceCode("secret".into())),
"[REDACTED]"
);
}
#[test]
fn registry_enforces_interval_slow_down_expiry_and_single_consumption() {
let flows = Mutex::new(GithubDeviceFlowRegistry::default());
let now = Instant::now();
let flow_id =
activate_test_flow(&flows, now, Duration::from_secs(7), Duration::from_secs(30));
let lease = match flows.lock().poll_permit(&flow_id, now) {
PollPermit::Ready(lease) => lease,
_ => panic!("first poll should run"),
};
assert!(matches!(
flows
.lock()
.poll_permit(&flow_id, now + Duration::from_secs(6)),
PollPermit::TooSoon
));
assert!(flows
.lock()
.apply_slow_down(&lease, now + Duration::from_secs(6)));
assert!(matches!(
flows
.lock()
.poll_permit(&flow_id, now + Duration::from_secs(11)),
PollPermit::TooSoon
));
assert!(matches!(
flows
.lock()
.poll_permit(&flow_id, now + Duration::from_secs(12)),
PollPermit::Ready(_)
));
assert!(matches!(
flows
.lock()
.poll_permit(&flow_id, now + Duration::from_secs(31)),
PollPermit::Invalid(FLOW_EXPIRED)
));
}
#[test]
fn cancel_after_user_response_prevents_token_save() {
let flows = Mutex::new(GithubDeviceFlowRegistry::default());
let now = Instant::now();
let flow_id =
activate_test_flow(&flows, now, Duration::from_secs(5), Duration::from_secs(60));
let lease = match flows.lock().poll_permit(&flow_id, now) {
PollPermit::Ready(lease) => lease,
_ => panic!("poll should run"),
};
flows.lock().cancel(Some(&flow_id));
let mut saved = false;
let result = flows.lock().consume_if_active(&lease, now, || {
saved = true;
Ok(())
});
assert!(result.is_err());
assert!(!saved);
}
#[test]
fn malformed_and_redirect_start_responses_fail_closed() {
assert!(parse_device_start_response(
reqwest::StatusCode::FOUND,
&serde_json::json!({"device_code":"secret"}),
)
.is_err());
assert!(parse_device_start_response(
reqwest::StatusCode::OK,
&serde_json::json!({
"device_code":"secret",
"user_code":"CODE",
"verification_uri":"https://github.com/login/device",
"interval":0,
"expires_in":900
}),
)
.is_err());
assert!(github_login_from_verified_response(
reqwest::StatusCode::FOUND,
&serde_json::json!({"login":"forged"}),
)
.is_err());
}
async fn write_json_response(
stream: &mut tokio::net::TcpStream,
body: &str,
) -> std::io::Result<()> {
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(), body
);
stream.write_all(response.as_bytes()).await
}
#[tokio::test]
async fn real_start_http_keeps_raw_device_code_inside_rust() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut request = [0u8; 2048];
let _ = stream.read(&mut request).await.unwrap();
write_json_response(
&mut stream,
r#"{"device_code":"raw-http-device-secret","user_code":"ABCD-EFGH","verification_uri":"https://github.com/login/device","interval":9,"expires_in":600}"#,
)
.await
.unwrap();
});
let endpoint = format!("http://{address}");
let endpoints = GithubOAuthEndpoints {
device_code: &endpoint,
access_token: &endpoint,
user: &endpoint,
};
let flows = Mutex::new(GithubDeviceFlowRegistry::default());
let response = github_device_flow_start_with(endpoints, &flows)
.await
.unwrap();
server.await.unwrap();
let serialized = serde_json::to_string(&response).unwrap();
assert_eq!(response.interval, 9);
assert_eq!(response.expires_in, 600);
assert!(!serialized.contains("raw-http-device-secret"));
assert!(!serialized.contains("deviceCode"));
assert_eq!(
flows
.lock()
.active
.as_ref()
.map(|active| active.device_code.0.as_str()),
Some("raw-http-device-secret")
);
}
#[tokio::test]
async fn in_flight_user_request_cancelled_before_resume_never_saves_token() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let (user_seen_tx, user_seen_rx) = oneshot::channel();
let (resume_tx, resume_rx) = oneshot::channel();
let server = tokio::spawn(async move {
let (mut token_stream, _) = listener.accept().await.unwrap();
let mut request = vec![0u8; 4096];
let _ = token_stream.read(&mut request).await.unwrap();
write_json_response(&mut token_stream, r#"{"access_token":"gho_mock_secret"}"#)
.await
.unwrap();
let (mut user_stream, _) = listener.accept().await.unwrap();
let _ = user_stream.read(&mut request).await.unwrap();
user_seen_tx.send(()).unwrap();
resume_rx.await.unwrap();
write_json_response(&mut user_stream, r#"{"login":"octocat"}"#)
.await
.unwrap();
});
let base = format!("http://{address}");
let endpoints = GithubOAuthEndpoints {
device_code: &base,
access_token: &base,
user: &base,
};
let flows = Mutex::new(GithubDeviceFlowRegistry::default());
let flow_id = activate_test_flow(
&flows,
Instant::now(),
Duration::from_millis(1),
Duration::from_secs(60),
);
let saved = AtomicBool::new(false);
let poll = github_device_flow_poll_with(flow_id.clone(), endpoints, &flows, |_| {
saved.store(true, Ordering::SeqCst);
Ok(())
});
let cancel = async {
user_seen_rx.await.unwrap();
flows.lock().cancel(Some(&flow_id));
resume_tx.send(()).unwrap();
};
let (result, ()) = tokio::join!(poll, cancel);
server.await.unwrap();
assert!(matches!(
result.unwrap(),
GithubDevicePollResult::Error { .. }
));
assert!(!saved.load(Ordering::SeqCst));
}
}
@@ -5,8 +5,8 @@ use std::io::Write;
//
// 客户端跟 marketplace backend 的 HTTP 客户端封装。Backend URL 走 prefs
// `marketplace_base_url`(默认 http://127.0.0.1:8090 开发;生产用户填 https://api.<domain>)。
// dev-mode auth:用户在 Settings 填 `marketplace_dev_login`(GitHub 风格 username),
// 后续 OAuth 接入时换成 token 字段。
// 写操作认证:Rust 从 CredentialsVault 读取 GitHub OAuth token 并附加
// `Authorization: Bearer`。`marketplace_dev_login` 只是前端展示缓存,不是权限来源。
//
// 5 个 IPC:
// - marketplace_list 列表 + 搜索 + 排序
@@ -68,6 +68,280 @@ fn marketplace_dev_user(prefs: &UserPreferences) -> String {
prefs.marketplace_dev_login.trim().to_string()
}
pub(crate) const MARKETPLACE_REAUTH_REQUIRED: &str =
"marketplace_auth_required: GitHub sign-in expired or is missing; sign in again";
pub(crate) const MARKETPLACE_REDIRECT_REJECTED: &str =
"marketplace_authenticated_redirect_rejected";
pub(crate) const MARKETPLACE_PUBLIC_REDIRECT_REJECTED: &str =
"marketplace_public_redirect_rejected";
fn marketplace_access_token() -> Result<String, String> {
CredentialsVault::get_marketplace_github_token()
.map_err(|e| format!("read marketplace credential failed: {e}"))?
.ok_or_else(|| MARKETPLACE_REAUTH_REQUIRED.to_string())
}
fn with_marketplace_bearer(
request: reqwest::RequestBuilder,
token: &str,
) -> reqwest::RequestBuilder {
request.bearer_auth(token)
}
fn marketplace_bearer_request(
method: reqwest::Method,
url: &str,
token: &str,
) -> reqwest::RequestBuilder {
with_marketplace_bearer(net::credential_http().request(method, url), token)
}
#[derive(Clone)]
enum MarketplaceAuthenticatedEndpoint {
Upload {
pack_id: String,
origin_pack_id: Option<String>,
bytes: Vec<u8>,
},
Like {
pack_id: String,
},
Delete {
pack_id: String,
},
MyLikes,
MyPacks,
}
impl MarketplaceAuthenticatedEndpoint {
fn operation(&self) -> &'static str {
match self {
Self::Upload { .. } => "upload",
Self::Like { .. } => "like",
Self::Delete { .. } => "delete",
Self::MyLikes => "my-likes",
Self::MyPacks => "my-packs",
}
}
fn method(&self) -> reqwest::Method {
match self {
Self::Upload { .. } | Self::Like { .. } => reqwest::Method::POST,
Self::Delete { .. } => reqwest::Method::DELETE,
Self::MyLikes | Self::MyPacks => reqwest::Method::GET,
}
}
fn path(&self) -> String {
match self {
Self::Upload { .. } => "/packs".to_string(),
Self::Like { pack_id } => format!("/packs/{pack_id}/like"),
Self::Delete { pack_id } => format!("/packs/{pack_id}"),
Self::MyLikes => "/me/likes".to_string(),
Self::MyPacks => "/me/packs".to_string(),
}
}
fn timeout(&self) -> std::time::Duration {
std::time::Duration::from_secs(match self {
Self::Upload { .. } => 30,
Self::Delete { .. } => 15,
Self::Like { .. } | Self::MyLikes | Self::MyPacks => 10,
})
}
fn request(&self, base: &str, token: &str) -> reqwest::RequestBuilder {
let url = format!("{}{}", base.trim_end_matches('/'), self.path());
let mut request =
marketplace_bearer_request(self.method(), &url, token).timeout(self.timeout());
if let Self::Upload {
pack_id,
origin_pack_id,
bytes,
} = self
{
let part = reqwest::multipart::Part::bytes(bytes.clone())
.file_name(format!("{pack_id}.zip"))
.mime_str("application/zip")
.expect("static ZIP MIME type must be valid");
let mut form = reqwest::multipart::Form::new().part("file", part);
if let Some(origin_pack_id) = origin_pack_id {
form = form.text("origin_pack_id", origin_pack_id.clone());
}
request = request.multipart(form);
}
request
}
}
#[derive(Clone)]
enum MarketplacePublicEndpoint {
List {
query: Option<String>,
sort: Option<String>,
limit: Option<u32>,
},
Detail {
pack_id: String,
},
Download {
pack_id: String,
},
}
impl MarketplacePublicEndpoint {
fn operation(&self) -> &'static str {
match self {
Self::List { .. } => "marketplace list",
Self::Detail { .. } => "marketplace detail",
Self::Download { .. } => "marketplace download",
}
}
fn url(&self, base: &str) -> Result<reqwest::Url, String> {
let base = base.trim_end_matches('/');
match self {
Self::List { query, sort, limit } => {
let mut url = reqwest::Url::parse(&format!("{base}/packs"))
.map_err(|_| "invalid marketplace URL".to_string())?;
if let Some(query) = query.as_deref().map(str::trim).filter(|v| !v.is_empty()) {
url.query_pairs_mut().append_pair("q", query);
}
if let Some(sort) = sort.as_deref().map(str::trim).filter(|v| !v.is_empty()) {
url.query_pairs_mut().append_pair("sort", sort);
}
if let Some(limit) = limit {
url.query_pairs_mut()
.append_pair("limit", &limit.to_string());
}
Ok(url)
}
Self::Detail { pack_id } => reqwest::Url::parse(&format!("{base}/packs/{pack_id}"))
.map_err(|_| "invalid marketplace URL".to_string()),
Self::Download { pack_id } => {
reqwest::Url::parse(&format!("{base}/packs/{pack_id}/download"))
.map_err(|_| "invalid marketplace URL".to_string())
}
}
}
fn timeout(&self) -> std::time::Duration {
std::time::Duration::from_secs(match self {
Self::List { .. } => 10,
Self::Detail { .. } => 15,
Self::Download { .. } => 30,
})
}
}
fn marketplace_auth_error_for_status(status: reqwest::StatusCode) -> Option<&'static str> {
(status == reqwest::StatusCode::UNAUTHORIZED).then_some(MARKETPLACE_REAUTH_REQUIRED)
}
fn require_valid_marketplace_auth_with(
status: reqwest::StatusCode,
clear_credential: impl FnOnce() -> Result<(), String>,
) -> Result<(), String> {
if status.is_redirection() {
return Err(MARKETPLACE_REDIRECT_REJECTED.to_string());
}
let Some(message) = marketplace_auth_error_for_status(status) else {
return Ok(());
};
if let Err(error) = clear_credential() {
log::warn!("[marketplace] failed to clear rejected credential: {error}");
}
Err(message.to_string())
}
fn clear_marketplace_authentication_with(
remove_credential: impl FnOnce() -> Result<(), String>,
clear_display_login: impl FnOnce() -> Result<(), String>,
) -> Result<(), String> {
let credential_result = remove_credential();
// Always clear display state even when durable vault deletion fails. The
// process tombstone has already made the token unusable.
let display_result = clear_display_login();
credential_result.and(display_result)
}
pub(crate) fn clear_marketplace_authentication(coord: &Coordinator) -> Result<(), String> {
clear_marketplace_authentication_with(
|| {
CredentialsVault::remove_marketplace_github_token()
.map_err(|error| format!("clear Marketplace credential failed: {error}"))
},
|| {
let mut prefs = coord.prefs().get();
prefs.marketplace_dev_login.clear();
coord
.prefs()
.set(prefs)
.map_err(|error| format!("clear Marketplace display login failed: {error}"))
},
)
}
async fn execute_authenticated_marketplace_with(
base: &str,
endpoint: MarketplaceAuthenticatedEndpoint,
token_provider: impl FnOnce() -> Result<String, String>,
clear_rejected_credential: impl FnOnce() -> Result<(), String>,
) -> Result<reqwest::Response, String> {
// Resolve authentication before constructing/sending a request. The
// process tombstone therefore guarantees a rejected token never leaves the
// process again, even if durable vault deletion failed.
let token = token_provider()?;
let operation = endpoint.operation();
let response = net::send_with_retry(|| endpoint.request(base, &token))
.await
.map_err(|_| format!("{operation} request failed"))?;
let status = response.status();
require_valid_marketplace_auth_with(status, clear_rejected_credential)?;
if !status.is_success() {
// Never echo authenticated response bodies or request details into IPC
// errors. Both may contain server diagnostics or credential material.
return Err(format!("{operation} HTTP {status}"));
}
Ok(response)
}
async fn execute_authenticated_marketplace(
base: &str,
endpoint: MarketplaceAuthenticatedEndpoint,
coord: &Coordinator,
) -> Result<reqwest::Response, String> {
execute_authenticated_marketplace_with(base, endpoint, marketplace_access_token, || {
clear_marketplace_authentication(coord)
})
.await
}
async fn execute_public_marketplace_with(
base: &str,
endpoint: MarketplacePublicEndpoint,
) -> Result<reqwest::Response, String> {
let url = endpoint.url(base)?;
let timeout = endpoint.timeout();
let operation = endpoint.operation();
let response = net::send_with_retry(|| {
// Public browse/detail/download intentionally use the anonymous client
// and never inherit Marketplace bearer state.
net::anonymous_no_redirect_http()
.get(url.clone())
.timeout(timeout)
})
.await
.map_err(|_| format!("{operation} request failed"))?;
if response.status().is_redirection() {
return Err(MARKETPLACE_PUBLIC_REDIRECT_REJECTED.to_string());
}
if !response.status().is_success() {
return Err(format!("{operation} HTTP {}", response.status()));
}
Ok(response)
}
#[tauri::command]
pub async fn marketplace_list(
coord: CoordinatorState<'_>,
@@ -77,33 +351,11 @@ pub async fn marketplace_list(
) -> Result<Vec<MarketplaceListItem>, String> {
let prefs = coord.prefs().get();
let base = marketplace_url_from_prefs(&prefs);
let mut url = reqwest::Url::parse(&format!("{base}/packs"))
.map_err(|e| format!("invalid marketplace url: {e}"))?;
if let Some(q) = query.as_deref() {
if !q.trim().is_empty() {
url.query_pairs_mut().append_pair("q", q.trim());
}
}
if let Some(s) = sort.as_deref() {
if !s.trim().is_empty() {
url.query_pairs_mut().append_pair("sort", s.trim());
}
}
if let Some(n) = limit {
url.query_pairs_mut().append_pair("limit", &n.to_string());
}
let resp = net::send_with_retry(|| {
net::http()
.get(url.clone())
.timeout(std::time::Duration::from_secs(10))
})
.await
.map_err(|e| format!("marketplace request failed: {e}"))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(format!("marketplace HTTP {status}: {body}"));
}
let resp = execute_public_marketplace_with(
&base,
MarketplacePublicEndpoint::List { query, sort, limit },
)
.await?;
let items: Vec<MarketplaceListItem> = resp
.json()
.await
@@ -121,18 +373,9 @@ pub async fn marketplace_detail(
}
let prefs = coord.prefs().get();
let base = marketplace_url_from_prefs(&prefs);
let url = format!("{base}/packs/{pack_id}");
let resp = net::send_with_retry(|| {
net::http()
.get(&url)
.timeout(std::time::Duration::from_secs(10))
})
.await
.map_err(|e| format!("marketplace request failed: {e}"))?;
if !resp.status().is_success() {
let status = resp.status();
return Err(format!("marketplace HTTP {status}"));
}
let resp =
execute_public_marketplace_with(&base, MarketplacePublicEndpoint::Detail { pack_id })
.await?;
resp.json::<MarketplaceDetail>()
.await
.map_err(|e| format!("parse failed: {e}"))
@@ -154,16 +397,13 @@ pub async fn marketplace_install(
// 先拉 detail 拿 authorLogin —— 装好后本地写 originAuthorLogin,
// 后续编辑+发布时 backend 据此判 supersede(原作者)vs derivative(他人 fork)。
let detail_url = format!("{base}/packs/{pack_id}");
let detail: serde_json::Value = net::send_with_retry(|| {
net::http()
.get(&detail_url)
.timeout(std::time::Duration::from_secs(15))
})
.await
.map_err(|e| format!("marketplace detail failed: {e}"))?
.error_for_status()
.map_err(|e| format!("marketplace detail HTTP error: {e}"))?
let detail: serde_json::Value = execute_public_marketplace_with(
&base,
MarketplacePublicEndpoint::Detail {
pack_id: pack_id.clone(),
},
)
.await?
.json()
.await
.map_err(|e| format!("parse detail failed: {e}"))?;
@@ -172,16 +412,13 @@ pub async fn marketplace_install(
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let download_url = format!("{base}/packs/{pack_id}/download");
let response = net::send_with_retry(|| {
net::http()
.get(&download_url)
.timeout(std::time::Duration::from_secs(30))
})
.await
.map_err(|e| format!("marketplace download failed: {e}"))?
.error_for_status()
.map_err(|e| format!("marketplace HTTP error: {e}"))?;
let response = execute_public_marketplace_with(
&base,
MarketplacePublicEndpoint::Download {
pack_id: pack_id.clone(),
},
)
.await?;
let bytes = read_marketplace_archive_response(response).await?;
// 每次安装使用 create_new 的唯一临时路径;Drop 覆盖导入成功和所有错误分支。
@@ -291,10 +528,6 @@ pub async fn marketplace_upload(
}
let prefs = coord.prefs().get();
let base = marketplace_url_from_prefs(&prefs);
let dev_user = marketplace_dev_user(&prefs);
if dev_user.is_empty() {
return Err("未登录:先在 Settings 填发布者名字".into());
}
// 拉本地 pack 拿 origin_pack_id —— 装过的 pack 这里有值,
// backend 据此判同作者就 supersede 原行(新版本),他人就 derivative(独立新 row)。
@@ -315,33 +548,20 @@ pub async fn marketplace_upload(
let bytes = std::fs::read(&tmp).map_err(|e| format!("read exported zip: {e}"))?;
let _ = std::fs::remove_file(&tmp);
// multipart 上传:表单是流式 body,不走 send_with_retry 的闭包重试;改用共享
// 客户端 —— 之前 list/detail 命令若已打开过连接,这里直接复用连接池里的连接。
let part = reqwest::multipart::Part::bytes(bytes)
.file_name(format!("{pack_id}.zip"))
.mime_str("application/zip")
.map_err(|e| format!("multipart build failed: {e}"))?;
let mut form = reqwest::multipart::Form::new().part("file", part);
if let Some(ref oid) = origin_pack_id {
form = form.text("origin_pack_id", oid.clone());
}
let resp = net::http()
.post(format!("{base}/packs"))
.header("X-Dev-User", dev_user)
.timeout(std::time::Duration::from_secs(30))
.multipart(form)
.send()
.await
.map_err(|e| format!("upload request failed: {e}"))?;
let status = resp.status();
let resp = execute_authenticated_marketplace(
&base,
MarketplaceAuthenticatedEndpoint::Upload {
pack_id: pack_id.clone(),
origin_pack_id: origin_pack_id.clone(),
bytes,
},
&coord,
)
.await?;
let body = resp
.text()
.await
.unwrap_or_else(|e| format!("read body failed: {e}"))
.clone();
if !status.is_success() {
return Err(format!("upload HTTP {status}: {body}"));
}
.map_err(|_| "read upload response failed".to_string())?;
let parsed = serde_json::from_str::<serde_json::Value>(&body)
.map_err(|e| format!("parse upload response failed: {e}"))?;
@@ -372,22 +592,12 @@ pub async fn marketplace_like(
}
let prefs = coord.prefs().get();
let base = marketplace_url_from_prefs(&prefs);
let dev_user = marketplace_dev_user(&prefs);
if dev_user.is_empty() {
return Err("未登录:先在 Settings 填发布者名字".into());
}
let like_url = format!("{base}/packs/{pack_id}/like");
let resp = net::send_with_retry(|| {
net::http()
.post(&like_url)
.header("X-Dev-User", dev_user.as_str())
.timeout(std::time::Duration::from_secs(10))
})
.await
.map_err(|e| format!("like request failed: {e}"))?;
if !resp.status().is_success() {
return Err(format!("like HTTP {}", resp.status()));
}
let resp = execute_authenticated_marketplace(
&base,
MarketplaceAuthenticatedEndpoint::Like { pack_id },
&coord,
)
.await?;
resp.json::<serde_json::Value>()
.await
.map_err(|e| format!("parse failed: {e}"))
@@ -439,24 +649,12 @@ pub async fn marketplace_delete(
}
let prefs = coord.prefs().get();
let base = marketplace_url_from_prefs(&prefs);
let dev_user = marketplace_dev_user(&prefs);
if dev_user.is_empty() {
return Err("未登录:先在 Settings 填发布者名字".into());
}
let delete_url = format!("{base}/packs/{pack_id}");
let resp = net::send_with_retry(|| {
net::http()
.delete(&delete_url)
.header("X-Dev-User", dev_user.as_str())
.timeout(std::time::Duration::from_secs(15))
})
.await
.map_err(|e| format!("delete request failed: {e}"))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(format!("delete HTTP {status}: {body}"));
}
execute_authenticated_marketplace(
&base,
MarketplaceAuthenticatedEndpoint::Delete { pack_id },
&coord,
)
.await?;
Ok(())
}
@@ -465,22 +663,9 @@ pub async fn marketplace_delete(
pub async fn marketplace_my_likes(coord: CoordinatorState<'_>) -> Result<Vec<String>, String> {
let prefs = coord.prefs().get();
let base = marketplace_url_from_prefs(&prefs);
let dev_user = marketplace_dev_user(&prefs);
if dev_user.is_empty() {
return Ok(Vec::new()); // 未登录就空集合,UI 渲染无红心
}
let likes_url = format!("{base}/me/likes");
let resp = net::send_with_retry(|| {
net::http()
.get(&likes_url)
.header("X-Dev-User", dev_user.as_str())
.timeout(std::time::Duration::from_secs(10))
})
.await
.map_err(|e| format!("my-likes request failed: {e}"))?;
if !resp.status().is_success() {
return Err(format!("my-likes HTTP {}", resp.status()));
}
let resp =
execute_authenticated_marketplace(&base, MarketplaceAuthenticatedEndpoint::MyLikes, &coord)
.await?;
resp.json::<Vec<String>>()
.await
.map_err(|e| format!("parse my-likes failed: {e}"))
@@ -493,23 +678,448 @@ pub async fn marketplace_my_packs(
) -> Result<Vec<MarketplaceMyPackItem>, String> {
let prefs = coord.prefs().get();
let base = marketplace_url_from_prefs(&prefs);
let dev_user = marketplace_dev_user(&prefs);
if dev_user.is_empty() {
return Ok(Vec::new());
}
let packs_url = format!("{base}/me/packs");
let resp = net::send_with_retry(|| {
net::http()
.get(&packs_url)
.header("X-Dev-User", dev_user.as_str())
.timeout(std::time::Duration::from_secs(10))
})
.await
.map_err(|e| format!("my-packs request failed: {e}"))?;
if !resp.status().is_success() {
return Err(format!("my-packs HTTP {}", resp.status()));
}
let resp =
execute_authenticated_marketplace(&base, MarketplaceAuthenticatedEndpoint::MyPacks, &coord)
.await?;
resp.json::<Vec<MarketplaceMyPackItem>>()
.await
.map_err(|e| format!("parse my-packs failed: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::github_oauth::marketplace_auth_status;
use anyhow::anyhow;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
async fn read_http_request(stream: &mut TcpStream) -> String {
let mut request = Vec::new();
let mut expected = None;
loop {
let mut chunk = [0u8; 4096];
let read = stream.read(&mut chunk).await.unwrap();
if read == 0 {
break;
}
request.extend_from_slice(&chunk[..read]);
if expected.is_none() {
if let Some(header_end) = request.windows(4).position(|part| part == b"\r\n\r\n") {
let headers = String::from_utf8_lossy(&request[..header_end + 4]);
let content_length = headers
.lines()
.find_map(|line| {
line.to_ascii_lowercase()
.strip_prefix("content-length:")
.and_then(|value| value.trim().parse::<usize>().ok())
})
.unwrap_or(0);
expected = Some(header_end + 4 + content_length);
}
}
if expected.is_some_and(|expected| request.len() >= expected) {
break;
}
}
String::from_utf8_lossy(&request).into_owned()
}
fn status_reason(status: u16) -> &'static str {
match status {
200 => "OK",
302 => "Found",
401 => "Unauthorized",
403 => "Forbidden",
500 => "Internal Server Error",
_ => "Test",
}
}
async fn spawn_mock_response(
status: u16,
body: String,
) -> (String, tokio::task::JoinHandle<(String, bool)>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let task = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let request = read_http_request(&mut stream).await;
let location = if status == 302 {
"Location: /must-not-follow\r\n"
} else {
""
};
let response = format!(
"HTTP/1.1 {status} {}\r\n{location}Content-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
status_reason(status),
body.len()
);
stream.write_all(response.as_bytes()).await.unwrap();
let followed_redirect =
tokio::time::timeout(std::time::Duration::from_millis(150), listener.accept())
.await
.is_ok();
(request, followed_redirect)
});
(base, task)
}
async fn spawn_mock_redirect_response(
body: String,
) -> (
String,
tokio::task::JoinHandle<(String, usize, bool, String)>,
) {
let redirect_target = TcpListener::bind("127.0.0.1:0").await.unwrap();
let target_url = format!(
"http://{}/location-gho_public_redirect_secret",
redirect_target.local_addr().unwrap()
);
let source = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", source.local_addr().unwrap());
let task = tokio::spawn(async move {
let (mut stream, _) = source.accept().await.unwrap();
let request = read_http_request(&mut stream).await;
let response = format!(
"HTTP/1.1 302 Found\r\nLocation: {target_url}\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
stream.write_all(response.as_bytes()).await.unwrap();
let target_accessed = match tokio::time::timeout(
std::time::Duration::from_millis(300),
redirect_target.accept(),
)
.await
{
Ok(Ok((mut target_stream, _))) => {
let mut request = [0u8; 2048];
let _ = target_stream.read(&mut request).await;
target_stream
.write_all(
b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}",
)
.await
.unwrap();
true
}
_ => false,
};
let source_connections =
if tokio::time::timeout(std::time::Duration::from_millis(150), source.accept())
.await
.is_ok()
{
2
} else {
1
};
(request, source_connections, target_accessed, target_url)
});
(base, task)
}
fn authenticated_cases() -> Vec<(MarketplaceAuthenticatedEndpoint, reqwest::Method, String)> {
vec![
(
MarketplaceAuthenticatedEndpoint::Upload {
pack_id: "local-pack".to_string(),
origin_pack_id: Some("origin-id".to_string()),
bytes: b"zip-fixture".to_vec(),
},
reqwest::Method::POST,
"/packs".to_string(),
),
(
MarketplaceAuthenticatedEndpoint::Like {
pack_id: "remote-id".to_string(),
},
reqwest::Method::POST,
"/packs/remote-id/like".to_string(),
),
(
MarketplaceAuthenticatedEndpoint::Delete {
pack_id: "remote-id".to_string(),
},
reqwest::Method::DELETE,
"/packs/remote-id".to_string(),
),
(
MarketplaceAuthenticatedEndpoint::MyLikes,
reqwest::Method::GET,
"/me/likes".to_string(),
),
(
MarketplaceAuthenticatedEndpoint::MyPacks,
reqwest::Method::GET,
"/me/packs".to_string(),
),
]
}
fn assert_authenticated_request(request: &str, method: &reqwest::Method, path: &str) {
let headers = request
.split("\r\n\r\n")
.next()
.unwrap()
.to_ascii_lowercase();
assert!(
headers.starts_with(&format!(
"{} {} http/1.1",
method.as_str().to_lowercase(),
path
)),
"unexpected request: {request}"
);
assert_eq!(
headers
.lines()
.filter(|line| *line == "authorization: bearer gho_http_secret")
.count(),
1,
"must send exactly one bearer header"
);
assert!(!headers.contains("x-dev-user:"));
assert!(!headers.contains("x-admin:"));
}
#[tokio::test]
async fn every_authenticated_endpoint_executes_exact_request_and_like_toggles() {
for (endpoint, method, path) in authenticated_cases() {
let body = if matches!(endpoint, MarketplaceAuthenticatedEndpoint::Like { .. }) {
r#"{"alreadyLiked":true,"likeCount":2}"#.to_string()
} else {
"{}".to_string()
};
let (base, server) = spawn_mock_response(200, body).await;
let response = execute_authenticated_marketplace_with(
&base,
endpoint.clone(),
|| Ok("gho_http_secret".to_string()),
|| Ok(()),
)
.await
.unwrap();
let json: serde_json::Value = response.json().await.unwrap();
if matches!(endpoint, MarketplaceAuthenticatedEndpoint::Like { .. }) {
assert_eq!(json["alreadyLiked"], true);
}
let (request, followed_redirect) = server.await.unwrap();
assert_authenticated_request(&request, &method, &path);
assert!(!followed_redirect);
}
let endpoint = MarketplaceAuthenticatedEndpoint::Like {
pack_id: "remote-id".to_string(),
};
let (base, server) =
spawn_mock_response(200, r#"{"alreadyLiked":false,"likeCount":1}"#.to_string()).await;
let response = execute_authenticated_marketplace_with(
&base,
endpoint,
|| Ok("gho_http_secret".to_string()),
|| Ok(()),
)
.await
.unwrap();
let json: serde_json::Value = response.json().await.unwrap();
assert_eq!(json["alreadyLiked"], false, "same endpoint covers unlike");
let (request, followed_redirect) = server.await.unwrap();
assert_authenticated_request(&request, &reqwest::Method::POST, "/packs/remote-id/like");
assert!(!followed_redirect);
}
#[tokio::test]
async fn every_authenticated_endpoint_rejects_redirect_and_sanitizes_all_errors() {
for (endpoint, method, path) in authenticated_cases() {
for status in [302, 401, 403, 500] {
let response_secret =
format!("backend-diagnostic-gho_http_secret-raw-device-secret-{status}");
let (base, server) = spawn_mock_response(status, response_secret.clone()).await;
let cleared = Arc::new(AtomicUsize::new(0));
let clear_count = Arc::clone(&cleared);
let error = execute_authenticated_marketplace_with(
&base,
endpoint.clone(),
|| Ok("gho_http_secret".to_string()),
move || {
clear_count.fetch_add(1, Ordering::SeqCst);
Ok(())
},
)
.await
.unwrap_err();
if status == 302 {
assert_eq!(error, MARKETPLACE_REDIRECT_REJECTED);
} else if status == 401 {
assert_eq!(error, MARKETPLACE_REAUTH_REQUIRED);
} else {
assert_eq!(
error,
format!(
"{} HTTP {} {}",
endpoint.operation(),
status,
status_reason(status)
)
);
}
assert!(!error.contains("gho_http_secret"));
assert!(!error.contains("raw-device-secret"));
assert!(!error.contains(&response_secret));
assert_eq!(
cleared.load(Ordering::SeqCst),
if status == 401 { 1 } else { 0 }
);
let (request, followed_redirect) = server.await.unwrap();
assert_authenticated_request(&request, &method, &path);
assert!(!followed_redirect, "credential client followed redirect");
}
}
}
#[tokio::test]
async fn public_list_detail_download_execute_anonymously() {
let cases = [
(
MarketplacePublicEndpoint::List {
query: Some("hello".to_string()),
sort: Some("likes".to_string()),
limit: Some(25),
},
"/packs?q=hello&sort=likes&limit=25",
),
(
MarketplacePublicEndpoint::Detail {
pack_id: "remote-id".to_string(),
},
"/packs/remote-id",
),
(
MarketplacePublicEndpoint::Download {
pack_id: "remote-id".to_string(),
},
"/packs/remote-id/download",
),
];
for (endpoint, path) in cases {
let (base, server) = spawn_mock_response(200, "{}".to_string()).await;
execute_public_marketplace_with(&base, endpoint)
.await
.unwrap();
let (request, followed_redirect) = server.await.unwrap();
let headers = request
.split("\r\n\r\n")
.next()
.unwrap()
.to_ascii_lowercase();
assert!(headers.starts_with(&format!("get {path} http/1.1")));
assert!(!headers.contains("authorization:"));
assert!(!headers.contains("x-dev-user:"));
assert!(!headers.contains("x-admin:"));
assert!(!followed_redirect, "anonymous client sent a second request");
}
}
#[tokio::test]
async fn every_public_endpoint_rejects_redirect_without_contacting_target_or_leaking() {
let cases = [
MarketplacePublicEndpoint::List {
query: None,
sort: None,
limit: None,
},
MarketplacePublicEndpoint::Detail {
pack_id: "remote-id".to_string(),
},
MarketplacePublicEndpoint::Download {
pack_id: "remote-id".to_string(),
},
];
for endpoint in cases {
let response_secret = format!(
"public-redirect-body-gho_secret-{}",
endpoint.operation().replace(' ', "-")
);
let (base, server) = spawn_mock_redirect_response(response_secret.clone()).await;
let result = execute_public_marketplace_with(&base, endpoint).await;
let (request, source_connections, target_accessed, location) = server.await.unwrap();
let headers = request
.split("\r\n\r\n")
.next()
.unwrap()
.to_ascii_lowercase();
assert!(headers.starts_with("get "));
assert!(!headers.contains("authorization:"));
assert!(!headers.contains("x-dev-user:"));
assert!(!headers.contains("x-admin:"));
assert_eq!(source_connections, 1, "redirect source was requested again");
assert!(!target_accessed, "redirect target was contacted");
let error = result.expect_err("public Marketplace redirect must fail closed");
assert_eq!(error, "marketplace_public_redirect_rejected");
assert!(!error.contains(&response_secret));
assert!(!error.contains(&location));
}
}
#[tokio::test]
async fn tombstoned_token_reports_signed_out_and_blocks_every_request_after_delete_failure() {
CredentialsVault::seed_marketplace_github_token_for_tests("gho_rejected_http");
let display_cleared = Arc::new(AtomicUsize::new(0));
let display_count = Arc::clone(&display_cleared);
let result = clear_marketplace_authentication_with(
|| {
CredentialsVault::reject_marketplace_github_token_for_tests(|| {
Err(anyhow!("injected keyring delete failure"))
})
.map_err(|error| error.to_string())
},
move || {
display_count.fetch_add(1, Ordering::SeqCst);
Ok(())
},
);
assert!(result.is_err());
assert_eq!(display_cleared.load(Ordering::SeqCst), 1);
assert!(!marketplace_auth_status().unwrap().signed_in);
for (endpoint, _, _) in authenticated_cases() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let error = execute_authenticated_marketplace_with(
&base,
endpoint,
marketplace_access_token,
|| Ok(()),
)
.await
.unwrap_err();
assert_eq!(error, MARKETPLACE_REAUTH_REQUIRED);
assert!(
tokio::time::timeout(std::time::Duration::from_millis(100), listener.accept())
.await
.is_err(),
"tombstoned request reached the network"
);
}
let mut retried = false;
CredentialsVault::reject_marketplace_github_token_for_tests(|| {
retried = true;
Ok(())
})
.unwrap();
assert!(retried, "logout must retry durable deletion");
assert!(!marketplace_auth_status().unwrap().signed_in);
CredentialsVault::reset_marketplace_github_token_for_tests();
}
}
+6
View File
@@ -188,6 +188,9 @@ macro_rules! app_invoke_handler_desktop {
commands::marketplace_delete,
commands::github_device_flow_start,
commands::github_device_flow_poll,
commands::github_device_flow_cancel,
commands::marketplace_auth_status,
commands::marketplace_logout,
commands::list_vocab,
commands::add_vocab,
commands::remove_vocab,
@@ -357,6 +360,9 @@ macro_rules! app_invoke_handler_mobile {
$crate::commands::marketplace_delete,
$crate::commands::github_device_flow_start,
$crate::commands::github_device_flow_poll,
$crate::commands::github_device_flow_cancel,
$crate::commands::marketplace_auth_status,
$crate::commands::marketplace_logout,
$crate::commands::list_vocab,
$crate::commands::add_vocab,
$crate::commands::remove_vocab,
+78
View File
@@ -33,11 +33,46 @@ static HTTP: Lazy<reqwest::Client> = Lazy::new(|| {
.unwrap_or_else(|_| reqwest::Client::new())
});
/// HTTP client for requests carrying OAuth device credentials or bearer tokens.
/// Redirects are disabled so secrets are never replayed to a different origin.
static CREDENTIAL_HTTP: Lazy<reqwest::Client> = Lazy::new(|| {
reqwest::Client::builder()
.connect_timeout(Duration::from_secs(8))
.pool_idle_timeout(Duration::from_secs(90))
.pool_max_idle_per_host(8)
.tcp_keepalive(Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::none())
.user_agent(concat!("OpenLess/", env!("CARGO_PKG_VERSION")))
.build()
.expect("build no-redirect credential HTTP client")
});
/// Anonymous HTTP client for public endpoints that must fail closed on redirects.
static ANONYMOUS_NO_REDIRECT_HTTP: Lazy<reqwest::Client> = Lazy::new(|| {
reqwest::Client::builder()
.connect_timeout(Duration::from_secs(8))
.pool_idle_timeout(Duration::from_secs(90))
.pool_max_idle_per_host(8)
.tcp_keepalive(Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::none())
.user_agent(concat!("OpenLess/", env!("CARGO_PKG_VERSION")))
.build()
.expect("build anonymous no-redirect HTTP client")
});
/// 进程级共享 HTTP 客户端。带连接池 —— 一次握手成功后的连接被后续请求复用。
pub fn http() -> &'static reqwest::Client {
&HTTP
}
pub fn credential_http() -> &'static reqwest::Client {
&CREDENTIAL_HTTP
}
pub fn anonymous_no_redirect_http() -> &'static reqwest::Client {
&ANONYMOUS_NO_REDIRECT_HTTP
}
/// 按 `(timeout_secs, no_proxy)` 缓存并复用 `reqwest::Client`。
///
/// LLM / ASR provider 过去每次请求都新建一个 `reqwest::Client`,新客户端连接池是
@@ -89,3 +124,46 @@ where
}
}
}
#[cfg(test)]
mod tests {
use super::credential_http;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
#[tokio::test]
async fn credential_client_never_follows_redirects_or_forwards_bearer() {
let redirect_target = TcpListener::bind("127.0.0.1:0").await.unwrap();
let target_url = format!("http://{}", redirect_target.local_addr().unwrap());
let source = TcpListener::bind("127.0.0.1:0").await.unwrap();
let source_url = format!("http://{}", source.local_addr().unwrap());
let source_task = tokio::spawn(async move {
let (mut stream, _) = source.accept().await.unwrap();
let mut request = [0u8; 2048];
let read = stream.read(&mut request).await.unwrap();
assert!(String::from_utf8_lossy(&request[..read])
.to_ascii_lowercase()
.contains("authorization: bearer gho_redirect_test"));
let response = format!(
"HTTP/1.1 302 Found\r\nLocation: {target_url}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
);
stream.write_all(response.as_bytes()).await.unwrap();
});
let response = credential_http()
.get(source_url)
.bearer_auth("gho_redirect_test")
.send()
.await
.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::FOUND);
assert!(
tokio::time::timeout(Duration::from_millis(150), redirect_target.accept())
.await
.is_err()
);
source_task.await.unwrap();
}
}
@@ -12,13 +12,21 @@
//! "providers": {
//! "asr": { "<id>": { "appKey", "accessKey", "resourceId", "apiKey", "baseURL", "model", "vocabularyId" } },
//! "llm": { "<id>": { "displayName", "apiKey", "baseURL", "model", "temperature", "extraHeaders" } }
//! }
//! },
//! "marketplace": { "githubAccessToken": "<desktop-only secret>" }
//! }
//!
//! Android's generic credential envelope is only reversible Base64 today. Until
//! the Keystore-backed vault lands, Marketplace OAuth is process-memory-only on
//! Android and is deliberately stripped from `credentials.enc.json`.
//!
//! "ark.api_key"/"volcengine.app_key" 等账户名按 Swift 语义路由到 active provider。
use std::collections::{BTreeMap, HashMap};
#[cfg(any(target_os = "android", test))]
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::OnceLock;
use anyhow::{Context, Result};
@@ -29,8 +37,6 @@ use serde::{Deserialize, Serialize};
// import keeps the Android build free of an unused-import warning.
#[cfg(not(target_os = "android"))]
use anyhow::anyhow;
#[cfg(target_os = "android")]
use std::fs;
/// 旧版 plaintext JSON 凭据路径。仅作为迁移来源;成功写入系统凭据库后会删除。
const LEGACY_CREDS_DIR: &str = ".openless";
@@ -53,10 +59,32 @@ const KEYRING_CHUNK_MAX_UTF16_UNITS: usize = 1000;
static CREDENTIALS_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
// A rejected Marketplace token must become unusable before best-effort durable
// deletion starts. Keychain/credential-manager deletion can fail or prompt, so
// this process-local tombstone is authoritative for every read until a newly
// verified token has been saved successfully.
static MARKETPLACE_TOKEN_REJECTED: AtomicBool = AtomicBool::new(false);
#[cfg(target_os = "android")]
static ANDROID_MARKETPLACE_TOKEN: OnceLock<Mutex<Option<MarketplaceGithubToken>>> = OnceLock::new();
#[cfg(target_os = "android")]
static ANDROID_MARKETPLACE_LEGACY_SCRUBBED: OnceLock<Mutex<bool>> = OnceLock::new();
fn credentials_lock() -> &'static Mutex<()> {
CREDENTIALS_LOCK.get_or_init(|| Mutex::new(()))
}
#[cfg(target_os = "android")]
fn android_marketplace_token() -> &'static Mutex<Option<MarketplaceGithubToken>> {
ANDROID_MARKETPLACE_TOKEN.get_or_init(|| Mutex::new(None))
}
#[cfg(target_os = "android")]
fn android_marketplace_legacy_scrubbed() -> &'static Mutex<bool> {
ANDROID_MARKETPLACE_LEGACY_SCRUBBED.get_or_init(|| Mutex::new(false))
}
/// Process-wide credentials cache.
///
/// Without this cache every `CredentialsVault::get_*` / `snapshot` call hits
@@ -101,6 +129,8 @@ struct CredsRoot {
active: CredsActive,
#[serde(default)]
providers: CredsProviders,
#[serde(default, skip_serializing_if = "CredsMarketplace::is_empty")]
marketplace: CredsMarketplace,
}
fn credsroot_default_version() -> u32 {
@@ -146,6 +176,29 @@ struct CredsProviders {
llm: HashMap<String, CredsLlmEntry>,
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
#[allow(non_snake_case)]
struct CredsMarketplace {
#[serde(default, skip_serializing_if = "Option::is_none")]
githubAccessToken: Option<MarketplaceGithubToken>,
}
impl CredsMarketplace {
fn is_empty(&self) -> bool {
self.githubAccessToken.is_none()
}
}
#[derive(Serialize, Deserialize, Clone)]
#[serde(transparent)]
struct MarketplaceGithubToken(String);
impl std::fmt::Debug for MarketplaceGithubToken {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("[REDACTED]")
}
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
#[allow(non_snake_case)]
struct CredsAsrEntry {
@@ -329,10 +382,80 @@ fn android_credentials_path() -> Result<PathBuf> {
#[cfg(target_os = "android")]
fn load_android_credentials() -> Result<Option<CredsRoot>> {
let path = android_credentials_path()?;
let loaded = load_android_credentials_from_path(&path);
if loaded.is_ok() {
*android_marketplace_legacy_scrubbed().lock() = true;
}
loaded
}
#[cfg(any(target_os = "android", test))]
fn load_android_credentials_from_path(path: &Path) -> Result<Option<CredsRoot>> {
load_android_credentials_from_path_with_fault(path, &mut |_| Ok(()))
}
#[cfg(any(target_os = "android", test))]
fn ensure_android_marketplace_legacy_scrubbed_at(
path: &Path,
completed: &Mutex<bool>,
) -> Result<()> {
let mut completed = completed.lock();
if *completed {
return Ok(());
}
// Mark completion only after the durable sanitized rewrite (or confirmed
// absence of a legacy file) succeeds. Any error remains retryable.
let _ = load_android_credentials_from_path(path)?;
*completed = true;
Ok(())
}
#[cfg(any(target_os = "android", test))]
fn get_android_marketplace_token_at(
path: &Path,
completed: &Mutex<bool>,
memory_token: &Mutex<Option<MarketplaceGithubToken>>,
) -> Result<Option<String>> {
ensure_android_marketplace_legacy_scrubbed_at(path, completed)?;
Ok(memory_token.lock().as_ref().map(|token| token.0.clone()))
}
#[cfg(target_os = "android")]
fn ensure_android_marketplace_legacy_scrubbed() -> Result<()> {
let path = android_credentials_path()?;
ensure_android_marketplace_legacy_scrubbed_at(&path, android_marketplace_legacy_scrubbed())
}
#[cfg(any(target_os = "android", test))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AndroidEnvelopeStage {
LegacyOpen,
LegacySync,
LegacyRemove,
ParentSyncAfterErase,
AfterErase,
TempOpen,
AfterTempOpen,
TempWrite,
AfterTempWrite,
TempFlush,
AfterTempFlush,
TempSync,
AfterTempSync,
Rename,
AfterRename,
ParentSyncAfterRename,
}
#[cfg(any(target_os = "android", test))]
fn load_android_credentials_from_path_with_fault(
path: &Path,
fault: &mut impl FnMut(AndroidEnvelopeStage) -> io::Result<()>,
) -> Result<Option<CredsRoot>> {
if !path.exists() {
return Ok(None);
}
let bytes = fs::read(&path).with_context(|| format!("read failed: {}", path.display()))?;
let bytes = std::fs::read(path).with_context(|| format!("read failed: {}", path.display()))?;
if bytes.is_empty() {
return Ok(None);
}
@@ -341,21 +464,144 @@ fn load_android_credentials() -> Result<Option<CredsRoot>> {
let decoded = base64::engine::general_purpose::STANDARD
.decode(bytes)
.context("decode android credentials envelope")?;
let root =
let mut root =
serde_json::from_slice::<CredsRoot>(&decoded).context("parse android credentials json")?;
if lookup_marketplace_github_token(&root).is_some() {
// Files written by unreleased/dev builds may contain a recoverable
// bearer token. Erase that legacy secret durably *before* creating the
// sanitized replacement. A crash can therefore lose generic Android
// credentials, but can never leave the old bearer recoverable.
write_marketplace_github_token(&mut root, None);
scrub_android_legacy_envelope_with_fault(path, &root, fault)
.context("scrub Marketplace token from Android credential envelope")?;
}
Ok(Some(root))
}
#[cfg(target_os = "android")]
fn save_android_credentials(root: &CredsRoot) -> Result<()> {
let cleaned = clean_credentials(root);
let path = android_credentials_path()?;
write_android_credentials_envelope(&path, root)
}
#[cfg(target_os = "android")]
fn write_android_credentials_envelope(path: &Path, root: &CredsRoot) -> Result<()> {
write_android_credentials_envelope_with_fault(path, root, false, &mut |_| Ok(()))
}
#[cfg(any(target_os = "android", test))]
fn scrub_android_legacy_envelope_with_fault(
path: &Path,
root: &CredsRoot,
fault: &mut impl FnMut(AndroidEnvelopeStage) -> io::Result<()>,
) -> Result<()> {
write_android_credentials_envelope_with_fault(path, root, true, fault)
}
#[cfg(any(target_os = "android", test))]
fn sync_android_credentials_parent(path: &Path) -> io::Result<()> {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
std::fs::File::open(parent)?.sync_all()
}
#[cfg(any(target_os = "android", test))]
fn fail_closed_remove_android_envelope(path: &Path, tmp: &Path) {
// Truncate+sync first so even a crash that resurrects the directory entry
// after unlink cannot resurrect the bearer bytes.
if let Ok(file) = std::fs::OpenOptions::new().write(true).open(path) {
let _ = file.set_len(0);
let _ = file.sync_all();
}
let _ = std::fs::remove_file(path);
let _ = std::fs::remove_file(tmp);
let _ = sync_android_credentials_parent(path);
}
#[cfg(any(target_os = "android", test))]
fn write_android_credentials_envelope_with_fault(
path: &Path,
root: &CredsRoot,
erase_legacy_secret_first: bool,
fault: &mut impl FnMut(AndroidEnvelopeStage) -> io::Result<()>,
) -> Result<()> {
let cleaned = android_persistable_credentials(root);
let json = serde_json::to_string(&cleaned).context("encode credentials failed")?;
use base64::Engine;
let encoded = base64::engine::general_purpose::STANDARD.encode(json.as_bytes());
let path = android_credentials_path()?;
super::ensure_dir(path.parent().unwrap_or_else(|| Path::new(".")))?;
fs::write(&path, encoded).with_context(|| format!("write failed: {}", path.display()))?;
Ok(())
let tmp = path.with_extension("json.tmp");
let _ = std::fs::remove_file(&tmp);
let persisted = (|| -> Result<()> {
if erase_legacy_secret_first {
fault(AndroidEnvelopeStage::LegacyOpen)?;
let legacy = std::fs::OpenOptions::new()
.write(true)
.truncate(true)
.open(path)
.with_context(|| format!("open legacy envelope failed: {}", path.display()))?;
fault(AndroidEnvelopeStage::LegacySync)?;
legacy
.sync_all()
.with_context(|| format!("sync erased envelope failed: {}", path.display()))?;
drop(legacy);
fault(AndroidEnvelopeStage::LegacyRemove)?;
std::fs::remove_file(path)
.with_context(|| format!("remove legacy envelope failed: {}", path.display()))?;
fault(AndroidEnvelopeStage::ParentSyncAfterErase)?;
sync_android_credentials_parent(path)
.with_context(|| format!("sync erased envelope directory: {}", path.display()))?;
fault(AndroidEnvelopeStage::AfterErase)?;
}
fault(AndroidEnvelopeStage::TempOpen)?;
let mut output = std::fs::OpenOptions::new()
.create_new(true)
.write(true)
.open(&tmp)
.with_context(|| format!("open failed: {}", tmp.display()))?;
fault(AndroidEnvelopeStage::AfterTempOpen)?;
fault(AndroidEnvelopeStage::TempWrite)?;
output
.write_all(encoded.as_bytes())
.with_context(|| format!("write failed: {}", tmp.display()))?;
fault(AndroidEnvelopeStage::AfterTempWrite)?;
fault(AndroidEnvelopeStage::TempFlush)?;
output
.flush()
.with_context(|| format!("flush failed: {}", tmp.display()))?;
fault(AndroidEnvelopeStage::AfterTempFlush)?;
fault(AndroidEnvelopeStage::TempSync)?;
output
.sync_all()
.with_context(|| format!("sync failed: {}", tmp.display()))?;
fault(AndroidEnvelopeStage::AfterTempSync)?;
drop(output);
fault(AndroidEnvelopeStage::Rename)?;
std::fs::rename(&tmp, path)
.with_context(|| format!("replace failed: {}", path.display()))?;
fault(AndroidEnvelopeStage::AfterRename)?;
fault(AndroidEnvelopeStage::ParentSyncAfterRename)?;
sync_android_credentials_parent(path)
.with_context(|| format!("sync replacement directory: {}", path.display()))?;
Ok(())
})();
if persisted.is_err() {
if erase_legacy_secret_first {
fail_closed_remove_android_envelope(path, &tmp);
} else {
let _ = std::fs::remove_file(&tmp);
}
}
persisted
}
#[cfg(any(target_os = "android", test))]
fn android_persistable_credentials(root: &CredsRoot) -> CredsRoot {
let mut cleaned = clean_credentials(root);
write_marketplace_github_token(&mut cleaned, None);
cleaned
}
fn clean_credentials(root: &CredsRoot) -> CredsRoot {
@@ -365,6 +611,53 @@ fn clean_credentials(root: &CredsRoot) -> CredsRoot {
cleaned
}
fn lookup_marketplace_github_token(root: &CredsRoot) -> Option<String> {
root.marketplace
.githubAccessToken
.as_ref()
.map(|token| token.0.as_str())
.filter(|token| !token.trim().is_empty())
.map(str::to_string)
}
fn write_marketplace_github_token(root: &mut CredsRoot, value: Option<String>) {
root.marketplace.githubAccessToken = value.and_then(|token| {
if token.trim().is_empty() {
None
} else {
Some(MarketplaceGithubToken(token))
}
});
}
fn marketplace_token_is_rejected() -> bool {
MARKETPLACE_TOKEN_REJECTED.load(Ordering::SeqCst)
}
fn invalidate_marketplace_token_process_local() {
// Publish the tombstone first. All token reads happen under
// `credentials_lock`, so the subsequent cache/memory clear is atomic from
// the command layer's point of view; the atomic also prevents accidental
// direct readers from observing the rejected token.
MARKETPLACE_TOKEN_REJECTED.store(true, Ordering::SeqCst);
if let Some(root) = credentials_cache().lock().as_mut() {
write_marketplace_github_token(root, None);
}
#[cfg(target_os = "android")]
{
*android_marketplace_token().lock() = None;
}
}
fn invalidate_marketplace_token_with(durable_delete: impl FnOnce() -> Result<()>) -> Result<()> {
invalidate_marketplace_token_process_local();
durable_delete()
}
fn mark_marketplace_token_verified() {
MARKETPLACE_TOKEN_REJECTED.store(false, Ordering::SeqCst);
}
fn read_legacy_credentials_file(path: &Path) -> Option<CredsRoot> {
if !path.exists() {
return None;
@@ -632,6 +925,26 @@ fn migrate_legacy_sources_for_update() -> Result<CredsRoot> {
Ok(CredsRoot::default())
}
#[cfg(any(target_os = "android", test))]
fn load_android_credentials_into_cache_with(
loader: impl FnOnce() -> Result<Option<CredsRoot>>,
) -> CredsRoot {
match loader() {
Ok(root) => {
let root = root.unwrap_or_default();
store_credentials_cache(&root);
root
}
Err(e) => {
// Do not cache the fallback. In particular, a failed legacy-token
// scrub must be retried by the next startup/getter call rather than
// hidden for the rest of the process.
log::warn!("[vault] android credential read failed: {e}");
CredsRoot::default()
}
}
}
fn load_credentials() -> CredsRoot {
if let Some(cached) = credentials_cache().lock().as_ref().cloned() {
return cached;
@@ -639,16 +952,7 @@ fn load_credentials() -> CredsRoot {
#[cfg(target_os = "android")]
{
let root = match load_android_credentials() {
Ok(Some(root)) => root,
Ok(None) => CredsRoot::default(),
Err(e) => {
log::warn!("[vault] android credential read failed: {e}");
CredsRoot::default()
}
};
store_credentials_cache(&root);
return root;
return load_android_credentials_into_cache_with(load_android_credentials);
}
#[cfg(not(target_os = "android"))]
@@ -973,6 +1277,94 @@ impl CredentialsVault {
save_credentials(&root)
}
/// GitHub OAuth token for authenticated marketplace operations.
///
/// This credential deliberately has no generic `CredentialAccount` and is
/// excluded from `CredentialsSnapshot`, so frontend IPC can never read it.
pub fn get_marketplace_github_token() -> Result<Option<String>> {
let _guard = credentials_lock().lock();
if marketplace_token_is_rejected() {
return Ok(None);
}
#[cfg(target_os = "android")]
{
let path = android_credentials_path()?;
return get_android_marketplace_token_at(
&path,
android_marketplace_legacy_scrubbed(),
android_marketplace_token(),
);
}
#[cfg(not(target_os = "android"))]
Ok(lookup_marketplace_github_token(&load_credentials()))
}
pub fn set_marketplace_github_token(value: &str) -> Result<()> {
let _guard = credentials_lock().lock();
#[cfg(target_os = "android")]
{
ensure_android_marketplace_legacy_scrubbed()?;
*android_marketplace_token().lock() =
(!value.trim().is_empty()).then(|| MarketplaceGithubToken(value.to_string()));
if value.trim().is_empty() {
invalidate_marketplace_token_process_local();
} else {
mark_marketplace_token_verified();
}
return Ok(());
}
#[cfg(not(target_os = "android"))]
{
let mut root = load_credentials_for_update()?;
write_marketplace_github_token(&mut root, Some(value.to_string()));
save_credentials(&root)?;
mark_marketplace_token_verified();
Ok(())
}
}
pub fn remove_marketplace_github_token() -> Result<()> {
let _guard = credentials_lock().lock();
invalidate_marketplace_token_with(|| {
#[cfg(target_os = "android")]
{
// Retry the durable legacy scrub on every logout until it has
// actually completed. Process memory is already invalidated.
return ensure_android_marketplace_legacy_scrubbed();
}
#[cfg(not(target_os = "android"))]
{
let mut root = load_credentials_for_update()?;
write_marketplace_github_token(&mut root, None);
save_credentials(&root)
}
})
}
#[cfg(test)]
pub(crate) fn seed_marketplace_github_token_for_tests(value: &str) {
let _guard = credentials_lock().lock();
let mut root = CredsRoot::default();
write_marketplace_github_token(&mut root, Some(value.to_string()));
store_credentials_cache(&root);
mark_marketplace_token_verified();
}
#[cfg(test)]
pub(crate) fn reject_marketplace_github_token_for_tests(
durable_delete: impl FnOnce() -> Result<()>,
) -> Result<()> {
let _guard = credentials_lock().lock();
invalidate_marketplace_token_with(durable_delete)
}
#[cfg(test)]
pub(crate) fn reset_marketplace_github_token_for_tests() {
let _guard = credentials_lock().lock();
store_credentials_cache(&CredsRoot::default());
MARKETPLACE_TOKEN_REJECTED.store(false, Ordering::SeqCst);
}
pub fn get_active_asr() -> String {
let _guard = credentials_lock().lock();
load_credentials().active.asr
@@ -1039,7 +1431,17 @@ impl CredentialsVault {
#[cfg(test)]
mod tests {
use super::{chunk_json_payload, parse_extra_headers_json, KEYRING_CHUNK_MAX_UTF16_UNITS};
use super::{
android_persistable_credentials, chunk_json_payload, credentials_cache,
get_android_marketplace_token_at, load_android_credentials_from_path,
load_android_credentials_from_path_with_fault, load_android_credentials_into_cache_with,
lookup_marketplace_github_token, parse_extra_headers_json,
reset_credentials_cache_for_tests, write_marketplace_github_token, AndroidEnvelopeStage,
CredsRoot, MarketplaceGithubToken, KEYRING_CHUNK_MAX_UTF16_UNITS,
};
use anyhow::anyhow;
use parking_lot::Mutex;
use std::io;
#[test]
fn credential_payload_chunks_stay_under_windows_blob_limit() {
@@ -1074,4 +1476,252 @@ mod tests {
);
}
}
#[test]
fn marketplace_github_token_uses_the_credentials_payload_not_provider_accounts() {
let mut root = CredsRoot::default();
assert_eq!(lookup_marketplace_github_token(&root), None);
write_marketplace_github_token(&mut root, Some("gho_vault_only".to_string()));
assert_eq!(
lookup_marketplace_github_token(&root).as_deref(),
Some("gho_vault_only")
);
assert!(root.providers.asr.is_empty());
assert!(root.providers.llm.is_empty());
}
#[test]
fn legacy_credentials_payload_without_marketplace_token_remains_readable() {
let root: CredsRoot = serde_json::from_str(r#"{"version":1}"#)
.expect("pre-marketplace credentials should remain compatible");
assert_eq!(lookup_marketplace_github_token(&root), None);
}
#[test]
fn marketplace_logout_removes_only_the_marketplace_token() {
let mut root = CredsRoot::default();
root.active.llm = "configured-provider".to_string();
write_marketplace_github_token(&mut root, Some("gho_remove_me".to_string()));
write_marketplace_github_token(&mut root, None);
assert_eq!(lookup_marketplace_github_token(&root), None);
assert_eq!(root.active.llm, "configured-provider");
}
#[test]
fn marketplace_token_is_absent_from_serialized_preferences() {
let token = "gho_must_not_enter_preferences";
let mut root = CredsRoot::default();
write_marketplace_github_token(&mut root, Some(token.to_string()));
let credentials_json = serde_json::to_string(&root).expect("credentials should serialize");
let preferences_json = serde_json::to_string(&crate::types::UserPreferences::default())
.expect("preferences should serialize");
assert!(credentials_json.contains(token));
assert!(!preferences_json.contains(token));
assert!(!preferences_json.contains("githubAccessToken"));
assert!(!format!("{root:?}").contains(token));
}
#[test]
fn android_persistable_credentials_never_contains_marketplace_token_or_account() {
let token = "gho_android_memory_only";
let mut root = CredsRoot::default();
write_marketplace_github_token(&mut root, Some(token.to_string()));
let persisted = serde_json::to_string(&android_persistable_credentials(&root))
.expect("android credential payload should serialize");
assert!(!persisted.contains(token));
assert!(!persisted.contains("githubAccessToken"));
assert!(!persisted.contains("marketplace"));
}
#[test]
fn android_legacy_envelope_is_atomically_scrubbed_before_load_returns() {
use base64::Engine;
let token = "gho_legacy_android_secret";
let mut root = CredsRoot::default();
write_marketplace_github_token(&mut root, Some(token.to_string()));
let raw = serde_json::to_vec(&root).unwrap();
let encoded = base64::engine::general_purpose::STANDARD.encode(raw);
let dir = std::env::temp_dir().join(format!(
"openless-android-credential-scrub-{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("credentials.enc.json");
std::fs::write(&path, encoded).unwrap();
let loaded = load_android_credentials_from_path(&path)
.unwrap()
.expect("credential envelope should load");
let disk = std::fs::read_to_string(&path).unwrap();
let decoded = base64::engine::general_purpose::STANDARD
.decode(disk)
.unwrap();
let decoded = String::from_utf8(decoded).unwrap();
assert_eq!(lookup_marketplace_github_token(&loaded), None);
assert!(!decoded.contains(token));
assert!(!decoded.contains("githubAccessToken"));
assert!(!decoded.contains("marketplace"));
assert!(!path.with_extension("json.tmp").exists());
std::fs::remove_dir_all(dir).unwrap();
}
fn write_legacy_android_envelope(path: &std::path::Path, token: &str) {
use base64::Engine;
let mut root = CredsRoot::default();
write_marketplace_github_token(&mut root, Some(token.to_string()));
let raw = serde_json::to_vec(&root).unwrap();
let encoded = base64::engine::general_purpose::STANDARD.encode(raw);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, encoded).unwrap();
}
fn assert_android_secret_unrecoverable(path: &std::path::Path, token: &str) {
use base64::Engine;
for candidate in [path.to_path_buf(), path.with_extension("json.tmp")] {
let Ok(bytes) = std::fs::read(&candidate) else {
continue;
};
assert!(
!String::from_utf8_lossy(&bytes).contains(token),
"raw secret remained in {}",
candidate.display()
);
if let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(&bytes) {
assert!(
!String::from_utf8_lossy(&decoded).contains(token),
"base64 secret remained in {}",
candidate.display()
);
}
}
}
#[test]
fn android_legacy_scrub_is_fail_closed_for_every_io_failure() {
let stages = [
AndroidEnvelopeStage::LegacyOpen,
AndroidEnvelopeStage::LegacySync,
AndroidEnvelopeStage::LegacyRemove,
AndroidEnvelopeStage::ParentSyncAfterErase,
AndroidEnvelopeStage::TempOpen,
AndroidEnvelopeStage::TempWrite,
AndroidEnvelopeStage::TempFlush,
AndroidEnvelopeStage::TempSync,
AndroidEnvelopeStage::Rename,
AndroidEnvelopeStage::ParentSyncAfterRename,
];
for failed_stage in stages {
let token = format!("gho_android_fault_{failed_stage:?}");
let dir = std::env::temp_dir()
.join(format!("openless-android-fault-{}", uuid::Uuid::new_v4()));
let path = dir.join("credentials.enc.json");
write_legacy_android_envelope(&path, &token);
let result = load_android_credentials_from_path_with_fault(&path, &mut |stage| {
if stage == failed_stage {
Err(io::Error::other(format!("injected {stage:?}")))
} else {
Ok(())
}
});
assert!(result.is_err(), "{failed_stage:?} should fail");
assert_android_secret_unrecoverable(&path, &token);
assert!(!path.with_extension("json.tmp").exists());
let _ = std::fs::remove_dir_all(dir);
}
}
#[test]
fn android_legacy_secret_is_unrecoverable_at_every_crash_boundary() {
let boundaries = [
AndroidEnvelopeStage::AfterErase,
AndroidEnvelopeStage::AfterTempOpen,
AndroidEnvelopeStage::AfterTempWrite,
AndroidEnvelopeStage::AfterTempFlush,
AndroidEnvelopeStage::AfterTempSync,
AndroidEnvelopeStage::AfterRename,
];
for crash_stage in boundaries {
let token = format!("gho_android_crash_{crash_stage:?}");
let dir = std::env::temp_dir()
.join(format!("openless-android-crash-{}", uuid::Uuid::new_v4()));
let path = dir.join("credentials.enc.json");
write_legacy_android_envelope(&path, &token);
let crashed = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _ = load_android_credentials_from_path_with_fault(&path, &mut |stage| {
assert_ne!(stage, crash_stage, "injected crash at {stage:?}");
Ok(())
});
}));
assert!(crashed.is_err(), "{crash_stage:?} should simulate a crash");
assert_android_secret_unrecoverable(&path, &token);
let _ = std::fs::remove_file(path.with_extension("json.tmp"));
let _ = std::fs::remove_dir_all(dir);
}
}
#[test]
fn android_real_getter_scrubs_legacy_disk_token_and_retries_failure() {
let dir =
std::env::temp_dir().join(format!("openless-android-getter-{}", uuid::Uuid::new_v4()));
let path = dir.join("credentials.enc.json");
std::fs::create_dir_all(&path).unwrap();
let completed = Mutex::new(false);
let memory = Mutex::new(Some(MarketplaceGithubToken(
"gho_process_memory".to_string(),
)));
assert!(get_android_marketplace_token_at(&path, &completed, &memory).is_err());
assert!(!*completed.lock(), "failed scrub must remain retryable");
std::fs::remove_dir(&path).unwrap();
write_legacy_android_envelope(&path, "gho_legacy_getter_secret");
let token = get_android_marketplace_token_at(&path, &completed, &memory).unwrap();
assert_eq!(token.as_deref(), Some("gho_process_memory"));
assert!(*completed.lock());
assert_android_secret_unrecoverable(&path, "gho_legacy_getter_secret");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn android_startup_failure_does_not_cache_default_or_suppress_retry() {
reset_credentials_cache_for_tests();
let first = load_android_credentials_into_cache_with(|| {
Err(anyhow!("injected startup scrub failure"))
});
assert!(lookup_marketplace_github_token(&first).is_none());
assert!(credentials_cache().lock().is_none());
let dir =
std::env::temp_dir().join(format!("openless-android-startup-{}", uuid::Uuid::new_v4()));
let path = dir.join("credentials.enc.json");
write_legacy_android_envelope(&path, "gho_legacy_startup_secret");
let second =
load_android_credentials_into_cache_with(|| load_android_credentials_from_path(&path));
assert!(lookup_marketplace_github_token(&second).is_none());
assert!(credentials_cache().lock().is_some());
assert_android_secret_unrecoverable(&path, "gho_legacy_startup_secret");
*credentials_cache().lock() = Some(CredsRoot::default());
let _ = std::fs::remove_dir_all(dir);
}
}
+1 -2
View File
@@ -855,8 +855,7 @@ pub struct UserPreferences {
/// 用户在 Settings 里填生产 URL (如 https://api.openless-marketplace.com)。
#[serde(default)]
pub marketplace_base_url: String,
/// Marketplace dev-mode 模拟登录用户名(GitHub login 风格)。生产换 OAuth token 后此字段废弃。
/// 上传 / 点赞需要带这个 header;空时上传被后端 401。
/// GitHub login 展示缓存。不用于认证;OAuth token 只存在 CredentialsVault。
#[serde(default)]
pub marketplace_dev_login: String,
/// Android: text insertion strategy for cross-app dictation results.
@@ -6,8 +6,12 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
githubDeviceFlowCancel,
githubDeviceFlowPoll,
githubDeviceFlowStart,
githubFlowExpiresAt,
githubPollIntervalMs,
githubSlowDownIntervalMs,
openExternal,
} from '../lib/ipc';
import { Btn } from '../pages/_atoms';
@@ -15,7 +19,14 @@ import { Modal } from './ui/Modal';
type Phase =
| { kind: 'starting' }
| { kind: 'pending'; userCode: string; verificationUri: string; deviceCode: string }
| {
kind: 'pending';
userCode: string;
verificationUri: string;
flowId: string;
intervalMs: number;
expiresAt: number;
}
| { kind: 'success'; login: string }
| { kind: 'error'; message: string };
@@ -30,6 +41,8 @@ export function GithubLoginModal({ onClose, onSuccess }: GithubLoginModalProps)
const [phase, setPhase] = useState<Phase>({ kind: 'starting' });
const [copied, setCopied] = useState(false);
const cancelledRef = useRef(false);
const beginGenerationRef = useRef(0);
const activeFlowIdRef = useRef<string | undefined>(undefined);
// 用 ref 持有回调,poll 副作用只依赖 phase,不因父组件重渲染而重启。
const onSuccessRef = useRef(onSuccess);
const onCloseRef = useRef(onClose);
@@ -38,17 +51,32 @@ export function GithubLoginModal({ onClose, onSuccess }: GithubLoginModalProps)
onCloseRef.current = onClose;
});
const cancelActiveFlow = useCallback(async () => {
const flowId = activeFlowIdRef.current;
activeFlowIdRef.current = undefined;
try { await githubDeviceFlowCancel(flowId); } catch { /* best effort */ }
}, []);
const begin = useCallback(async () => {
const generation = ++beginGenerationRef.current;
cancelledRef.current = false;
setPhase({ kind: 'starting' });
try {
await cancelActiveFlow();
if (cancelledRef.current || generation !== beginGenerationRef.current) return;
const start = await githubDeviceFlowStart();
if (cancelledRef.current) return;
if (cancelledRef.current || generation !== beginGenerationRef.current) {
await githubDeviceFlowCancel(start.flowId).catch(() => undefined);
return;
}
activeFlowIdRef.current = start.flowId;
setPhase({
kind: 'pending',
userCode: start.userCode,
verificationUri: start.verificationUri,
deviceCode: start.deviceCode,
flowId: start.flowId,
intervalMs: githubPollIntervalMs(start.interval),
expiresAt: githubFlowExpiresAt(Date.now(), start.expiresIn),
});
// 自动拉起浏览器;失败不致命,用户可手动复制。
try { await openExternal(start.verificationUri); } catch { /* manual fallback */ }
@@ -56,36 +84,55 @@ export function GithubLoginModal({ onClose, onSuccess }: GithubLoginModalProps)
if (cancelledRef.current) return;
setPhase({ kind: 'error', message: err instanceof Error ? err.message : String(err) });
}
}, []);
}, [cancelActiveFlow]);
// 打开即发起登录。
useEffect(() => {
void begin();
return () => { cancelledRef.current = true; };
}, [begin]);
return () => {
cancelledRef.current = true;
beginGenerationRef.current += 1;
void cancelActiveFlow();
};
}, [begin, cancelActiveFlow]);
// pending 阶段轮询 backend。
useEffect(() => {
if (phase.kind !== 'pending') return;
let cancelled = false;
let timer: number | null = null;
let interval = 5_000;
const deviceCode = phase.deviceCode;
let interval = phase.intervalMs;
const { flowId, expiresAt } = phase;
const tick = async () => {
if (cancelled) return;
if (Date.now() >= expiresAt) {
await githubDeviceFlowCancel(flowId).catch(() => undefined);
activeFlowIdRef.current = undefined;
if (!cancelled) {
setPhase({
kind: 'error',
message: t('marketplace.oauth.expiredError', {
defaultValue: 'GitHub authorization expired. Start again.',
}),
});
}
return;
}
try {
const res = await githubDeviceFlowPoll(deviceCode);
const res = await githubDeviceFlowPoll(flowId);
if (cancelled) return;
if (res.kind === 'authorized') {
activeFlowIdRef.current = undefined;
setPhase({ kind: 'success', login: res.login });
onSuccessRef.current(res.login);
window.setTimeout(() => { if (!cancelled) onCloseRef.current(); }, 1200);
} else if (res.kind === 'slowDown') {
interval = Math.min(interval + 5_000, 30_000);
timer = window.setTimeout(tick, interval);
interval = githubSlowDownIntervalMs(interval);
timer = window.setTimeout(tick, Math.min(interval, Math.max(0, expiresAt - Date.now())));
} else if (res.kind === 'pending') {
timer = window.setTimeout(tick, interval);
timer = window.setTimeout(tick, Math.min(interval, Math.max(0, expiresAt - Date.now())));
} else {
activeFlowIdRef.current = undefined;
setPhase({ kind: 'error', message: res.message });
}
} catch (err) {
@@ -98,11 +145,12 @@ export function GithubLoginModal({ onClose, onSuccess }: GithubLoginModalProps)
cancelled = true;
if (timer != null) window.clearTimeout(timer);
};
}, [phase]);
}, [phase, t]);
const close = () => {
cancelledRef.current = true;
onClose();
beginGenerationRef.current += 1;
void cancelActiveFlow().finally(onClose);
};
const copyCode = async () => {
@@ -0,0 +1,26 @@
import {
githubFlowExpiresAt,
githubPollIntervalMs,
githubSlowDownIntervalMs,
} from "./ipc/github-oauth"
function assertEqual(actual: number, expected: number, name: string) {
if (actual !== expected) {
throw new Error(`${name}: expected ${expected}, got ${actual}`)
}
}
assertEqual(githubPollIntervalMs(7), 7_000, "server interval")
assertEqual(githubPollIntervalMs(0), 5_000, "invalid zero interval fallback")
assertEqual(githubPollIntervalMs(Number.NaN), 5_000, "invalid NaN interval fallback")
let interval = githubPollIntervalMs(7)
interval = githubSlowDownIntervalMs(interval)
assertEqual(interval, 12_000, "first slow_down")
interval = githubSlowDownIntervalMs(interval)
assertEqual(interval, 17_000, "repeated slow_down")
assertEqual(githubFlowExpiresAt(1_000, 15), 16_000, "expiry deadline")
assertEqual(githubFlowExpiresAt(1_000, -1), 1_000, "negative expiry clamp")
console.log("githubOauth.test.ts passed")
+41 -4
View File
@@ -1,7 +1,7 @@
import { invokeOrMock } from "./shared"
export interface GithubDeviceStartResponse {
deviceCode: string
flowId: string
userCode: string
verificationUri: string
interval: number
@@ -14,12 +14,16 @@ export type GithubDevicePollResult =
| { kind: "slowDown" }
| { kind: "error"; message: string }
export interface MarketplaceAuthStatus {
signedIn: boolean
}
export function githubDeviceFlowStart(): Promise<GithubDeviceStartResponse> {
return invokeOrMock<GithubDeviceStartResponse>(
"github_device_flow_start",
undefined,
() => ({
deviceCode: "mock-device-code-xxxxxxxx",
flowId: "mock-opaque-flow-id",
userCode: "MOCK-CODE",
verificationUri: "https://github.com/login/device",
interval: 5,
@@ -29,14 +33,47 @@ export function githubDeviceFlowStart(): Promise<GithubDeviceStartResponse> {
}
export function githubDeviceFlowPoll(
deviceCode: string,
flowId: string,
): Promise<GithubDevicePollResult> {
return invokeOrMock<GithubDevicePollResult>(
"github_device_flow_poll",
{ deviceCode },
{ flowId },
() => ({
kind: "authorized" as const,
login: "mock-user",
}),
)
}
export function githubDeviceFlowCancel(flowId?: string): Promise<void> {
return invokeOrMock<void>(
"github_device_flow_cancel",
{ flowId: flowId ?? null },
() => undefined,
)
}
export function githubPollIntervalMs(intervalSeconds: number): number {
if (!Number.isFinite(intervalSeconds) || intervalSeconds <= 0) return 5_000
return Math.ceil(intervalSeconds * 1_000)
}
export function githubSlowDownIntervalMs(currentIntervalMs: number): number {
return Math.max(0, currentIntervalMs) + 5_000
}
export function githubFlowExpiresAt(nowMs: number, expiresInSeconds: number): number {
return nowMs + Math.max(0, expiresInSeconds) * 1_000
}
export function marketplaceAuthStatus(): Promise<MarketplaceAuthStatus> {
return invokeOrMock<MarketplaceAuthStatus>(
"marketplace_auth_status",
undefined,
() => ({ signedIn: false }),
)
}
export function marketplaceLogout(): Promise<void> {
return invokeOrMock<void>("marketplace_logout", undefined, () => undefined)
}
+15 -2
View File
@@ -188,8 +188,21 @@ export {
} from "./marketplace"
// github-oauth
export type { GithubDeviceStartResponse, GithubDevicePollResult } from "./github-oauth"
export { githubDeviceFlowStart, githubDeviceFlowPoll } from "./github-oauth"
export type {
GithubDeviceStartResponse,
GithubDevicePollResult,
MarketplaceAuthStatus,
} from "./github-oauth"
export {
githubDeviceFlowStart,
githubDeviceFlowPoll,
githubDeviceFlowCancel,
githubPollIntervalMs,
githubSlowDownIntervalMs,
githubFlowExpiresAt,
marketplaceAuthStatus,
marketplaceLogout,
} from "./github-oauth"
// marketplace-cache
export {
+1 -1
View File
@@ -389,7 +389,7 @@ export interface UserPreferences {
audioRecordingMaxEntries: number | null;
/** Marketplace HTTP 基地址。空 = 本地开发默认 http://127.0.0.1:8090;生产填 https://api.<domain>。 */
marketplaceBaseUrl: string;
/** Marketplace dev-mode 模拟登录用户名(GitHub login 风格)。生产换 OAuth token 后此字段废弃。 */
/** GitHub login 展示缓存。不用于认证;OAuth token 只存在 Rust CredentialsVault。 */
marketplaceDevLogin: string;
/** 是否启用远程输入(局域网手机录音)HTTPS+WS 服务。默认 false。 */
remoteInputEnabled: boolean;
+62 -21
View File
@@ -9,7 +9,7 @@
//
// 后端 URL 走 prefs.marketplaceBaseUrl,dev 模式默认 http://127.0.0.1:8090;
// 用户在 Settings 填生产 URL 后客户端自动切换。
// dev 上传需要 prefs.marketplaceDevLogin(GitHub login 风格)—— 空时上传按钮 disabled。
// GitHub login 只用作展示;是否可写由 Rust 端凭据库中的 OAuth token 决定。
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
@@ -26,6 +26,7 @@ import {
listMarketplace,
listStylePacks,
marketplaceDelete,
marketplaceAuthStatus,
marketplaceMyLikes,
marketplaceMyPacks,
readMarketplaceDetailCache,
@@ -77,8 +78,27 @@ export function Marketplace() {
// 当前用户赞过的 pack id 集合 —— 用于红心渲染 + 「我赞过的」过滤。
// 进入 marketplace 时拉一次;点星后本地 mutate。
const [likedIds, setLikedIds] = useState<Set<string>>(new Set());
const canUpload = (prefs?.marketplaceDevLogin ?? '').trim().length > 0;
const currentLogin = (prefs?.marketplaceDevLogin ?? '').trim();
const [marketplaceSignedIn, setMarketplaceSignedIn] = useState(false);
const authorizedLogin = marketplaceSignedIn ? currentLogin : '';
const canUpload = marketplaceSignedIn;
const refreshAuthStatus = useCallback(async () => {
try {
const status = await marketplaceAuthStatus();
setMarketplaceSignedIn(status.signedIn);
if (!status.signedIn) {
setLikedIds(new Set());
setMyPacks([]);
if (currentLogin) {
await updatePrefs(current => ({ ...current, marketplaceDevLogin: '' }));
}
}
return status.signedIn;
} catch {
setMarketplaceSignedIn(false);
return false;
}
}, [currentLogin, updatePrefs]);
// 「衍生自」只在 origin 作者 != 当前登录身份时显示 —— 自己的 pack 不要给自己挂衍生标签。
const isDerivative = (originLogin: string | null | undefined): boolean =>
!!originLogin && originLogin !== currentLogin;
@@ -142,22 +162,31 @@ export function Marketplace() {
void refresh();
}, [refresh]);
useEffect(() => {
void refreshAuthStatus();
}, [currentLogin, refreshAuthStatus]);
// 拉一次「我赞过的」缓存,渲染红心 + 「我赞过的」过滤。登录身份变更时重拉。
useEffect(() => {
let cancelled = false;
if (!marketplaceSignedIn) {
setLikedIds(new Set());
return () => { cancelled = true; };
}
void (async () => {
try {
const ids = await marketplaceMyLikes();
if (!cancelled) setLikedIds(new Set(ids));
} catch (error) {
void refreshAuthStatus();
console.warn('[marketplace] fetch my-likes failed', error);
}
})();
return () => { cancelled = true; };
}, [currentLogin]);
}, [marketplaceSignedIn, refreshAuthStatus]);
const refreshMyPacks = useCallback(async () => {
if (!currentLogin) {
if (!marketplaceSignedIn) {
setMyPacks([]);
setMyPacksLoading(false);
setMyPacksError(null);
@@ -169,6 +198,7 @@ export function Marketplace() {
const packs = await marketplaceMyPacks();
setMyPacks(packs);
} catch (error) {
void refreshAuthStatus();
console.warn('[marketplace] fetch my-packs failed', error);
const msg = errorMessage(error);
setMyPacksError(msg);
@@ -177,7 +207,7 @@ export function Marketplace() {
} finally {
setMyPacksLoading(false);
}
}, [currentLogin, t]);
}, [marketplaceSignedIn, refreshAuthStatus, t]);
useEffect(() => {
void refreshMyPacks();
@@ -185,10 +215,10 @@ export function Marketplace() {
// 弹框打开时刷新一次「我的发布」,避免显示陈旧数据。
useEffect(() => {
if (showMyPacks && currentLogin) {
if (showMyPacks && marketplaceSignedIn) {
void refreshMyPacks();
}
}, [showMyPacks, currentLogin, refreshMyPacks]);
}, [showMyPacks, marketplaceSignedIn, refreshMyPacks]);
const openDetail = async (id: string) => {
const seq = ++detailSeqRef.current;
@@ -241,6 +271,11 @@ export function Marketplace() {
const onLike = async () => {
if (!detail) return;
if (!marketplaceSignedIn) {
setActionMsg({ kind: 'err', text: t('marketplace.myPacks.notLoggedIn') });
setShowLogin(true);
return;
}
const packId = detail.id;
const prevLikedIds = likedIds;
const prevLikeCount = detail.likeCount;
@@ -267,6 +302,7 @@ export function Marketplace() {
return next;
});
} catch (error) {
void refreshAuthStatus();
// rollback 到点击前的状态
setLikedIds(prevLikedIds);
setDetail(prev => (prev && prev.id === packId ? { ...prev, likeCount: prevLikeCount } : prev));
@@ -315,6 +351,7 @@ export function Marketplace() {
setItems(prev => prev.filter(p => p.id !== detail.id));
void refresh();
} catch (error) {
void refreshAuthStatus();
setActionMsg({ kind: 'err', text: t('marketplace.detail.withdrawFailed', { err: errorMessage(error) }) });
}
};
@@ -330,6 +367,7 @@ export function Marketplace() {
setItems(prev => prev.filter(p => p.id !== pack.id));
void refreshMyPacks();
} catch (error) {
void refreshAuthStatus();
setActionMsg({ kind: 'err', text: t('marketplace.detail.withdrawFailed', { err: errorMessage(error) }) });
}
};
@@ -389,12 +427,14 @@ export function Marketplace() {
// 这里只需单次兜底刷新;取较长延时(5s)确保后端最终一致后能查到,去掉冗余的 1.5s 那次。
window.setTimeout(() => { void refresh(); void refreshMyPacks(); }, 5000);
} catch (error) {
void refreshAuthStatus();
setActionMsg({ kind: 'err', text: t('marketplace.errors.upload', { err: errorMessage(error) }) });
}
};
// GitHub 登录成功 → 写回 prefs.marketplaceDevLogin,让后续 X-Dev-User 走真实身份。
// GitHub 登录成功后 Rust 已保存 token;prefs 只缓存 login 供界面展示。
const onLoginSuccess = useCallback((nextLogin: string) => {
setMarketplaceSignedIn(true);
// prefs 写入失败只 console 记一笔(与重构前的 OAuth 轮询一致)—— 不能裸 void,
// 否则 reject 会冒成未处理的 promise rejection。
void updatePrefs(current => ({ ...current, marketplaceDevLogin: nextLogin }))
@@ -422,7 +462,7 @@ export function Marketplace() {
<button
type="button"
onClick={() => setShowMyPacks(true)}
title={currentLogin ? t('marketplace.myPacks.buttonTitle', { login: currentLogin }) : t('marketplace.myPacks.buttonTitleEmpty')}
title={authorizedLogin ? t('marketplace.myPacks.buttonTitle', { login: authorizedLogin }) : t('marketplace.myPacks.buttonTitleEmpty')}
style={{
display: 'inline-flex', alignItems: 'center', gap: 8,
height: 30, padding: '0 12px', borderRadius: 9,
@@ -440,7 +480,7 @@ export function Marketplace() {
background: 'var(--ol-surface-2)',
fontSize: 10, fontWeight: 750,
}}>
{(currentLogin || '?').slice(0, 1).toUpperCase()}
{(authorizedLogin || '?').slice(0, 1).toUpperCase()}
</span>
<span>{t('marketplace.myPacks.buttonLabel')}</span>
</button>
@@ -680,7 +720,7 @@ export function Marketplace() {
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, alignItems: 'center' }}>
<div>
{detail.authorLogin === currentLogin && currentLogin.length > 0 && (
{marketplaceSignedIn && detail.authorLogin === currentLogin && currentLogin.length > 0 && (
<Btn variant="ghost" size="sm" onClick={() => void onDelete()}>
<span style={{ color: '#ef4444', marginRight: 4 }}>🗑</span>
{t('marketplace.detail.withdrawBtn')}
@@ -692,6 +732,7 @@ export function Marketplace() {
whileTap={{ scale: 0.75 }}
transition={{ type: 'spring', stiffness: 400, damping: 17 }}
onClick={() => void onLike()}
aria-label={marketplaceSignedIn ? undefined : t('marketplace.oauth.loginBtn')}
style={{
display: 'inline-flex',
alignItems: 'center',
@@ -860,14 +901,14 @@ export function Marketplace() {
已登录时再点会重新走一次(切账号)。 */}
<button
type="button"
title={currentLogin ? t('marketplace.oauth.reloginTooltip', { login: currentLogin }) : t('marketplace.oauth.loginTooltip')}
title={authorizedLogin ? t('marketplace.oauth.reloginTooltip', { login: authorizedLogin }) : t('marketplace.oauth.loginTooltip')}
onClick={() => setShowLogin(true)}
style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '5px 10px', borderRadius: 9,
border: '0.5px solid var(--ol-line-strong)',
background: currentLogin ? 'var(--ol-blue-soft)' : 'var(--ol-surface)',
color: currentLogin ? 'var(--ol-blue)' : 'var(--ol-ink-3)',
background: authorizedLogin ? 'var(--ol-blue-soft)' : 'var(--ol-surface)',
color: authorizedLogin ? 'var(--ol-blue)' : 'var(--ol-ink-3)',
fontSize: 12, fontWeight: 650,
cursor: 'pointer',
whiteSpace: 'nowrap',
@@ -876,12 +917,12 @@ export function Marketplace() {
<span style={{
width: 18, height: 18, borderRadius: 999,
display: 'inline-grid', placeItems: 'center',
background: currentLogin ? 'rgba(37,99,235,0.14)' : 'var(--ol-surface-2)',
background: authorizedLogin ? 'rgba(37,99,235,0.14)' : 'var(--ol-surface-2)',
fontSize: 10, fontWeight: 750,
}}>
{(currentLogin || '?').slice(0, 1).toUpperCase()}
{(authorizedLogin || '?').slice(0, 1).toUpperCase()}
</span>
<span>{currentLogin ? `@${currentLogin}` : t('marketplace.oauth.loginBtn')}</span>
<span>{authorizedLogin ? `@${authorizedLogin}` : t('marketplace.oauth.loginBtn')}</span>
</button>
{/* 关闭 × */}
<button
@@ -909,7 +950,7 @@ export function Marketplace() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 12 }}>
<div style={{ fontSize: 11.5, color: 'var(--ol-ink-3)' }}>
{(() => {
if (!currentLogin) return t('marketplace.myPacks.notLoggedIn');
if (!marketplaceSignedIn) return t('marketplace.myPacks.notLoggedIn');
const activeCount = visibleMyPacks.length;
const pendingCount = visibleMyPacks.filter(p => p.state === 'pending').length;
return pendingCount > 0
@@ -918,7 +959,7 @@ export function Marketplace() {
})()}
</div>
<div style={{ display: 'flex', gap: 6 }}>
<Btn icon="refresh" variant="ghost" size="sm" onClick={() => void refreshMyPacks()} disabled={!currentLogin || myPacksLoading}>
<Btn icon="refresh" variant="ghost" size="sm" onClick={() => void refreshMyPacks()} disabled={!marketplaceSignedIn || myPacksLoading}>
{t('common.refresh')}
</Btn>
<span title={canUpload ? '' : t('marketplace.uploadDisabledHint')}>
@@ -965,11 +1006,11 @@ export function Marketplace() {
return (
<div style={{ padding: '32px 12px', textAlign: 'center' }}>
<div style={{ fontSize: 13, color: 'var(--ol-ink-3)', marginBottom: 6 }}>
{currentLogin
{marketplaceSignedIn
? (myPacks.length === 0 ? t('marketplace.myPacks.emptyTitle') : t('marketplace.myPacks.noMatch'))
: t('marketplace.myPacks.notLoggedIn')}
</div>
{currentLogin && myPacks.length === 0 && (
{marketplaceSignedIn && myPacks.length === 0 && (
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)' }}>
{t('marketplace.myPacks.emptyHint')}
</div>
+29 -2
View File
@@ -8,6 +8,7 @@ import {
importStylePackFromZip,
isTauri,
listStylePacks,
marketplaceAuthStatus,
previewStylePackRuntime,
resetBuiltinStylePack,
saveStylePack,
@@ -120,8 +121,10 @@ function sanitizeZipFileName(name: string) {
export function Style() {
const { t } = useTranslation();
const { prefs: marketplacePrefs } = useHotkeySettings();
const canPublish = (marketplacePrefs?.marketplaceDevLogin ?? '').trim().length > 0;
const { prefs: marketplacePrefs, updatePrefs: updateMarketplacePrefs } = useHotkeySettings();
const marketplaceDisplayLogin = (marketplacePrefs?.marketplaceDevLogin ?? '').trim();
const [marketplaceSignedIn, setMarketplaceSignedIn] = useState(false);
const canPublish = marketplaceSignedIn;
const [packs, setPacks] = useState<StylePack[]>([]);
@@ -141,6 +144,22 @@ export function Style() {
const [runtimePreview, setRuntimePreview] = useState<StylePackRuntimeDiagnostics | null>(null);
const [runtimePreviewError, setRuntimePreviewError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
void marketplaceAuthStatus()
.then(async status => {
if (cancelled) return;
setMarketplaceSignedIn(status.signedIn);
if (!status.signedIn && marketplaceDisplayLogin) {
await updateMarketplacePrefs(current => ({ ...current, marketplaceDevLogin: '' }));
}
})
.catch(() => {
if (!cancelled) setMarketplaceSignedIn(false);
});
return () => { cancelled = true; };
}, [marketplaceDisplayLogin, updateMarketplacePrefs]);
useEffect(() => () => {
if (statusTimer.current !== null) window.clearTimeout(statusTimer.current);
if (editorCloseTimer.current !== null) window.clearTimeout(editorCloseTimer.current);
@@ -475,6 +494,14 @@ export function Style() {
await uploadMarketplacePack(pack.id);
showSaveStatus('saved', t('style.pack.publishSuccess'), true);
} catch (publishError) {
void marketplaceAuthStatus()
.then(async status => {
setMarketplaceSignedIn(status.signedIn);
if (!status.signedIn && marketplaceDisplayLogin) {
await updateMarketplacePrefs(current => ({ ...current, marketplaceDevLogin: '' }));
}
})
.catch(() => setMarketplaceSignedIn(false));
showSaveStatus('failed', t('style.pack.publishFailed', { err: String(publishError) }));
} finally {
setBusy(null);
@@ -2,20 +2,39 @@
// 浏览与安装风格在「风格」页内完成,设置页只管登录身份。
//
// 登录走共用的 <GithubLoginModal />(GitHub OAuth Device Flow),与风格市场
// 完全一致 —— 点登录弹出统一登录窗口,授权成功写回 prefs.marketplaceDevLogin。
// 完全一致 —— 点登录弹出统一登录窗口,授权成功后 token 由
// Rust CredentialsVault 保管,prefs.marketplaceDevLogin 只作展示。
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useHotkeySettings } from '../../state/HotkeySettingsContext';
import { Icon } from '../../components/Icon';
import { GithubLoginModal } from '../../components/GithubLoginModal';
import { Btn, Card } from '../_atoms';
import { SectionTitle } from './shared';
import { marketplaceAuthStatus, marketplaceLogout } from '../../lib/ipc';
export function MarketplaceSection() {
const { t } = useTranslation();
const { prefs, updatePrefs: savePrefs } = useHotkeySettings();
const [showLogin, setShowLogin] = useState(false);
const [signedIn, setSignedIn] = useState(false);
useEffect(() => {
let cancelled = false;
void marketplaceAuthStatus()
.then(async status => {
if (cancelled) return;
setSignedIn(status.signedIn);
if (!status.signedIn && prefs?.marketplaceDevLogin.trim()) {
await savePrefs(current => ({ ...current, marketplaceDevLogin: '' }));
}
})
.catch(() => {
if (!cancelled) setSignedIn(false);
});
return () => { cancelled = true; };
}, [prefs?.marketplaceDevLogin, savePrefs]);
if (!prefs) {
return (
@@ -26,10 +45,17 @@ export function MarketplaceSection() {
}
const login = prefs.marketplaceDevLogin.trim();
const signedIn = login.length > 0;
const signOut = () => {
void savePrefs(current => ({ ...current, marketplaceDevLogin: '' }));
void (async () => {
try {
await marketplaceLogout();
setSignedIn(false);
await savePrefs(current => ({ ...current, marketplaceDevLogin: '' }));
} catch (error) {
console.warn('[marketplace] sign out failed', error);
}
})();
};
return (
@@ -98,6 +124,7 @@ export function MarketplaceSection() {
<GithubLoginModal
onClose={() => setShowLogin(false)}
onSuccess={nextLogin => {
setSignedIn(true);
void savePrefs(current => ({ ...current, marketplaceDevLogin: nextLogin }))
.catch(e => console.warn('[marketplace] save login to prefs failed', e));
}}