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:
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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
}}
|
||||
|
||||
Reference in new issue
Block a user