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 };
+ }
+}