From 54b5ec751cb3b8fb7a450cb09d1bbbebb83dc70a Mon Sep 17 00:00:00 2001 From: baiqing Date: Sat, 16 May 2026 17:01:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20marketplace=20=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E7=A7=BB=E5=88=B0=20refresh=20=E5=B7=A6=20+=20list=20cache=20?= =?UTF-8?q?=E7=A7=92=E5=BC=80=20+=20GitHub=20OAuth=20Device=20Flow=20Phase?= =?UTF-8?q?=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Style.tsx: - 「风格市场」按钮从 PageHeader.titleRight 移到 .right 槽,放在「刷新」按钮左边(按用户需求)。 - 删除 titleRight 那块独立的 pill 按钮,与其他动作按钮统一为 Btn variant=ghost icon=cloud。 ipc.ts: - 加 sessionStorage 缓存:readMarketplaceListCache / writeMarketplaceListCache,5 分钟 TTL。 - 加 GitHub OAuth Device Flow IPC wrappers:githubDeviceFlowStart / githubDeviceFlowPoll + 类型定义。 commands.rs: - 新增 github_device_flow_start:调 GitHub /login/device/code,返回 user_code/device_code/verification_uri。 - 新增 github_device_flow_poll:轮询 /login/oauth/access_token,拿到 token 后调 /user,返回 login。 - GITHUB_OAUTH_CLIENT_ID 常量:留空 + 详细错误指引(去 https://github.com/settings/applications/new 注册); 支持环境变量 GITHUB_OAUTH_CLIENT_ID 覆盖(dev 方便)。 lib.rs: - 注册 github_device_flow_start / github_device_flow_poll 到 invoke_handler。 Marketplace.tsx: - items state 用 readMarketplaceListCache() 做 lazy init —— 重开「风格市场」秒看到上次默认视图。 - refresh() 拿到默认视图(popular + 空 query)的列表后写缓存。 - 加载 UI 改成 (loading && items.length === 0) —— 有缓存时不显示「加载中」遮挡数据。 - OAuth 状态机:idle → starting → pending (展示 user_code) → success / error。 - 「我的发布」弹框里的 @login chip 点击 → 触发 GitHub OAuth Device Flow;浏览器自动拉起 verification_uri; 轮询直到用户授权完成;拿到 login 后自动 updatePrefs(marketplaceDevLogin)。 - 新 OAuth 弹框 UI:大字号 user_code + 一键复制 + 打开浏览器 + 取消 + waiting pulse。 Phase 2 留给后续:marketplace backend 验证 GitHub JWT 防止 X-Dev-User 伪造。 --- openless-all/app/src-tauri/src/commands.rs | 147 ++++++++++++++ openless-all/app/src-tauri/src/lib.rs | 2 + openless-all/app/src/lib/ipc.ts | 66 +++++++ openless-all/app/src/pages/Marketplace.tsx | 218 +++++++++++++++++++-- openless-all/app/src/pages/Style.tsx | 24 +-- 5 files changed, 426 insertions(+), 31 deletions(-) diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 5f18d26c..f85317ed 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -2677,6 +2677,153 @@ pub async fn marketplace_my_packs( .map_err(|e| format!("parse my-packs failed: {e}")) } +// ─────────────────────── 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=(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 填到本常量 + +const GITHUB_OAUTH_CLIENT_ID: &str = ""; + +fn get_github_oauth_client_id() -> Result { + if let Ok(env_id) = std::env::var("GITHUB_OAUTH_CLIENT_ID") { + let trimmed = env_id.trim(); + if !trimmed.is_empty() { + return Ok(trimmed.to_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=。" + .to_string()) +} + +#[derive(Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GithubDeviceStartResponse { + pub device_code: 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 { + let client_id = get_github_oauth_client_id()?; + let client = reqwest::Client::new(); + let resp = client + .post("https://github.com/login/device/code") + .header("Accept", "application/json") + .header("User-Agent", "OpenLess") + .form(&[("client_id", client_id.as_str()), ("scope", "read:user")]) + .send() + .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 { + Authorized { login: String }, + Pending, + SlowDown, + Error { message: String }, +} + +#[tauri::command] +pub async fn github_device_flow_poll( + device_code: String, +) -> Result { + let client_id = get_github_oauth_client_id()?; + let client = reqwest::Client::new(); + let token_resp = client + .post("https://github.com/login/oauth/access_token") + .header("Accept", "application/json") + .header("User-Agent", "OpenLess") + .form(&[ + ("client_id", client_id.as_str()), + ("device_code", device_code.as_str()), + ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), + ]) + .send() + .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}"))?; + + if let Some(token) = body["access_token"].as_str() { + let user_resp = client + .get("https://api.github.com/user") + .header("User-Agent", "OpenLess") + .header("Accept", "application/vnd.github+json") + .bearer_auth(token) + .send() + .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() { + return Ok(GithubDevicePollResult::Error { + message: "GitHub /user 返回空 login".to_string(), + }); + } + 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 }) +} + #[cfg(test)] mod tests { use super::{ diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 0463a7c1..9b758258 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -292,6 +292,8 @@ pub fn run() { commands::marketplace_my_likes, commands::marketplace_my_packs, commands::marketplace_delete, + commands::github_device_flow_start, + commands::github_device_flow_poll, commands::list_vocab, commands::add_vocab, commands::remove_vocab, diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index 428383f7..70763c45 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -1031,3 +1031,69 @@ export function marketplaceMyPacks(): Promise { export function marketplaceDelete(packId: string): Promise { return invokeOrMock('marketplace_delete', { packId }, () => undefined); } + +// ─────────────────────── GitHub OAuth Device Flow (Phase 1) ─────────────── +// 客户端直连 GitHub OAuth Device Flow 拿 login,自动写进 prefs.marketplaceDevLogin。 +// marketplace backend 不动(继续走 X-Dev-User header;Phase 2 才接 JWT 验证)。 +// +// 后端 Rust 实现:commands.rs:github_device_flow_start / github_device_flow_poll +// 需要预先配置 GITHUB_OAUTH_CLIENT_ID(OAuth App client_id,非敏感,可硬编码)。 + +export interface GithubDeviceStartResponse { + deviceCode: string; + userCode: string; + verificationUri: string; + interval: number; + expiresIn: number; +} + +export type GithubDevicePollResult = + | { kind: 'authorized'; login: string } + | { kind: 'pending' } + | { kind: 'slowDown' } + | { kind: 'error'; message: string }; + +export function githubDeviceFlowStart(): Promise { + return invokeOrMock('github_device_flow_start', undefined, () => ({ + deviceCode: 'mock-device-code-xxxxxxxx', + userCode: 'MOCK-CODE', + verificationUri: 'https://github.com/login/device', + interval: 5, + expiresIn: 900, + })); +} + +export function githubDeviceFlowPoll(deviceCode: string): Promise { + return invokeOrMock('github_device_flow_poll', { deviceCode }, () => ({ + kind: 'authorized' as const, + login: 'mock-user', + })); +} + +// ─────────────────────── Marketplace list 缓存 (sessionStorage) ───────────── +// 让用户重开「风格市场」时秒看到上次列表,再后台 refresh 校准。 +// 只缓存「默认视图」(query='' + sort='popular'),避免缓存 query/sort 组合爆炸。 + +const MARKETPLACE_LIST_CACHE_KEY = 'ol-marketplace-list-cache-v1'; +const MARKETPLACE_LIST_CACHE_TTL_MS = 5 * 60 * 1000; // 5 分钟内信任缓存 + +export function readMarketplaceListCache(): MarketplaceListItem[] | null { + try { + const raw = sessionStorage.getItem(MARKETPLACE_LIST_CACHE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as { items: MarketplaceListItem[]; ts: number }; + if (!parsed || !Array.isArray(parsed.items)) return null; + if (Date.now() - parsed.ts > MARKETPLACE_LIST_CACHE_TTL_MS) return null; + return parsed.items; + } catch { + return null; + } +} + +export function writeMarketplaceListCache(items: MarketplaceListItem[]): void { + try { + sessionStorage.setItem(MARKETPLACE_LIST_CACHE_KEY, JSON.stringify({ items, ts: Date.now() })); + } catch { + // quota exceeded / disabled — silent + } +} diff --git a/openless-all/app/src/pages/Marketplace.tsx b/openless-all/app/src/pages/Marketplace.tsx index 3634744e..82b68674 100644 --- a/openless-all/app/src/pages/Marketplace.tsx +++ b/openless-all/app/src/pages/Marketplace.tsx @@ -16,6 +16,8 @@ import { useTranslation } from 'react-i18next'; import { Icon } from '../components/Icon'; import { fetchMarketplaceDetail, + githubDeviceFlowPoll, + githubDeviceFlowStart, installMarketplacePack, likeMarketplacePack, listMarketplace, @@ -23,7 +25,10 @@ import { marketplaceDelete, marketplaceMyLikes, marketplaceMyPacks, + openExternal, + readMarketplaceListCache, uploadMarketplacePack, + writeMarketplaceListCache, } from '../lib/ipc'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; import type { MarketplaceDetail, MarketplaceListItem, MarketplaceMyPackItem, StylePack } from '../lib/types'; @@ -33,9 +38,10 @@ type SortMode = 'popular' | 'new' | 'liked'; export function Marketplace() { const { t } = useTranslation(); - const { prefs } = useHotkeySettings(); + const { prefs, updatePrefs } = useHotkeySettings(); - const [items, setItems] = useState([]); + // 启动时尝试读缓存:上次默认视图(popular + 空 query)的列表,秒呈现。后台 refresh 校准。 + const [items, setItems] = useState(() => readMarketplaceListCache() ?? []); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(null); const [query, setQuery] = useState(''); @@ -77,6 +83,15 @@ export function Marketplace() { const [myPacksQuery, setMyPacksQuery] = useState(''); // 弹框内已下架包 5 分钟自动消失:tick 每 30s 一次,让 visibleMyPacks 重新计算。 const [nowTick, setNowTick] = useState(() => Date.now()); + // GitHub OAuth Device Flow 状态。点登录 chip → 'starting' → 'pending'(展示 user_code 等待 + // 用户在浏览器授权)→ 'success'(自动保存 marketplaceDevLogin)/ 'error'。 + type OAuthPhase = + | { phase: 'idle' } + | { phase: 'starting' } + | { phase: 'pending'; userCode: string; verificationUri: string; deviceCode: string } + | { phase: 'success'; login: string } + | { phase: 'error'; message: string }; + const [oauth, setOauth] = useState({ phase: 'idle' }); // 当前用户赞过的 pack id 集合 —— 用于红心渲染 + 「我赞过的」过滤。 // 进入 marketplace 时拉一次;点星后本地 mutate。 const [likedIds, setLikedIds] = useState>(new Set()); @@ -107,6 +122,10 @@ export function Marketplace() { const list = await listMarketplace({ query: debouncedQuery, sort: serverSort, limit: 50 }); if (seq !== reqSeqRef.current) return; // stale response setItems(list); + // 只缓存「默认视图」(popular + 空 query),重开时秒出。 + if (serverSort === 'popular' && debouncedQuery.trim() === '') { + writeMarketplaceListCache(list); + } } catch (error) { if (seq !== reqSeqRef.current) return; console.error('[marketplace] list failed', error); @@ -366,6 +385,68 @@ export function Marketplace() { } }; + // GitHub OAuth Device Flow 入口:点登录 chip 触发。 + const beginGithubLogin = useCallback(async () => { + setOauth({ phase: 'starting' }); + try { + const start = await githubDeviceFlowStart(); + setOauth({ + phase: 'pending', + userCode: start.userCode, + verificationUri: start.verificationUri, + deviceCode: start.deviceCode, + }); + // 自动拉起浏览器到 verification_uri;失败不致命,用户可以手动复制点击 + try { await openExternal(start.verificationUri); } catch { /* user can copy manually */ } + } catch (error) { + setOauth({ phase: 'error', message: errorMessage(error) }); + } + }, []); + + // OAuth 轮询:phase==='pending' 时每 interval 秒打 backend → GitHub 一次。 + useEffect(() => { + if (oauth.phase !== 'pending') return; + let cancelled = false; + let timer: number | null = null; + let interval = 5_000; + const pendingDeviceCode = oauth.deviceCode; + const tick = async () => { + if (cancelled) return; + try { + const res = await githubDeviceFlowPoll(pendingDeviceCode); + if (cancelled) return; + if (res.kind === 'authorized') { + setOauth({ phase: 'success', login: res.login }); + // 写入 prefs.marketplaceDevLogin,让后续 X-Dev-User 走真实 GitHub login。 + try { + await updatePrefs(current => ({ ...current, marketplaceDevLogin: res.login })); + } catch (e) { + console.warn('[oauth] save login to prefs failed', e); + } + setActionMsg({ kind: 'ok', text: `已登录为 @${res.login}` }); + window.setTimeout(() => { + if (!cancelled) setOauth({ phase: 'idle' }); + }, 1500); + } else if (res.kind === 'slowDown') { + interval = Math.min(interval + 5_000, 30_000); + timer = window.setTimeout(tick, interval); + } else if (res.kind === 'pending') { + timer = window.setTimeout(tick, interval); + } else { + setOauth({ phase: 'error', message: res.message }); + } + } catch (error) { + if (cancelled) return; + setOauth({ phase: 'error', message: errorMessage(error) }); + } + }; + timer = window.setTimeout(tick, interval); + return () => { + cancelled = true; + if (timer != null) window.clearTimeout(timer); + }; + }, [oauth, updatePrefs]); + const sortPills = useMemo>( () => [ { id: 'popular', label: t('marketplace.sortPopular') }, @@ -531,7 +612,8 @@ export function Marketplace() { {/* 卡片列表 / 我的发布 */}
- {loading ? ( + {loading && items.length === 0 ? ( + // 只在没有缓存数据时才显示 loading;有缓存就直接渲染缓存数据,后台 refresh 校准
{t('common.loading')}
@@ -775,15 +857,13 @@ export function Marketplace() { }} />
- {/* 用户名 + 登录 chip。点击 → 触发 OAuth Device Flow(上线后接入); - 现阶段提示用户去 Settings 填发布身份。 */} + {/* 用户名 + 登录 chip。点击 → 触发 GitHub OAuth Device Flow。 + 已登录时再点会重新走一次(切账号)。 */} + + + {oauth.phase === 'starting' && ( +
+ 正在生成设备验证码… +
+ )} + + {oauth.phase === 'pending' && ( +
+
+ 在浏览器中打开 {oauth.verificationUri} 并输入下方代码: +
+
+ {oauth.userCode} + { + try { + await navigator.clipboard.writeText(oauth.userCode); + setActionMsg({ kind: 'ok', text: '已复制设备码' }); + } catch (e) { + setActionMsg({ kind: 'err', text: `复制失败:${errorMessage(e)}` }); + } + }} + > + 复制 + +
+
+ void openExternal(oauth.verificationUri)}> + 打开浏览器 + + setOauth({ phase: 'idle' })}> + 取消 + +
+
+ + 等待你在浏览器中授权… +
+ +
+ )} + + {oauth.phase === 'success' && ( +
+
✓
+
已登录为 @{oauth.login}
+
+ )} + + {oauth.phase === 'error' && ( +
+
+ {oauth.message} +
+
+ setOauth({ phase: 'idle' })}>关闭 + void beginGithubLogin()}>重试 +
+
+ )} + + )} ); } diff --git a/openless-all/app/src/pages/Style.tsx b/openless-all/app/src/pages/Style.tsx index 1ed20eb0..e5e918aa 100644 --- a/openless-all/app/src/pages/Style.tsx +++ b/openless-all/app/src/pages/Style.tsx @@ -643,28 +643,12 @@ export function Style() { kicker={copy.kicker} title={copy.title} desc={copy.desc} - titleRight={( - - )} right={(
+ {/* 风格市场入口:放在 刷新 左边(按用户需求)。点击 → 全屏弹框承载 。*/} + setMarketplaceOpen(true)}> + {isEnglish ? 'Marketplace' : '风格市场'} + void loadPacks(selectedId)} disabled={busy === 'loading'}> {t('common.refresh')}