diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b6f4b1964..2d6d2496a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -330,9 +330,6 @@ jobs: with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as env_file: env_file.write(f"TAURI_SIGNING_PRIVATE_KEY_PATH={key_path}\n") - env_file.write("TAURI_SIGNING_PRIVATE_KEY<<__TAURI_SIGNING_PRIVATE_KEY__\n") - env_file.write(normalized_key.rstrip("\n") + "\n") - env_file.write("__TAURI_SIGNING_PRIVATE_KEY__\n") print(f"Normalized updater key written to {key_path}") PY diff --git a/src/RootRouter.test.tsx b/src/RootRouter.test.tsx new file mode 100644 index 000000000..55cdb06e8 --- /dev/null +++ b/src/RootRouter.test.tsx @@ -0,0 +1,123 @@ +import { act } from "react"; +import type { ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { RootRouter } from "./RootRouter"; + +const { + mockFinalizeModuleImportAutoReload, + mockGetRuntimeAppVersion, + mockStartOemCloudStartupLoginIfRequired, +} = vi.hoisted(() => ({ + mockFinalizeModuleImportAutoReload: vi.fn(), + mockGetRuntimeAppVersion: vi.fn(() => "0.0.0-test"), + mockStartOemCloudStartupLoginIfRequired: vi.fn(), +})); + +vi.mock("./App", () => ({ + default: () =>
主应用
, +})); + +vi.mock("./pages/smart-input", () => ({ + SmartInputPage: () =>
, +})); + +vi.mock("./pages/update-notification", () => ({ + UpdateNotificationPage: () =>
, +})); + +vi.mock("./pages", () => ({ + BrowserRuntimeDebuggerPage: () => ( +
+ ), +})); + +vi.mock("./features/resource-manager", () => ({ + ResourceManagerPage: () =>
, +})); + +vi.mock("./components/settings-v2/system/chrome-relay/guide-window", () => ({ + BrowserConnectorGuideWindow: () => ( +
+ ), +})); + +vi.mock("./components/ui/sonner", () => ({ + Toaster: () =>
, +})); + +vi.mock("./components/layout/AppCrashBoundary", () => ({ + AppCrashBoundary: ({ children }: { children: ReactNode }) => ( + <>{children} + ), +})); + +vi.mock("./components/layout/CrashRecoveryPanel.helpers", () => ({ + finalizeModuleImportAutoReload: mockFinalizeModuleImportAutoReload, +})); + +vi.mock("./lib/appVersion", () => ({ + getRuntimeAppVersion: mockGetRuntimeAppVersion, +})); + +vi.mock("./lib/oemCloudStartupLogin", () => ({ + startOemCloudStartupLoginIfRequired: + mockStartOemCloudStartupLoginIfRequired, +})); + +interface MountedRootRouter { + container: HTMLDivElement; + root: Root; +} + +const mounted: MountedRootRouter[] = []; + +async function renderRootRouter(pathname: string) { + window.history.pushState({}, "", pathname); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + + const page = { container, root }; + mounted.push(page); + return page; +} + +describe("RootRouter", () => { + beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + mockStartOemCloudStartupLoginIfRequired.mockResolvedValue({ + status: "not_configured", + }); + }); + + afterEach(() => { + for (const item of mounted.splice(0)) { + act(() => item.root.unmount()); + item.container.remove(); + } + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("打开主应用时应触发启动期云端登录判断", async () => { + const { container } = await renderRootRouter("/"); + + expect(container.textContent).toContain("主应用"); + expect(mockStartOemCloudStartupLoginIfRequired).toHaveBeenCalledTimes(1); + }); + + it("独立工具窗口不应触发主应用登录流程", async () => { + const { container } = await renderRootRouter("/smart-input"); + + expect( + container.querySelector('[data-testid="smart-input-page"]'), + ).not.toBeNull(); + expect(mockStartOemCloudStartupLoginIfRequired).not.toHaveBeenCalled(); + }); +}); diff --git a/src/RootRouter.tsx b/src/RootRouter.tsx index d9b36b73d..9691eb937 100644 --- a/src/RootRouter.tsx +++ b/src/RootRouter.tsx @@ -14,6 +14,7 @@ import { Toaster } from "./components/ui/sonner"; import { AppCrashBoundary } from "./components/layout/AppCrashBoundary"; import { finalizeModuleImportAutoReload } from "./components/layout/CrashRecoveryPanel.helpers"; import { getRuntimeAppVersion } from "./lib/appVersion"; +import { startOemCloudStartupLoginIfRequired } from "./lib/oemCloudStartupLogin"; /** * 根据 URL 路径渲染对应的组件 @@ -27,6 +28,13 @@ import { getRuntimeAppVersion } from "./lib/appVersion"; */ export function RootRouter() { const pathname = window.location.pathname; + const isMainAppRoute = ![ + "/smart-input", + "/update-notification", + "/browser-runtime-debugger", + "/resource-manager", + "/browser-connector-guide", + ].includes(pathname); useEffect(() => { if (typeof window === "undefined") { @@ -41,6 +49,14 @@ export function RootRouter() { ); }, [pathname]); + useEffect(() => { + if (!isMainAppRoute) { + return; + } + + void startOemCloudStartupLoginIfRequired(); + }, [isMainAppRoute]); + // 截图对话悬浮窗口路由(也用于语音输入) if (pathname === "/smart-input") { return ( diff --git a/src/components/settings-v2/system/web-search/index.test.tsx b/src/components/settings-v2/system/web-search/index.test.tsx index 03ed96e63..a303e52e0 100644 --- a/src/components/settings-v2/system/web-search/index.test.tsx +++ b/src/components/settings-v2/system/web-search/index.test.tsx @@ -285,6 +285,7 @@ describe("WebSearchSettings", () => { ); await leaveTip(heroTip); + await switchTab(document.body, "图片搜索"); const pexelsTip = await hoverTip("Pexels 接入说明"); expect(getBodyText()).toContain( "申请地址:https://www.pexels.com/api/new/", @@ -312,6 +313,8 @@ describe("WebSearchSettings", () => { findInput(container, "web-search-provider-priority"), "multi_search_engine, tavily, bing_search_api", ); + + await switchTab(container, "Provider 凭证"); await setInputValue( findInput(container, "web-search-tavily-key"), "tavily-new-key", @@ -328,6 +331,8 @@ describe("WebSearchSettings", () => { findInput(container, "web-search-google-engine-id"), "cx-new-id", ); + + await switchTab(container, "MSE 聚合"); await setInputValue( findInput(container, "web-search-mse-custom-engine-name"), "hn", @@ -336,6 +341,8 @@ describe("WebSearchSettings", () => { findInput(container, "web-search-mse-custom-engine-template"), "https://hn.algolia.com/?q={query}", ); + + await switchTab(container, "图片搜索"); await setInputValue( findInput(container, "web-search-pexels-key"), "new-key", @@ -384,6 +391,7 @@ describe("WebSearchSettings", () => { await flushEffects(); await flushEffects(); + await switchTab(container, "图片搜索"); await act(async () => { findButton(container, "申请 Pexels Key").click(); await flushEffects(); @@ -397,6 +405,7 @@ describe("WebSearchSettings", () => { await flushEffects(); await flushEffects(); + await switchTab(container, "Provider 凭证"); await act(async () => { findButton(container, "申请 Tavily Key").click(); await flushEffects(); @@ -417,6 +426,7 @@ describe("WebSearchSettings", () => { await flushEffects(); await flushEffects(); + await switchTab(container, "图片搜索"); await act(async () => { findButton(container, "申请 Pexels Key").click(); await flushEffects(); @@ -436,6 +446,7 @@ describe("WebSearchSettings", () => { await flushEffects(); await flushEffects(); + await switchTab(container, "图片搜索"); await act(async () => { findButton(container, "申请 Pixabay Key").click(); await flushEffects(); @@ -451,6 +462,7 @@ describe("WebSearchSettings", () => { await flushEffects(); await flushEffects(); + await switchTab(container, "Provider 凭证"); await act(async () => { findButton(container, "申请 Bing Key").click(); await flushEffects(); @@ -466,6 +478,7 @@ describe("WebSearchSettings", () => { await flushEffects(); await flushEffects(); + await switchTab(container, "Provider 凭证"); await act(async () => { findButton(container, "申请 Google Key").click(); await flushEffects(); @@ -481,6 +494,7 @@ describe("WebSearchSettings", () => { await flushEffects(); await flushEffects(); + await switchTab(container, "Provider 凭证"); await act(async () => { findButton(container, "创建 CSE").click(); await flushEffects(); diff --git a/src/lib/api/oemCloudControlPlane.test.ts b/src/lib/api/oemCloudControlPlane.test.ts index 6b799a35b..cbe06c6ef 100644 --- a/src/lib/api/oemCloudControlPlane.test.ts +++ b/src/lib/api/oemCloudControlPlane.test.ts @@ -15,6 +15,7 @@ import { getClientCreditsDashboard, getClientProviderOffer, getClientReferralDashboard, + listPublicOAuthProviders, listClientPaymentConfigs, listClientPlans, listClientProviderOfferModels, @@ -154,6 +155,54 @@ describe("oemCloudControlPlane desktop auth", () => { }); }); + it("应读取公开 OAuth Provider 目录供启动登录判断使用", async () => { + const fetchMock = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ + code: 200, + message: "success", + data: { + items: [ + { + provider: "google", + displayName: "Google", + authorizeUrl: "https://user.limeai.run/oauth/google", + redirectUri: "https://user.limeai.run/oauth/callback", + scopes: ["openid", "email"], + enabled: true, + loginHint: "使用 Google 登录", + }, + ], + }, + }), + })); + vi.stubGlobal("fetch", fetchMock); + + const providers = await listPublicOAuthProviders("tenant-0001"); + + expect(fetchMock).toHaveBeenCalledWith( + "https://user.limeai.run/api/v1/public/tenants/tenant-0001/oauth/providers", + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + Accept: "application/json", + }), + }), + ); + expect(providers).toEqual([ + { + provider: "google", + displayName: "Google", + authorizeUrl: "https://user.limeai.run/oauth/google", + redirectUri: "https://user.limeai.run/oauth/callback", + scopes: ["openid", "email"], + enabled: true, + loginHint: "使用 Google 登录", + }, + ]); + }); + it("应解析 bootstrap 中缓存的邀请开关与分享事实源", async () => { window.__LIME_SESSION_TOKEN__ = "session-token-001"; diff --git a/src/lib/api/oemCloudControlPlane.ts b/src/lib/api/oemCloudControlPlane.ts index 73df300ef..a9549df92 100644 --- a/src/lib/api/oemCloudControlPlane.ts +++ b/src/lib/api/oemCloudControlPlane.ts @@ -47,6 +47,16 @@ export interface OemCloudUserSession { expiresAt: string; } +export interface OemCloudPublicOAuthProvider { + provider: string; + displayName: string; + authorizeUrl?: string; + redirectUri?: string; + scopes: string[]; + enabled: boolean; + loginHint?: string; +} + export interface OemCloudCurrentSession extends Omit< OemCloudCurrentSessionLike, "tenant" | "user" | "session" @@ -1096,6 +1106,28 @@ function parseCurrentSession(value: unknown): OemCloudCurrentSession { }; } +function parsePublicOAuthProvider(value: unknown): OemCloudPublicOAuthProvider { + if (!isRecord(value)) { + throw new OemCloudControlPlaneError("OAuth Provider 格式非法"); + } + + const provider = normalizeText(value.provider); + const displayName = normalizeText(value.displayName) ?? provider; + if (!provider || !displayName) { + throw new OemCloudControlPlaneError("OAuth Provider 格式非法"); + } + + return { + provider, + displayName, + authorizeUrl: normalizeText(value.authorizeUrl) ?? undefined, + redirectUri: normalizeText(value.redirectUri) ?? undefined, + scopes: normalizeStringArray(value.scopes), + enabled: normalizeBoolean(value.enabled, true), + loginHint: normalizeText(value.loginHint) ?? undefined, + }; +} + function parseProviderOfferSummary( value: unknown, ): OemCloudProviderOfferSummary { @@ -2402,6 +2434,18 @@ export async function pollClientDesktopAuthSession( ); } +export async function listPublicOAuthProviders( + tenantId: string, +): Promise { + const response = await requestControlPlane<{ items?: unknown[] }>( + `/v1/public/tenants/${encodeURIComponent(tenantId)}/oauth/providers`, + ); + + return Array.isArray(response.items) + ? response.items.map(parsePublicOAuthProvider) + : []; +} + export async function verifyClientAuthEmailCode( tenantId: string, payload: VerifyClientAuthEmailCodePayload, diff --git a/src/lib/oemCloudStartupLogin.test.ts b/src/lib/oemCloudStartupLogin.test.ts new file mode 100644 index 000000000..5f1e831f6 --- /dev/null +++ b/src/lib/oemCloudStartupLogin.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { setStoredOemCloudSessionState } from "@/lib/oemCloudSession"; +import { startOemCloudStartupLoginIfRequired } from "@/lib/oemCloudStartupLogin"; + +const { mockListPublicOAuthProviders, mockStartOemCloudLogin } = vi.hoisted( + () => ({ + mockListPublicOAuthProviders: vi.fn(), + mockStartOemCloudLogin: vi.fn(), + }), +); + +vi.mock("@/lib/api/oemCloudControlPlane", async (importOriginal) => { + const actual = + await importOriginal(); + + return { + ...actual, + listPublicOAuthProviders: mockListPublicOAuthProviders, + }; +}); + +vi.mock("@/lib/oemCloudLoginLauncher", () => ({ + startOemCloudLogin: mockStartOemCloudLogin, +})); + +function configureRuntime() { + window.__LIME_OEM_CLOUD__ = { + enabled: true, + baseUrl: "https://user.limeai.run", + tenantId: "tenant-0001", + desktopClientId: "desktop-client", + desktopOauthRedirectUrl: "lime://oauth/callback", + }; +} + +describe("oemCloudStartupLogin", () => { + beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + delete window.__LIME_BOOTSTRAP__; + delete window.__LIME_OEM_CLOUD__; + delete window.__LIME_SESSION_TOKEN__; + mockListPublicOAuthProviders.mockResolvedValue([ + { + provider: "google", + displayName: "Google", + enabled: true, + scopes: ["openid", "email"], + }, + ]); + mockStartOemCloudLogin.mockResolvedValue({ + mode: "desktop_auth", + openedUrl: "https://user.limeai.run/oauth/desktop/authorize", + }); + }); + + afterEach(() => { + localStorage.clear(); + sessionStorage.clear(); + delete window.__LIME_BOOTSTRAP__; + delete window.__LIME_OEM_CLOUD__; + delete window.__LIME_SESSION_TOKEN__; + vi.restoreAllMocks(); + }); + + it("启动时发现品牌云端配置了 Google 登录且本地无会话,应发起登录", async () => { + configureRuntime(); + + const result = await startOemCloudStartupLoginIfRequired(); + + expect(result.status).toBe("started"); + expect(mockListPublicOAuthProviders).toHaveBeenCalledWith("tenant-0001"); + expect(mockStartOemCloudLogin).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: "https://user.limeai.run", + tenantId: "tenant-0001", + }), + ); + }); + + it("同一窗口已尝试启动登录后不应重复打开浏览器", async () => { + configureRuntime(); + + await startOemCloudStartupLoginIfRequired(); + const result = await startOemCloudStartupLoginIfRequired(); + + expect(result.status).toBe("already_attempted"); + expect(mockStartOemCloudLogin).toHaveBeenCalledTimes(1); + }); + + it("已有当前租户会话时不应启动登录", async () => { + configureRuntime(); + setStoredOemCloudSessionState({ + token: "session-token", + tenant: { id: "tenant-0001" }, + user: { id: "user-001" }, + session: { id: "session-001", provider: "google" }, + }); + + const result = await startOemCloudStartupLoginIfRequired(); + + expect(result.status).toBe("has_session"); + expect(mockListPublicOAuthProviders).not.toHaveBeenCalled(); + expect(mockStartOemCloudLogin).not.toHaveBeenCalled(); + }); + + it("后端未下发 Google Provider 时保持开源默认使用", async () => { + configureRuntime(); + mockListPublicOAuthProviders.mockResolvedValue([ + { + provider: "github", + displayName: "GitHub", + enabled: true, + scopes: [], + }, + ]); + + const result = await startOemCloudStartupLoginIfRequired(); + + expect(result.status).toBe("no_google_provider"); + expect(mockStartOemCloudLogin).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/oemCloudStartupLogin.ts b/src/lib/oemCloudStartupLogin.ts new file mode 100644 index 000000000..ae4079b00 --- /dev/null +++ b/src/lib/oemCloudStartupLogin.ts @@ -0,0 +1,103 @@ +import { + listPublicOAuthProviders, + type OemCloudPublicOAuthProvider, +} from "@/lib/api/oemCloudControlPlane"; +import { + resolveOemCloudRuntimeContext, + type OemCloudRuntimeContext, +} from "@/lib/api/oemCloudRuntime"; +import { getStoredOemCloudSessionState } from "@/lib/oemCloudSession"; +import { startOemCloudLogin } from "@/lib/oemCloudLoginLauncher"; + +const STARTUP_LOGIN_ATTEMPT_PREFIX = "lime:oem-cloud-startup-login:v1"; + +export type OemCloudStartupLoginStatus = + | "not_configured" + | "has_session" + | "already_attempted" + | "no_google_provider" + | "started" + | "failed"; + +export interface OemCloudStartupLoginResult { + status: OemCloudStartupLoginStatus; + reason?: string; +} + +function normalizeProvider(value: string | undefined): string { + return value?.trim().toLowerCase() ?? ""; +} + +function hasGoogleOAuthProvider( + providers: OemCloudPublicOAuthProvider[], +): boolean { + return providers.some( + (provider) => + provider.enabled !== false && + normalizeProvider(provider.provider) === "google", + ); +} + +function hasCurrentTenantSession(runtime: OemCloudRuntimeContext): boolean { + const storedSession = getStoredOemCloudSessionState(); + return Boolean( + storedSession?.token && + storedSession.session.tenant.id === runtime.tenantId, + ); +} + +function getStartupLoginAttemptKey(runtime: OemCloudRuntimeContext): string { + return `${STARTUP_LOGIN_ATTEMPT_PREFIX}:${runtime.tenantId}:${runtime.baseUrl}`; +} + +function readStartupAttempt(runtime: OemCloudRuntimeContext): boolean { + if (typeof window === "undefined" || !window.sessionStorage) { + return false; + } + + return ( + window.sessionStorage.getItem(getStartupLoginAttemptKey(runtime)) === "1" + ); +} + +function markStartupAttempt(runtime: OemCloudRuntimeContext): void { + if (typeof window === "undefined" || !window.sessionStorage) { + return; + } + + window.sessionStorage.setItem(getStartupLoginAttemptKey(runtime), "1"); +} + +export async function startOemCloudStartupLoginIfRequired( + runtime = resolveOemCloudRuntimeContext(), +): Promise { + if (!runtime) { + return { status: "not_configured" }; + } + + if (hasCurrentTenantSession(runtime)) { + return { status: "has_session" }; + } + + if (readStartupAttempt(runtime)) { + return { status: "already_attempted" }; + } + + const providers = await listPublicOAuthProviders(runtime.tenantId); + if (!hasGoogleOAuthProvider(providers)) { + return { status: "no_google_provider" }; + } + + markStartupAttempt(runtime); + try { + await startOemCloudLogin(runtime); + return { status: "started" }; + } catch (error) { + const reason = + error instanceof Error && error.message.trim() + ? error.message.trim() + : "启动云端登录失败"; + console.warn("启动期云端登录失败:", error); + return { status: "failed", reason }; + } +}