diff --git a/CLAUDE.md b/CLAUDE.md
index aac1a8b7..b08b1e2d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -15,7 +15,7 @@ super-multica/
│ ├── desktop/ ← Electron + Vite + React (`@multica/desktop`) — primary target
│ ├── gateway/ ← NestJS WebSocket gateway (`@multica/gateway`)
│ ├── server/ ← NestJS REST API server (`@multica/server`)
-│ ├── web/ ← Next.js 16 web app (`@multica/web`, port 3001)
+│ ├── web/ ← Next.js 16 web app (`@multica/web`, port 3000)
│ └── mobile/ ← React Native mobile app (`@multica/mobile`)
│
├── packages/
diff --git a/README.md b/README.md
index 6c68a07f..9ff98045 100644
--- a/README.md
+++ b/README.md
@@ -45,14 +45,44 @@ pnpm install
# Desktop app (recommended for local development)
pnpm dev
-# Gateway + Web app (for remote/mobile clients)
+# Web app (for browser-based access)
+pnpm dev:web # Start Web app on :3000
+
+# Gateway (for remote/mobile clients)
pnpm dev:gateway # Start Gateway on :3000
-pnpm dev:web # Start Web app on :3001
pnpm dev:all # Start both Gateway and Web app
```
The Desktop app runs a standalone Hub with embedded Agent Engine - no Gateway required for local use.
+### Environment Configuration
+
+**Desktop** (`apps/desktop/.env.*`):
+
+| Variable | Description |
+|----------|-------------|
+| `MAIN_VITE_GATEWAY_URL` | WebSocket Gateway URL for remote device pairing |
+| `MAIN_VITE_WEB_URL` | Web app URL for OAuth login redirect |
+
+**Web** (`apps/web/next.config.ts`):
+
+| Variable | Description |
+|----------|-------------|
+| `API_URL` | Backend API URL (default: `https://api-dev.copilothub.ai`) |
+
+**Build for different environments:**
+
+```bash
+# Desktop
+pnpm --filter @multica/desktop build # Production (.env.production)
+pnpm --filter @multica/desktop build:staging # Staging (.env.staging)
+
+# Web (Vercel)
+# Set API_URL in Vercel Dashboard → Settings → Environment Variables
+```
+
+See `apps/desktop/.env.example` and `apps/web/.env.example` for details.
+
### Monorepo Development
| Command | Purpose |
diff --git a/apps/desktop/.env.example b/apps/desktop/.env.example
index dd98975b..18f56e21 100644
--- a/apps/desktop/.env.example
+++ b/apps/desktop/.env.example
@@ -3,11 +3,13 @@
# =============================================================================
#
# Local Development:
-# Just run `pnpm dev` - connects to dev gateway (multica-dev.copilothub.ai)
-# GATEWAY_URL is hardcoded in root package.json, no .env file needed.
+# Just run `pnpm dev` - no .env file needed (uses defaults)
+#
+# Staging Build:
+# `pnpm build:staging` uses .env.staging
#
# Production Build:
-# `pnpm build` uses .env.production automatically
+# `pnpm build` uses .env.production
#
# Variable naming convention:
# MAIN_VITE_* - Main process only (Node.js, full system access)
@@ -20,15 +22,11 @@
#
# MAIN_VITE_GATEWAY_URL - WebSocket Gateway
# Hub connects to Gateway for remote device access (QR code pairing)
-# Dev: multica-dev.copilothub.ai (hardcoded in root package.json)
-# Prod: see .env.production
#
-# MAIN_VITE_MULTICA_URL - REST API Server
-# HTTP requests for authentication, auto-updates, user data sync
-# Dev: not yet used
-# Prod: see .env.production
+# MAIN_VITE_WEB_URL - Web App URL
+# Desktop opens this URL for user login (OAuth flow)
#
# =============================================================================
MAIN_VITE_GATEWAY_URL=http://localhost:3000
-MAIN_VITE_MULTICA_URL=http://localhost:3001
+MAIN_VITE_WEB_URL=http://localhost:3000
diff --git a/apps/desktop/electron-builder.json5 b/apps/desktop/electron-builder.json5
index 0a7de5ff..d8fd3ce2 100644
--- a/apps/desktop/electron-builder.json5
+++ b/apps/desktop/electron-builder.json5
@@ -28,7 +28,13 @@
"NSMicrophoneUsageDescription": "Application requests access to the device's microphone.",
"NSDocumentsFolderUsageDescription": "Application requests access to the user's Documents folder.",
"NSDownloadsFolderUsageDescription": "Application requests access to the user's Downloads folder."
- }
+ },
+ "protocols": [
+ {
+ "name": "Multica",
+ "schemes": ["multica"]
+ }
+ ]
},
"dmg": {
"artifactName": "${productName}-${version}-${arch}.${ext}"
@@ -54,6 +60,12 @@
"uninstallDisplayName": "${productName}",
"createDesktopShortcut": "always"
},
+ "protocols": [
+ {
+ "name": "Multica",
+ "schemes": ["multica"]
+ }
+ ],
"linux": {
"target": [
"AppImage",
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index 27bb39c7..96a5e08a 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -10,6 +10,8 @@
"dev": "electron-vite dev",
"dev:onboarding": "electron-vite dev -- --force-onboarding",
"build": "electron-vite build && electron-builder --publish never",
+ "build:staging": "electron-vite build --mode staging && electron-builder --publish never",
+ "build:production": "electron-vite build --mode production && electron-builder --publish never",
"preview": "electron-vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
@@ -19,6 +21,7 @@
"@multica/sdk": "workspace:*",
"@multica/store": "workspace:*",
"@multica/ui": "workspace:*",
+ "@multica/utils": "workspace:*",
"electron-updater": "^6.7.3",
"lucide-react": "^0.563.0",
"qrcode.react": "^4.2.0",
diff --git a/apps/desktop/src/main/electron-env.d.ts b/apps/desktop/src/main/electron-env.d.ts
index 64552b79..58860c2b 100644
--- a/apps/desktop/src/main/electron-env.d.ts
+++ b/apps/desktop/src/main/electron-env.d.ts
@@ -1,10 +1,10 @@
///
// Environment variables loaded from .env files
-// See: .env.example, .env.development, .env.staging, .env.production
+// See: .env.example, .env.staging, .env.production
interface ImportMetaEnv {
readonly MAIN_VITE_GATEWAY_URL: string
- readonly MAIN_VITE_MULTICA_URL: string
+ readonly MAIN_VITE_WEB_URL: string
}
interface ImportMeta {
diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts
index a4428d31..f0780a5f 100644
--- a/apps/desktop/src/main/index.ts
+++ b/apps/desktop/src/main/index.ts
@@ -47,7 +47,7 @@ process.stderr?.on?.('error', (err: NodeJS.ErrnoException) => {
import { app, BrowserWindow, shell, ipcMain } from 'electron'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
-import { registerAllIpcHandlers, initializeApp, cleanupAll, setupDeviceConfirmation } from './ipc/index.js'
+import { registerAllIpcHandlers, initializeApp, cleanupAll, setupDeviceConfirmation, setAuthMainWindow, handleAuthDeepLink } from './ipc/index.js'
import { appStateManager } from '@multica/core'
import { createUpdater, AutoUpdater } from './updater/index.js'
@@ -71,6 +71,50 @@ const forceOnboarding = process.argv.includes('--force-onboarding')
let win: BrowserWindow | null
let updater: AutoUpdater
+// ============================================================================
+// Custom Protocol for Auth (multica://)
+// ============================================================================
+
+// Register custom protocol - must be called before app.whenReady()
+if (process.defaultApp) {
+ // Development: need to pass the script path
+ if (process.argv.length >= 2) {
+ app.setAsDefaultProtocolClient('multica', process.execPath, [path.resolve(process.argv[1])])
+ }
+} else {
+ // Production
+ app.setAsDefaultProtocolClient('multica')
+}
+
+// Handle protocol URL on macOS (when app is already running)
+app.on('open-url', (event, url) => {
+ event.preventDefault()
+ console.log('[Auth] Received open-url:', url)
+ if (url.startsWith('multica://')) {
+ handleAuthDeepLink(url)
+ }
+})
+
+// Handle second instance (Windows/Linux - when app is already running)
+const gotTheLock = app.requestSingleInstanceLock()
+if (!gotTheLock) {
+ app.quit()
+} else {
+ app.on('second-instance', (_event, commandLine) => {
+ // Focus window
+ if (win) {
+ if (win.isMinimized()) win.restore()
+ win.focus()
+ }
+ // Handle protocol URL from command line (Windows)
+ const url = commandLine.find(arg => arg.startsWith('multica://'))
+ if (url) {
+ console.log('[Auth] Received second-instance URL:', url)
+ handleAuthDeepLink(url)
+ }
+ })
+}
+
function createWindow() {
win = new BrowserWindow({
width: 1200,
@@ -136,9 +180,10 @@ app.whenReady().then(async () => {
createWindow()
- // Set up device confirmation flow (requires both Hub and window)
+ // Set up device confirmation flow and auth (requires window)
if (win) {
setupDeviceConfirmation(win)
+ setAuthMainWindow(win)
}
// Initialize auto-updater
diff --git a/apps/desktop/src/main/ipc/auth.ts b/apps/desktop/src/main/ipc/auth.ts
new file mode 100644
index 00000000..9a0d8111
--- /dev/null
+++ b/apps/desktop/src/main/ipc/auth.ts
@@ -0,0 +1,355 @@
+/**
+ * Auth IPC Handlers
+ *
+ * Desktop login flow, based on CAP project:
+ * - Dev mode: Start local HTTP Server, Web redirects back after login
+ * - Prod mode: Use Deep Link (multica://), Web redirects via deep link
+ *
+ * Reference: https://github.com/CapSoftware/Cap
+ */
+
+import http from "node:http";
+import { ipcMain, shell, BrowserWindow } from "electron";
+import {
+ existsSync,
+ readFileSync,
+ writeFileSync,
+ unlinkSync,
+ mkdirSync,
+} from "node:fs";
+import { join, dirname } from "node:path";
+import { DATA_DIR } from "@multica/utils";
+import type { AuthUser } from "@multica/types";
+
+// ============================================================================
+// Types
+// ============================================================================
+
+export type { AuthUser };
+
+export interface AuthData {
+ sid: string;
+ user: AuthUser;
+}
+
+// ============================================================================
+// Storage - 认证数据持久化
+// ============================================================================
+
+const AUTH_FILE_PATH = join(DATA_DIR, "auth.json");
+
+function loadAuthData(): AuthData | null {
+ try {
+ if (!existsSync(AUTH_FILE_PATH)) {
+ return null;
+ }
+ const raw = readFileSync(AUTH_FILE_PATH, "utf8");
+ const data = JSON.parse(raw) as AuthData;
+
+ if (!data.sid || !data.user?.uid) {
+ return null;
+ }
+
+ return data;
+ } catch (error) {
+ console.error("[Auth] Failed to load auth data:", error);
+ return null;
+ }
+}
+
+function saveAuthData(sid: string, user: AuthUser): boolean {
+ try {
+ mkdirSync(dirname(AUTH_FILE_PATH), { recursive: true });
+
+ const data: AuthData = { sid, user };
+ writeFileSync(AUTH_FILE_PATH, JSON.stringify(data, null, 2), "utf8");
+
+ console.log("[Auth] Auth data saved successfully");
+ return true;
+ } catch (error) {
+ console.error("[Auth] Failed to save auth data:", error);
+ return false;
+ }
+}
+
+function clearAuthData(): boolean {
+ try {
+ if (existsSync(AUTH_FILE_PATH)) {
+ unlinkSync(AUTH_FILE_PATH);
+ console.log("[Auth] Auth data cleared");
+ }
+ return true;
+ } catch (error) {
+ console.error("[Auth] Failed to clear auth data:", error);
+ return false;
+ }
+}
+
+// ============================================================================
+// Login - 登录流程
+// ============================================================================
+
+let authServer: http.Server | null = null;
+let mainWindowRef: BrowserWindow | null = null;
+
+/**
+ * 设置主窗口引用(用于发送 auth callback 和聚焦窗口)
+ */
+export function setMainWindow(win: BrowserWindow): void {
+ mainWindowRef = win;
+}
+
+/**
+ * 登录成功后的回调 HTML 页面
+ * 参考:Cap/apps/desktop/src/components/callback.template.ts
+ */
+const callbackHtml = `
+
+
+
+
+
+ Multica Auth
+
+
+
+
+
Sign in successful
+
Please return to Multica app
+
+
+
+`;
+
+/**
+ * 开发模式:创建本地 HTTP Server 接收登录回调
+ * 参考:Cap/apps/desktop/src/utils/auth.ts - createLocalServerSession
+ */
+async function createLocalServerSession(): Promise {
+ // 如果已有 server,先关闭
+ if (authServer) {
+ authServer.close();
+ authServer = null;
+ }
+
+ return new Promise((resolve, reject) => {
+ const server = http.createServer((req, res) => {
+ console.log("[Auth] Local server received request:", req.url);
+
+ try {
+ const url = new URL(req.url || "/", "http://localhost");
+
+ // 处理回调请求
+ if (url.pathname === "/callback" || url.pathname === "/") {
+ const sid = url.searchParams.get("sid");
+ const userJson = url.searchParams.get("user");
+
+ // 返回成功页面
+ res.writeHead(200, {
+ "Content-Type": "text/html; charset=utf-8",
+ "Cache-Control": "no-store, no-cache, must-revalidate",
+ });
+ res.end(callbackHtml);
+
+ console.log("[Auth] Parsed params:", { sid, userJson });
+
+ if (sid && userJson) {
+ try {
+ // URLSearchParams already decodes, so just parse JSON directly
+ const user = JSON.parse(userJson) as AuthUser;
+ console.log("[Auth] Received auth callback:", {
+ sid: sid.substring(0, 8) + "...",
+ user: user.name,
+ });
+
+ // 保存认证数据
+ saveAuthData(sid, user);
+
+ // 通知渲染进程
+ console.log("[Auth] mainWindowRef:", mainWindowRef ? "exists" : "null");
+ if (mainWindowRef && !mainWindowRef.isDestroyed()) {
+ console.log("[Auth] Sending auth:callback to renderer...");
+ mainWindowRef.webContents.send("auth:callback", { sid, user });
+ console.log("[Auth] auth:callback sent!");
+ // 聚焦窗口
+ if (mainWindowRef.isMinimized()) mainWindowRef.restore();
+ mainWindowRef.focus();
+ } else {
+ console.log("[Auth] ERROR: mainWindowRef is null or destroyed!");
+ }
+ } catch (parseError) {
+ console.error("[Auth] Failed to parse user data:", parseError);
+ }
+ }
+
+ // 关闭 server
+ setTimeout(() => {
+ server.close();
+ authServer = null;
+ }, 1000);
+ } else {
+ res.writeHead(404);
+ res.end("Not Found");
+ }
+ } catch (error) {
+ console.error("[Auth] Error handling request:", error);
+ res.writeHead(500);
+ res.end("Internal Server Error");
+ }
+ });
+
+ server.on("error", (err) => {
+ console.error("[Auth] Server error:", err);
+ reject(err);
+ });
+
+ // 监听随机端口
+ server.listen(0, "127.0.0.1", () => {
+ const address = server.address();
+ if (address && typeof address === "object") {
+ const port = address.port;
+ console.log("[Auth] Local server started on port:", port);
+ authServer = server;
+ resolve(port);
+ } else {
+ reject(new Error("Failed to get server address"));
+ }
+ });
+ });
+}
+
+/**
+ * 开始登录流程
+ * 参考:Cap/apps/desktop/src/utils/auth.ts - createSignInMutation
+ */
+async function startLogin(): Promise {
+ const isDev = !!process.env.ELECTRON_RENDERER_URL;
+ const webUrl =
+ (import.meta as unknown as { env: Record }).env
+ .MAIN_VITE_WEB_URL || "http://localhost:3000";
+
+ console.log("[Auth] Starting login, isDev:", isDev, "webUrl:", webUrl);
+
+ if (isDev) {
+ // 开发模式:启动本地 Server,Web 回调到这个 Server
+ try {
+ const port = await createLocalServerSession();
+ const loginUrl = `${webUrl}/api/desktop/session?port=${port}&platform=web`;
+ console.log("[Auth] Opening login URL:", loginUrl);
+ shell.openExternal(loginUrl);
+ } catch (error) {
+ console.error("[Auth] Failed to start local server:", error);
+ }
+ } else {
+ // 生产模式:直接打开登录页,通过 deep link 回调
+ const loginUrl = `${webUrl}/api/desktop/session?platform=desktop`;
+ console.log("[Auth] Opening login URL:", loginUrl);
+ shell.openExternal(loginUrl);
+ }
+}
+
+/**
+ * 处理 Deep Link 回调(生产模式)
+ * 在 main/index.ts 的 app.on('open-url') 中调用
+ */
+export function handleAuthDeepLink(url: string): void {
+ console.log("[Auth] Handling deep link:", url);
+
+ try {
+ const parsedUrl = new URL(url);
+
+ // multica://focus - just focus the window
+ if (parsedUrl.host === "focus" || parsedUrl.pathname === "//focus") {
+ console.log("[Auth] Focus request received");
+ if (mainWindowRef && !mainWindowRef.isDestroyed()) {
+ if (mainWindowRef.isMinimized()) mainWindowRef.restore();
+ mainWindowRef.focus();
+ }
+ return;
+ }
+
+ // multica://auth?sid=xxx&user=xxx
+ if (
+ parsedUrl.host === "auth" ||
+ parsedUrl.pathname === "//auth" ||
+ parsedUrl.pathname === "/auth"
+ ) {
+ const sid = parsedUrl.searchParams.get("sid");
+ const userJson = parsedUrl.searchParams.get("user");
+
+ if (sid && userJson) {
+ const user = JSON.parse(decodeURIComponent(userJson)) as AuthUser;
+ console.log("[Auth] Deep link auth received:", {
+ sid: sid.substring(0, 8) + "...",
+ user: user.name,
+ });
+
+ // 保存认证数据
+ saveAuthData(sid, user);
+
+ // 通知渲染进程
+ if (mainWindowRef && !mainWindowRef.isDestroyed()) {
+ mainWindowRef.webContents.send("auth:callback", { sid, user });
+ if (mainWindowRef.isMinimized()) mainWindowRef.restore();
+ mainWindowRef.focus();
+ }
+ }
+ }
+ } catch (error) {
+ console.error("[Auth] Failed to handle deep link:", error);
+ }
+}
+
+// ============================================================================
+// IPC Handlers
+// ============================================================================
+
+export function registerAuthHandlers(): void {
+ // 加载认证数据
+ ipcMain.handle("auth:load", () => {
+ return loadAuthData();
+ });
+
+ // 保存认证数据
+ ipcMain.handle("auth:save", (_, sid: string, user: AuthUser) => {
+ return saveAuthData(sid, user);
+ });
+
+ // 清除认证数据(登出)
+ ipcMain.handle("auth:clear", () => {
+ return clearAuthData();
+ });
+
+ // 开始登录
+ ipcMain.handle("auth:startLogin", () => {
+ return startLogin();
+ });
+}
diff --git a/apps/desktop/src/main/ipc/index.ts b/apps/desktop/src/main/ipc/index.ts
index d599e343..8f6e2ecb 100644
--- a/apps/desktop/src/main/ipc/index.ts
+++ b/apps/desktop/src/main/ipc/index.ts
@@ -10,9 +10,11 @@ export { registerChannelsIpcHandlers } from './channels.js'
export { registerCronIpcHandlers } from './cron.js'
export { registerHeartbeatIpcHandlers } from './heartbeat.js'
export { registerAppStateIpcHandlers } from './app-state.js'
+export { registerAuthHandlers, setMainWindow as setAuthMainWindow, handleAuthDeepLink } from './auth.js'
export { registerSubagentsIpcHandlers } from './subagents.js'
import { registerAgentIpcHandlers, cleanupAgent } from './agent.js'
+import { registerAuthHandlers } from './auth.js'
import { registerSkillsIpcHandlers } from './skills.js'
import { registerHubIpcHandlers, cleanupHub, initializeHub } from './hub.js'
import { registerProfileIpcHandlers } from './profile.js'
@@ -37,6 +39,7 @@ export function registerAllIpcHandlers(): void {
registerCronIpcHandlers()
registerHeartbeatIpcHandlers()
registerAppStateIpcHandlers()
+ registerAuthHandlers()
registerSubagentsIpcHandlers()
}
diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts
index 800e91bf..405e3c86 100644
--- a/apps/desktop/src/preload/index.ts
+++ b/apps/desktop/src/preload/index.ts
@@ -138,6 +138,28 @@ const electronAPI = {
setOnboardingCompleted: (completed: boolean): Promise => ipcRenderer.invoke('appState:setOnboardingCompleted', completed),
},
+ // Auth management
+ auth: {
+ /** Load auth data from local file */
+ load: (): Promise<{ sid: string; user: { uid: string; name: string; email?: string; icon?: string; vip?: number } } | null> =>
+ ipcRenderer.invoke('auth:load'),
+ /** Save auth data to local file */
+ save: (sid: string, user: { uid: string; name: string; email?: string; icon?: string; vip?: number }): Promise =>
+ ipcRenderer.invoke('auth:save', sid, user),
+ /** Clear auth data (logout) */
+ clear: (): Promise => ipcRenderer.invoke('auth:clear'),
+ /** Start login flow (opens browser) */
+ startLogin: (): Promise => ipcRenderer.invoke('auth:startLogin'),
+ /** Listen for auth callback */
+ onAuthCallback: (callback: (data: { sid: string; user: { uid: string; name: string; email?: string; icon?: string; vip?: number } }) => void) => {
+ ipcRenderer.on('auth:callback', (_event, data) => callback(data))
+ },
+ /** Remove auth callback listener */
+ offAuthCallback: () => {
+ ipcRenderer.removeAllListeners('auth:callback')
+ },
+ },
+
// Hub management
hub: {
init: () => ipcRenderer.invoke('hub:init'),
diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx
index 33177e2d..68a23c62 100644
--- a/apps/desktop/src/renderer/src/App.tsx
+++ b/apps/desktop/src/renderer/src/App.tsx
@@ -12,6 +12,7 @@ import ToolsPage from './pages/agent/tools'
import ClientsPage from './pages/clients'
import CronsPage from './pages/crons'
import OnboardingPage from './pages/onboarding'
+import LoginPage from './pages/login'
import { useOnboardingStore } from './stores/onboarding'
import { useHubStore } from './stores/hub'
import { useProviderStore } from './stores/provider'
@@ -19,6 +20,23 @@ import { useChannelsStore } from './stores/channels'
import { useSkillsStore } from './stores/skills'
import { useToolsStore } from './stores/tools'
import { useCronJobsStore } from './stores/cron-jobs'
+import { useAuthStore, setupAuthCallbackListener } from './stores/auth'
+
+// Auth guard - redirects to login if not authenticated
+function AuthGuard({ children }: { children: React.ReactNode }) {
+ const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
+ const isLoading = useAuthStore((s) => s.isLoading)
+
+ if (isLoading) {
+ return
+ }
+
+ if (!isAuthenticated) {
+ return
+ }
+
+ return <>{children}>
+}
function OnboardingGuard({ children }: { children: React.ReactNode }) {
const completed = useOnboardingStore((s) => s.completed)
@@ -27,13 +45,25 @@ function OnboardingGuard({ children }: { children: React.ReactNode }) {
}
const router = createHashRouter([
+ {
+ path: '/login',
+ element: ,
+ },
{
path: '/onboarding',
- element: ,
+ element: (
+
+
+
+ ),
},
{
path: '/',
- element: ,
+ element: (
+
+
+
+ ),
children: [
{
index: true,
@@ -58,19 +88,28 @@ export default function App() {
const setCompleted = useOnboardingStore((s) => s.setCompleted)
useEffect(() => {
- async function hydrateOnboardingState() {
+ let cleanupAuth: (() => void) | undefined
+
+ async function hydrateState() {
try {
+ // Load auth state first
+ await useAuthStore.getState().loadAuth()
+
+ // Setup auth callback listener
+ cleanupAuth = setupAuthCallbackListener()
+
+ // Load onboarding state
const completed = await window.electronAPI.appState.getOnboardingCompleted()
setCompleted(completed)
} catch (err) {
- console.error('[App] Failed to load onboarding state:', err)
+ console.error('[App] Failed to hydrate state:', err)
setCompleted(false)
} finally {
setIsHydrated(true)
}
}
- hydrateOnboardingState()
+ hydrateState()
useHubStore.getState().init()
useProviderStore.getState().fetch()
@@ -78,6 +117,10 @@ export default function App() {
useSkillsStore.getState().fetch()
useToolsStore.getState().fetch()
useCronJobsStore.getState().fetch()
+
+ return () => {
+ cleanupAuth?.()
+ }
}, [setCompleted])
if (!isHydrated) {
diff --git a/apps/desktop/src/renderer/src/pages/layout.tsx b/apps/desktop/src/renderer/src/pages/layout.tsx
index 5129b0e2..1651e447 100644
--- a/apps/desktop/src/renderer/src/pages/layout.tsx
+++ b/apps/desktop/src/renderer/src/pages/layout.tsx
@@ -14,11 +14,20 @@ import {
ChevronLeft,
ChevronRight,
ChevronDown,
+ ChevronsUpDown,
Bot,
+ LogOut,
} from 'lucide-react'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@multica/ui/components/ui/dropdown-menu'
import {
Sidebar,
SidebarContent,
+ SidebarFooter,
SidebarGroup,
SidebarHeader,
SidebarInset,
@@ -36,6 +45,7 @@ import { cn } from '@multica/ui/lib/utils'
import { ModeToggle } from '../components/mode-toggle'
import { DeviceConfirmDialog } from '../components/device-confirm-dialog'
import { UpdateNotification } from '../components/update-notification'
+import { useAuthStore } from '../stores/auth'
const mainNavItems = [
{ path: '/', label: 'Home', icon: Home, exact: true },
@@ -139,7 +149,14 @@ function MainHeader() {
export default function Layout() {
const location = useLocation()
+ const navigate = useNavigate()
const isAgentActive = location.pathname.startsWith('/agent')
+ const { user, clearAuth } = useAuthStore()
+
+ const handleLogout = async () => {
+ await clearAuth()
+ navigate('/login')
+ }
return (
@@ -236,6 +253,33 @@ export default function Layout() {
+
+
+
+
+
+ }
+ >
+
+ {user?.name?.charAt(0)?.toUpperCase() || '?'}
+
+
+ {user?.name || 'User'}
+ {user?.email || ''}
+
+
+
+
+
+
+ Log out
+
+
+
+
+
+
diff --git a/apps/desktop/src/renderer/src/pages/login.tsx b/apps/desktop/src/renderer/src/pages/login.tsx
new file mode 100644
index 00000000..a1ac0b6a
--- /dev/null
+++ b/apps/desktop/src/renderer/src/pages/login.tsx
@@ -0,0 +1,59 @@
+/**
+ * Login Page - Shown when user is not authenticated
+ */
+
+import { useEffect } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { Button } from '@multica/ui/components/ui/button'
+import { Loading } from '@multica/ui/components/ui/loading'
+import { MulticaIcon } from '@multica/ui/components/multica-icon'
+import { useAuthStore } from '../stores/auth'
+
+export default function LoginPage() {
+ const navigate = useNavigate()
+ const { startLogin, isLoading, isAuthenticated } = useAuthStore()
+
+ // Redirect to home when authenticated
+ useEffect(() => {
+ if (isAuthenticated) {
+ console.log('[LoginPage] Authenticated, redirecting to home...')
+ navigate('/', { replace: true })
+ }
+ }, [isAuthenticated, navigate])
+
+ if (isLoading) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+
+ {/* Brand */}
+
+
+
Multica
+
+
+ {/* Tagline */}
+
+ An AI assistant that gets things done — pulling data, running analysis,
+ and taking action. Talk to it like a team member.
+
+
+ {/* Sign In */}
+
+
+ {/* Helper */}
+
+ Opens browser for authentication
+
+
+
+ )
+}
diff --git a/apps/desktop/src/renderer/src/stores/auth.ts b/apps/desktop/src/renderer/src/stores/auth.ts
new file mode 100644
index 00000000..2dd54121
--- /dev/null
+++ b/apps/desktop/src/renderer/src/stores/auth.ts
@@ -0,0 +1,125 @@
+/**
+ * Auth Store - manages user authentication state
+ */
+
+import { create } from 'zustand'
+import { toast } from '@multica/ui/components/ui/sonner'
+import type { AuthUser } from '@multica/types'
+
+export type { AuthUser }
+
+interface AuthState {
+ // State
+ sid: string | null
+ user: AuthUser | null
+ isAuthenticated: boolean
+ isLoading: boolean
+
+ // Actions
+ loadAuth: () => Promise
+ saveAuth: (sid: string, user: AuthUser) => Promise
+ clearAuth: () => Promise
+ startLogin: () => void
+}
+
+export const useAuthStore = create((set) => ({
+ // Initial state
+ sid: null,
+ user: null,
+ isAuthenticated: false,
+ isLoading: true,
+
+ // Load auth data from local file
+ loadAuth: async () => {
+ set({ isLoading: true })
+ try {
+ const data = await window.electronAPI.auth.load()
+ if (data?.sid && data?.user) {
+ set({
+ sid: data.sid,
+ user: data.user,
+ isAuthenticated: true,
+ isLoading: false,
+ })
+ console.log('[AuthStore] Auth loaded:', data.user.name)
+ } else {
+ set({
+ sid: null,
+ user: null,
+ isAuthenticated: false,
+ isLoading: false,
+ })
+ console.log('[AuthStore] No auth data found')
+ }
+ } catch (error) {
+ console.error('[AuthStore] Failed to load auth:', error)
+ set({
+ sid: null,
+ user: null,
+ isAuthenticated: false,
+ isLoading: false,
+ })
+ }
+ },
+
+ // Save auth data to local file
+ saveAuth: async (sid: string, user: AuthUser) => {
+ try {
+ const success = await window.electronAPI.auth.save(sid, user)
+ if (success) {
+ set({
+ sid,
+ user,
+ isAuthenticated: true,
+ })
+ console.log('[AuthStore] Auth saved:', user.name)
+ }
+ } catch (error) {
+ console.error('[AuthStore] Failed to save auth:', error)
+ }
+ },
+
+ // Clear auth data (logout)
+ clearAuth: async () => {
+ try {
+ await window.electronAPI.auth.clear()
+ set({
+ sid: null,
+ user: null,
+ isAuthenticated: false,
+ })
+ toast('Signed out')
+ console.log('[AuthStore] Auth cleared')
+ } catch (error) {
+ console.error('[AuthStore] Failed to clear auth:', error)
+ }
+ },
+
+ // Start login flow (opens browser)
+ startLogin: () => {
+ console.log('[AuthStore] Starting login...')
+ window.electronAPI.auth.startLogin()
+ },
+}))
+
+/**
+ * Setup auth callback listener
+ * Call this once in App.tsx, returns cleanup function
+ */
+export function setupAuthCallbackListener(): () => void {
+ window.electronAPI.auth.onAuthCallback(async (data) => {
+ console.log('[AuthStore] Received auth callback:', data)
+ if (data.sid && data.user) {
+ useAuthStore.setState({
+ sid: data.sid,
+ user: data.user,
+ isAuthenticated: true,
+ })
+ toast.success(`Welcome back, ${data.user.name}`)
+ }
+ })
+
+ return () => {
+ window.electronAPI.auth.offAuthCallback()
+ }
+}
diff --git a/apps/web/app/api/desktop/session/route.ts b/apps/web/app/api/desktop/session/route.ts
new file mode 100644
index 00000000..339d97d4
--- /dev/null
+++ b/apps/web/app/api/desktop/session/route.ts
@@ -0,0 +1,23 @@
+import { NextRequest, NextResponse } from 'next/server';
+
+/**
+ * Desktop Session API
+ *
+ * 参考:Cap/apps/web/app/api/desktop/[...route]/session.ts
+ *
+ * 流程:
+ * 1. Desktop 打开这个 URL(带 port 或 platform 参数)
+ * 2. 直接重定向到 /login?next=当前URL
+ * 3. 用户登录后,login 页面会重定向到 Desktop 回调
+ *
+ * 注意:Web 端不做任何 SID 缓存,每次都要重新登录
+ */
+export async function GET(request: NextRequest) {
+ // Build current URL for next parameter
+ const currentUrl = request.nextUrl.toString();
+
+ // Always redirect to login page - no caching, always require fresh login
+ const loginUrl = new URL('/login', request.nextUrl.origin);
+ loginUrl.searchParams.set('next', currentUrl);
+ return NextResponse.redirect(loginUrl);
+}
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
index 660b3dbc..d7704893 100644
--- a/apps/web/app/layout.tsx
+++ b/apps/web/app/layout.tsx
@@ -4,6 +4,7 @@ import "@multica/ui/globals.css";
import { ThemeProvider } from "@multica/ui/components/theme-provider";
import { Toaster } from "@multica/ui/components/ui/sonner";
import { ServiceWorkerRegister } from "./sw-register";
+import { Providers } from "./providers";
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
@@ -41,14 +42,16 @@ export default function RootLayout({
-
- {children}
-
+
+
+ {children}
+
+
diff --git a/apps/web/app/login/login-form.tsx b/apps/web/app/login/login-form.tsx
new file mode 100644
index 00000000..98bebd35
--- /dev/null
+++ b/apps/web/app/login/login-form.tsx
@@ -0,0 +1,329 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { useSearchParams, useRouter } from 'next/navigation'
+import { useGoogleLogin } from '@react-oauth/google'
+import { Button } from '@multica/ui/components/ui/button'
+import { Input } from '@multica/ui/components/ui/input'
+import { Label } from '@multica/ui/components/ui/label'
+import { MulticaIcon } from '@multica/ui/components/multica-icon'
+import { LoginAuthType, UserInfo } from '@/lib/interface'
+import { saveSession, isAuthenticated } from '@/lib/auth'
+import { userLogin } from '@/service/user'
+
+type LoginStep = 'email' | 'code'
+
+function GoogleIcon() {
+ return (
+
+ )
+}
+
+export function LoginForm() {
+ const searchParams = useSearchParams()
+ const router = useRouter()
+ const next = searchParams.get('next')
+
+ // Redirect if already authenticated (and not desktop flow)
+ useEffect(() => {
+ if (!next && isAuthenticated()) {
+ router.replace('/')
+ }
+ }, [next, router])
+
+ // Form state
+ const [email, setEmail] = useState('')
+ const [code, setCode] = useState('')
+ const [step, setStep] = useState('email')
+
+ // Loading state
+ const [isSendingCode, setIsSendingCode] = useState(false)
+ const [isLoggingIn, setIsLoggingIn] = useState(false)
+ const [error, setError] = useState(null)
+
+ // Countdown state
+ const [countdown, setCountdown] = useState(0)
+
+ // Google login
+ const googleLogin = useGoogleLogin({
+ onSuccess: async (tokenResponse) => {
+ setError(null)
+ setIsLoggingIn(true)
+ try {
+ const res = await userLogin({
+ authType: LoginAuthType.Google,
+ googleToken: tokenResponse.access_token,
+ })
+
+ const { sid, user, account } = res
+
+ if (!sid) {
+ throw new Error('No session ID returned')
+ }
+
+ handleLoginSuccess(sid, {
+ ...user,
+ email: account?.email,
+ })
+ } catch (err) {
+ setError('Google login failed. Please try again.')
+ console.error(err)
+ } finally {
+ setIsLoggingIn(false)
+ }
+ },
+ onError: () => {
+ setError('Google login failed. Please try again.')
+ },
+ })
+
+ // Countdown timer
+ useEffect(() => {
+ if (countdown > 0) {
+ const timer = setTimeout(() => setCountdown(countdown - 1), 1000)
+ return () => clearTimeout(timer)
+ }
+ }, [countdown])
+
+ // Handle login success
+ const handleLoginSuccess = (sid: string, user: UserInfo) => {
+ // Save session to cookie for web app
+ saveSession(sid, user)
+
+ if (next) {
+ // Desktop flow - parse next URL to get port, then redirect directly to callback
+ try {
+ const nextUrl = new URL(next, window.location.origin)
+ const port = nextUrl.searchParams.get('port')
+ const platform = nextUrl.searchParams.get('platform') || 'web'
+
+ const params = new URLSearchParams({
+ sid,
+ user: JSON.stringify(user),
+ })
+
+ if (platform === 'web' && port) {
+ // Dev mode: redirect to local server
+ window.location.href = `http://127.0.0.1:${port}/callback?${params}`
+ } else {
+ // Production mode: redirect to deep link
+ window.location.href = `multica://auth?${params}`
+ }
+ } catch {
+ // Fallback: just go to next URL
+ window.location.href = next
+ }
+ } else {
+ // No next parameter - normal web login, go to home
+ window.location.href = '/'
+ }
+ }
+
+ // Send verification code
+ const handleSendCode = async () => {
+ if (!email || !email.includes('@')) {
+ setError('Please enter a valid email address')
+ return
+ }
+
+ setError(null)
+ setIsSendingCode(true)
+
+ try {
+ await userLogin({
+ authType: LoginAuthType.SendCode,
+ email,
+ })
+ setStep('code')
+ setCountdown(60)
+ } catch (err) {
+ setError('Failed to send verification code')
+ console.error(err)
+ } finally {
+ setIsSendingCode(false)
+ }
+ }
+
+ // Verify code and login
+ const handleLogin = async () => {
+ if (!code || code.length < 4) {
+ setError('Please enter the verification code')
+ return
+ }
+
+ setError(null)
+ setIsLoggingIn(true)
+
+ try {
+ const res = await userLogin({
+ authType: LoginAuthType.VerifyCode,
+ email,
+ verificationCode: code,
+ })
+
+ const { sid, user, account } = res
+
+ if (!sid) {
+ throw new Error('No session ID returned')
+ }
+
+ handleLoginSuccess(sid, {
+ ...user,
+ email: account?.email || email,
+ })
+ } catch (err) {
+ setError('Invalid or expired verification code')
+ console.error(err)
+ } finally {
+ setIsLoggingIn(false)
+ }
+ }
+
+ return (
+
+ {/* Logo and Header */}
+
+
+
+ Multica
+
+
+
Sign in
+
+ Enter your email to continue
+
+
+
+
+ {/* Error message */}
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Login form */}
+
+ {/* Email input */}
+
+
+ setEmail(e.target.value)}
+ disabled={step === 'code'}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' && step === 'email') {
+ handleSendCode()
+ }
+ }}
+ />
+
+
+ {/* Verification code input (shown in step 2) */}
+ {step === 'code' && (
+
+
+
+ setCode(e.target.value)}
+ maxLength={6}
+ className="font-mono tracking-widest"
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ handleLogin()
+ }
+ }}
+ />
+
+
+
+ )}
+
+ {/* Action buttons */}
+ {step === 'email' ? (
+
+ ) : (
+
+
+
+
+ )}
+
+
+ {/* Divider */}
+
+
+ {/* Google Login */}
+
+
+ )
+}
diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx
new file mode 100644
index 00000000..c343e257
--- /dev/null
+++ b/apps/web/app/login/page.tsx
@@ -0,0 +1,12 @@
+import { LoginForm } from './login-form'
+
+// Disable static prerendering - LoginForm uses useSearchParams
+export const dynamic = 'force-dynamic'
+
+export default function LoginPage() {
+ return (
+
+
+
+ )
+}
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx
index 50d9c193..dada2a25 100644
--- a/apps/web/app/page.tsx
+++ b/apps/web/app/page.tsx
@@ -1,5 +1,10 @@
import ChatPage from "@/components/pages/chat-page";
+import { AuthGuard } from "@/components/auth-guard";
export default function Page() {
- return ;
+ return (
+
+
+
+ );
}
diff --git a/apps/web/app/providers.tsx b/apps/web/app/providers.tsx
new file mode 100644
index 00000000..c8a9530b
--- /dev/null
+++ b/apps/web/app/providers.tsx
@@ -0,0 +1,15 @@
+'use client'
+
+import { GoogleOAuthProvider } from '@react-oauth/google'
+
+// Google OAuth Client ID
+// TODO: Move to environment variable
+const GOOGLE_CLIENT_ID = '69015293368-pg96qdahu57g8nb0oi1abv2j3qbqrshq.apps.googleusercontent.com'
+
+export function Providers({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/apps/web/components/auth-guard.tsx b/apps/web/components/auth-guard.tsx
new file mode 100644
index 00000000..a7e00773
--- /dev/null
+++ b/apps/web/components/auth-guard.tsx
@@ -0,0 +1,39 @@
+'use client'
+
+import { useLayoutEffect, useState } from 'react'
+import { useRouter } from 'next/navigation'
+import { isAuthenticated } from '@/lib/auth'
+
+interface AuthGuardProps {
+ children: React.ReactNode
+}
+
+export function AuthGuard({ children }: AuthGuardProps) {
+ const router = useRouter()
+ // Initialize state synchronously to avoid cascading renders
+ const [authState] = useState(() => {
+ if (typeof window === 'undefined') return { checking: true, authed: false }
+ const authed = isAuthenticated()
+ return { checking: false, authed }
+ })
+
+ useLayoutEffect(() => {
+ if (!authState.checking && !authState.authed) {
+ router.replace('/login')
+ }
+ }, [authState, router])
+
+ if (authState.checking) {
+ return (
+
+ )
+ }
+
+ if (!authState.authed) {
+ return null
+ }
+
+ return <>{children}>
+}
diff --git a/apps/web/lib/auth.ts b/apps/web/lib/auth.ts
new file mode 100644
index 00000000..d288021f
--- /dev/null
+++ b/apps/web/lib/auth.ts
@@ -0,0 +1,60 @@
+/**
+ * Client-side auth utilities
+ * Stores session in cookie for API authentication
+ */
+
+import type { UserInfo } from './interface'
+
+const SID_COOKIE_NAME = 'multica_sid'
+const USER_COOKIE_NAME = 'multica_user'
+
+// Cookie helpers
+function setCookie(name: string, value: string, days = 30) {
+ const expires = new Date(Date.now() + days * 24 * 60 * 60 * 1000).toUTCString()
+ document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/; SameSite=Lax`
+}
+
+function getCookie(name: string): string | null {
+ const match = document.cookie.match(new RegExp(`(^| )${name}=([^;]+)`))
+ return match ? decodeURIComponent(match[2]) : null
+}
+
+function deleteCookie(name: string) {
+ document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`
+}
+
+// Auth functions
+export function saveSession(sid: string, user: UserInfo) {
+ setCookie(SID_COOKIE_NAME, sid)
+ setCookie(USER_COOKIE_NAME, JSON.stringify(user))
+}
+
+export function getSession(): { sid: string; user: UserInfo } | null {
+ if (typeof window === 'undefined') return null
+
+ const sid = getCookie(SID_COOKIE_NAME)
+ const userJson = getCookie(USER_COOKIE_NAME)
+
+ if (!sid || !userJson) return null
+
+ try {
+ const user = JSON.parse(userJson) as UserInfo
+ return { sid, user }
+ } catch {
+ return null
+ }
+}
+
+export function getSid(): string | null {
+ if (typeof window === 'undefined') return null
+ return getCookie(SID_COOKIE_NAME)
+}
+
+export function clearSession() {
+ deleteCookie(SID_COOKIE_NAME)
+ deleteCookie(USER_COOKIE_NAME)
+}
+
+export function isAuthenticated(): boolean {
+ return !!getSid()
+}
diff --git a/apps/web/lib/constant.ts b/apps/web/lib/constant.ts
new file mode 100644
index 00000000..692db158
--- /dev/null
+++ b/apps/web/lib/constant.ts
@@ -0,0 +1,2 @@
+// API Host
+export const API_HOST = process.env.NEXT_PUBLIC_API_HOST || '';
diff --git a/apps/web/lib/device.ts b/apps/web/lib/device.ts
new file mode 100644
index 00000000..a19ceabc
--- /dev/null
+++ b/apps/web/lib/device.ts
@@ -0,0 +1,36 @@
+const DEVICE_ID = 'MULTICA_DEVICE_ID';
+
+// SHA-256 hash function (using Web Crypto API)
+async function sha256(text: string): Promise {
+ const buffer = new TextEncoder().encode(text);
+ const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
+ return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
+}
+
+// Generate Device-Id header
+export async function generateDeviceIdHeader(deviceId: string): Promise {
+ // First hash, take first 32 chars
+ const hash1 = await sha256(deviceId);
+ const hashedDeviceId = hash1.slice(0, 32);
+
+ // Second hash, take first 8 chars
+ const hash2 = await sha256(hashedDeviceId);
+ const finalDeviceId = hash2.slice(0, 8) + hashedDeviceId;
+
+ return finalDeviceId;
+}
+
+// Get or create Device ID
+export function getOrCreateDeviceId(): string {
+ if (typeof window === 'undefined') return '';
+
+ let deviceId = localStorage.getItem(DEVICE_ID);
+
+ if (!deviceId) {
+ deviceId = crypto.randomUUID();
+ localStorage.setItem(DEVICE_ID, deviceId);
+ }
+
+ return deviceId;
+}
diff --git a/apps/web/lib/interface.ts b/apps/web/lib/interface.ts
new file mode 100644
index 00000000..4dd42ded
--- /dev/null
+++ b/apps/web/lib/interface.ts
@@ -0,0 +1,37 @@
+// User info type
+export interface UserInfo {
+ uid: string;
+ name: string;
+ email?: string;
+ icon: string;
+ vip: number;
+}
+
+// Login auth type (from SceneSpeak)
+export enum LoginAuthType {
+ SendCode = 1, // 发送验证码
+ Google = 3, // Google AccessToken
+ VerifyCode = 8, // 验证码登录
+}
+
+// Login response type
+export interface LoginResponse {
+ authType: number;
+ sid: string;
+ pToken: string;
+ message: string;
+ account: {
+ uid: string;
+ type: number;
+ email: string;
+ googleId?: string;
+ createdTime: number;
+ newAccount: boolean;
+ };
+ user: UserInfo & {
+ status: number;
+ extra?: unknown;
+ createdTime: number;
+ customerId?: string;
+ };
+}
diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts
index 17a23e4c..dfa5bd7f 100644
--- a/apps/web/next.config.ts
+++ b/apps/web/next.config.ts
@@ -2,6 +2,12 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
transpilePackages: ["@multica/ui", "@multica/store", "@multica/hooks", "@multica/sdk"],
+ rewrites: async () => [
+ {
+ source: "/api/:path*",
+ destination: `${process.env.API_URL || "https://api-dev.copilothub.ai"}/api/:path*`,
+ },
+ ],
headers: async () => [
{
source: "/sw.js",
diff --git a/apps/web/package.json b/apps/web/package.json
index 1141dec3..9a10a8b6 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
- "dev": "next dev --port 3001 --experimental-https",
+ "dev": "next dev --port 3000",
"build": "next build",
"start": "next start",
"lint": "eslint"
@@ -13,6 +13,7 @@
"@multica/sdk": "workspace:*",
"@multica/store": "workspace:*",
"@multica/ui": "workspace:*",
+ "@react-oauth/google": "^0.12.1",
"next": "16.1.6",
"next-themes": "^0.4.6",
"react": "catalog:",
diff --git a/apps/web/service/request.ts b/apps/web/service/request.ts
new file mode 100644
index 00000000..e4ac3921
--- /dev/null
+++ b/apps/web/service/request.ts
@@ -0,0 +1,74 @@
+import { API_HOST } from '@/lib/constant';
+import { generateDeviceIdHeader, getOrCreateDeviceId } from '@/lib/device';
+import { getSid } from '@/lib/auth';
+
+// Fetch request wrapper
+export async function request(url: string, options: RequestInit = {}): Promise {
+ // Get or generate Device ID
+ let deviceIdHeader = '';
+ let sid: string | null = null;
+ if (typeof window !== 'undefined') {
+ const deviceId = getOrCreateDeviceId();
+ deviceIdHeader = await generateDeviceIdHeader(deviceId);
+ sid = getSid();
+ }
+
+ const config: RequestInit = {
+ ...options,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'os-type': '3',
+ ...(deviceIdHeader && { 'Device-Id': deviceIdHeader }),
+ ...(sid && { 'Authorization': `Bearer ${sid}` }),
+ ...options.headers,
+ },
+ };
+
+ const response = await fetch(`${API_HOST}${url}`, config);
+
+ let data: T;
+ const contentType = response.headers.get('content-type');
+ if (contentType?.includes('application/json')) {
+ data = await response.json();
+ } else {
+ const text = await response.text();
+ data = { message: text || response.statusText } as T;
+ }
+
+ if (!response.ok) {
+ console.error('API Error:', {
+ status: response.status,
+ url,
+ data,
+ });
+ throw new Error(
+ (data as { errMsg?: string; message?: string })?.errMsg ||
+ (data as { message?: string })?.message ||
+ `Request failed with status ${response.status}`
+ );
+ }
+
+ return data;
+}
+
+// GET request
+export function get(url: string, params?: Record) {
+ const filteredParams = params
+ ? Object.fromEntries(
+ Object.entries(params).filter(([, v]) => v !== undefined && v !== null)
+ )
+ : undefined;
+ const queryString =
+ filteredParams && Object.keys(filteredParams).length > 0
+ ? `?${new URLSearchParams(filteredParams as Record).toString()}`
+ : '';
+ return request(url + queryString, { method: 'GET' });
+}
+
+// POST request
+export function post(url: string, data?: unknown) {
+ return request(url, {
+ method: 'POST',
+ body: JSON.stringify(data),
+ });
+}
diff --git a/apps/web/service/user.ts b/apps/web/service/user.ts
new file mode 100644
index 00000000..c028dbd9
--- /dev/null
+++ b/apps/web/service/user.ts
@@ -0,0 +1,12 @@
+import type { LoginResponse } from '@/lib/interface';
+import { post } from './request';
+
+// User login
+export const userLogin = async (params: {
+ authType: number;
+ googleToken?: string;
+ email?: string;
+ verificationCode?: string;
+}) => {
+ return post('/api/v1/auth/login', params);
+};
diff --git a/docs/auth/desktop-integration.md b/docs/auth/desktop-integration.md
new file mode 100644
index 00000000..7c776ad5
--- /dev/null
+++ b/docs/auth/desktop-integration.md
@@ -0,0 +1,75 @@
+# Desktop 登录集成
+
+## 登录流程
+
+```
+Desktop 点击登录
+ ↓
+启动本地 HTTP 服务器 (随机端口,如 54321)
+ ↓
+打开浏览器 → http://localhost:3000/api/desktop/session?port=54321&platform=web
+ ↓
+Web 重定向 → /login?next=...
+ ↓
+用户登录,调用 /api/v1/auth/login (代理到 api-dev.copilothub.ai)
+ ↓
+登录成功,回调 → http://127.0.0.1:54321/callback?sid=xxx&user=xxx
+ ↓
+Desktop 保存到 ~/.super-multica/auth.json
+```
+
+## 前端逻辑
+
+### Web 端
+
+- 端口:**3000**
+- 登录 API:`/api/v1/auth/login`(通过 Next.js rewrites 代理到后端)
+- 登录成功后回调:`http://127.0.0.1:{port}/callback?sid=xxx&user=xxx`
+
+### Desktop 端
+
+- 点击登录 → 启动本地服务器 → 打开浏览器
+- 收到回调 → 保存到本地文件
+
+## 存储
+
+**路径:** `~/.super-multica/auth.json`
+
+Desktop 登录成功后,SID 和用户信息存储在本地文件:
+
+```json
+{
+ "sid": "session-id-from-backend",
+ "user": {
+ "uid": "user-id",
+ "name": "User Name",
+ "email": "user@example.com"
+ }
+}
+```
+
+后续请求可从此文件读取 `sid` 进行认证。
+
+## 退出登录
+
+**后端只需要返回错误,前端会自动处理退出。**
+
+前端收到认证错误后:
+1. 调用 `auth:clear` 清除本地数据
+2. 跳转到登录页
+
+## 本地调试
+
+```bash
+# 1. 启动 Web(Next.js rewrites 自动代理 /api/* 到 api-dev.copilothub.ai)
+pnpm dev:web
+
+# 2. 启动 Desktop
+pnpm dev:desktop
+```
+
+本地调试时,Next.js rewrites(配置在 `apps/web/next.config.ts`)自动将 `/api/*` 请求代理到 `api-dev.copilothub.ai`。
+
+## 参考
+
+- **Cap** - https://github.com/CapSoftware/Cap
diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts
index b311a805..07623ea7 100644
--- a/packages/types/src/index.ts
+++ b/packages/types/src/index.ts
@@ -190,3 +190,15 @@ export interface ExecApprovalRequest {
riskReasons: string[]
expiresAtMs: number
}
+
+// ============================================================================
+// Auth Types
+// ============================================================================
+
+export interface AuthUser {
+ uid: string
+ name: string
+ email?: string
+ icon?: string
+ vip?: number
+}
diff --git a/packages/ui/src/components/multica-icon.tsx b/packages/ui/src/components/multica-icon.tsx
index ac2401ef..52b0fe82 100644
--- a/packages/ui/src/components/multica-icon.tsx
+++ b/packages/ui/src/components/multica-icon.tsx
@@ -14,8 +14,18 @@ interface MulticaIconProps extends React.ComponentProps<"span"> {
* If true, show a border around the icon.
*/
bordered?: boolean;
+ /**
+ * Size of the bordered icon: "sm" (default), "md", "lg"
+ */
+ size?: "sm" | "md" | "lg";
}
+const borderedSizes = {
+ sm: { wrapper: "p-1.5", icon: "size-3.5" },
+ md: { wrapper: "p-2", icon: "size-4" },
+ lg: { wrapper: "p-2.5", icon: "size-5" },
+};
+
/**
* Pure CSS 8-pointed asterisk icon matching the Multica logo.
* Uses currentColor so it adapts to light/dark themes automatically.
@@ -26,6 +36,7 @@ export function MulticaIcon({
animate = false,
noSpin = false,
bordered = false,
+ size = "sm",
...props
}: MulticaIconProps) {
const [entranceDone, setEntranceDone] = useState(!animate);
@@ -36,11 +47,22 @@ export function MulticaIcon({
return () => clearTimeout(timer);
}, [animate]);
+ const clipPath = `polygon(
+ 45% 62.1%, 45% 100%, 55% 100%, 55% 62.1%,
+ 81.8% 88.9%, 88.9% 81.8%, 62.1% 55%, 100% 55%,
+ 100% 45%, 62.1% 45%, 88.9% 18.2%, 81.8% 11.1%,
+ 55% 37.9%, 55% 0%, 45% 0%, 45% 37.9%,
+ 18.2% 11.1%, 11.1% 18.2%, 37.9% 45%, 0% 45%,
+ 0% 55%, 37.9% 55%, 11.1% 81.8%, 18.2% 88.9%
+ )`;
+
if (bordered) {
+ const sizeConfig = borderedSizes[size];
return (
@@ -84,16 +98,7 @@ export function MulticaIcon({
>
);
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a79b09a8..714fe64a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -242,6 +242,9 @@ importers:
'@multica/ui':
specifier: workspace:*
version: link:../../packages/ui
+ '@multica/utils':
+ specifier: workspace:*
+ version: link:../../packages/utils
electron-updater:
specifier: ^6.7.3
version: 6.7.3
@@ -540,6 +543,9 @@ importers:
'@multica/ui':
specifier: workspace:*
version: link:../../packages/ui
+ '@react-oauth/google':
+ specifier: ^0.12.1
+ version: 0.12.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
next:
specifier: 16.1.6
version: 16.1.6(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -3219,6 +3225,12 @@ packages:
'@react-navigation/routers@7.5.3':
resolution: {integrity: sha512-1tJHg4KKRJuQ1/EvJxatrMef3NZXEPzwUIUZ3n1yJ2t7Q97siwRtbynRpQG9/69ebbtiZ8W3ScOZF/OmhvM4Rg==}
+ '@react-oauth/google@0.12.2':
+ resolution: {integrity: sha512-d1GVm2uD4E44EJft2RbKtp8Z1fp/gK8Lb6KHgs3pHlM0PxCXGLaq8LLYQYENnN4xPWO1gkL4apBtlPKzpLvZwg==}
+ peerDependencies:
+ react: '>=16.8.0'
+ react-dom: '>=16.8.0'
+
'@remirror/core-constants@3.0.0':
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
@@ -13218,6 +13230,11 @@ snapshots:
dependencies:
nanoid: 3.3.11
+ '@react-oauth/google@0.12.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+ dependencies:
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+
'@remirror/core-constants@3.0.0': {}
'@rn-primitives/portal@1.3.0(@types/react@19.2.13)(react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.13)(react@19.1.0))(react@19.1.0)(use-sync-external-store@1.6.0(react@19.1.0))':
@@ -15945,9 +15962,9 @@ snapshots:
'@typescript-eslint/eslint-plugin': 8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/parser': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
eslint: 9.39.2(jiti@2.6.1)
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-expo: 1.0.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
- eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-react-hooks: 5.2.0(eslint@9.39.2(jiti@2.6.1))
globals: 16.5.0
@@ -15962,8 +15979,8 @@ snapshots:
'@next/eslint-plugin-next': 16.1.6
eslint: 9.39.2(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
- eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1))
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1))
@@ -15985,7 +16002,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)):
+ eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.3
@@ -15996,18 +16013,18 @@ snapshots:
tinyglobby: 0.2.15
unrs-resolver: 1.11.1
optionalDependencies:
- eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
transitivePeerDependencies:
- supports-color
- eslint-module-utils@2.12.1(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)):
+ eslint-module-utils@2.12.1(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
eslint: 9.39.2(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1))
transitivePeerDependencies:
- supports-color
@@ -16020,7 +16037,7 @@ snapshots:
- supports-color
- typescript
- eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)):
+ eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@@ -16031,7 +16048,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.39.2(jiti@2.6.1)
eslint-import-resolver-node: 0.3.9
- eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
+ eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3