Merge pull request #156 from multica-ai/feat/desktop-auth

feat: add desktop authentication with Web login
This commit is contained in:
Naiyuan Qing 2026-02-13 14:49:38 +08:00 committed by GitHub
commit 736df03a4f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 1567 additions and 65 deletions

View file

@ -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/

View file

@ -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 |

View file

@ -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

View file

@ -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",

View file

@ -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",

View file

@ -1,10 +1,10 @@
/// <reference types="vite-plugin-electron/electron-env" />
// 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 {

View file

@ -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

View file

@ -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 = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Multica Auth</title>
<style>
html, body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
}
body {
display: flex;
align-items: center;
justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
text-align: center;
background-color: #f8f9fa;
}
.container {
padding: 30px;
max-width: 400px;
}
h1 {
font-size: 24px;
color: #12161F;
margin-bottom: 12px;
}
p {
font-size: 16px;
color: #666;
}
</style>
</head>
<body>
<div class="container">
<h1>Sign in successful</h1>
<p>Please return to Multica app</p>
</div>
</body>
</html>
`;
/**
* HTTP Server
* Cap/apps/desktop/src/utils/auth.ts - createLocalServerSession
*/
async function createLocalServerSession(): Promise<number> {
// 如果已有 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<void> {
const isDev = !!process.env.ELECTRON_RENDERER_URL;
const webUrl =
(import.meta as unknown as { env: Record<string, string> }).env
.MAIN_VITE_WEB_URL || "http://localhost:3000";
console.log("[Auth] Starting login, isDev:", isDev, "webUrl:", webUrl);
if (isDev) {
// 开发模式:启动本地 ServerWeb 回调到这个 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();
});
}

View file

@ -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()
}

View file

@ -138,6 +138,28 @@ const electronAPI = {
setOnboardingCompleted: (completed: boolean): Promise<void> => 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<boolean> =>
ipcRenderer.invoke('auth:save', sid, user),
/** Clear auth data (logout) */
clear: (): Promise<boolean> => ipcRenderer.invoke('auth:clear'),
/** Start login flow (opens browser) */
startLogin: (): Promise<void> => 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'),

View file

@ -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 <div className="flex h-screen items-center justify-center bg-background" />
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />
}
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: <LoginPage />,
},
{
path: '/onboarding',
element: <OnboardingPage />,
element: (
<AuthGuard>
<OnboardingPage />
</AuthGuard>
),
},
{
path: '/',
element: <Layout />,
element: (
<AuthGuard>
<Layout />
</AuthGuard>
),
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) {

View file

@ -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 (
<div className="flex h-screen flex-col bg-background text-foreground">
@ -236,6 +253,33 @@ export default function Layout() {
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={<SidebarMenuButton size="lg" />}
>
<div className="size-8 rounded-lg bg-muted flex items-center justify-center text-sm font-medium">
{user?.name?.charAt(0)?.toUpperCase() || '?'}
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user?.name || 'User'}</span>
<span className="truncate text-xs text-muted-foreground">{user?.email || ''}</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent side="top">
<DropdownMenuItem onClick={handleLogout}>
<LogOut className="size-4" />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
<SidebarInset className="overflow-hidden">

View file

@ -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 (
<div className="flex h-screen items-center justify-center bg-background">
<Loading className="size-6" />
</div>
)
}
return (
<div className="flex h-screen flex-col items-center justify-center bg-background p-8 animate-in fade-in duration-300">
<div className="w-full max-w-sm flex flex-col items-center text-center space-y-6">
{/* Brand */}
<div className="flex items-center gap-2.5">
<MulticaIcon animate className="size-5 text-muted-foreground/70" />
<h1 className="text-2xl tracking-wide font-brand">Multica</h1>
</div>
{/* Tagline */}
<p className="text-sm text-muted-foreground leading-relaxed">
An AI assistant that gets things done pulling data, running analysis,
and taking action. Talk to it like a team member.
</p>
{/* Sign In */}
<Button onClick={startLogin} size="lg" className="px-8">
Sign In to Continue
</Button>
{/* Helper */}
<p className="text-xs text-muted-foreground/60">
Opens browser for authentication
</p>
</div>
</div>
)
}

View file

@ -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<void>
saveAuth: (sid: string, user: AuthUser) => Promise<void>
clearAuth: () => Promise<void>
startLogin: () => void
}
export const useAuthStore = create<AuthState>((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()
}
}

View file

@ -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);
}

View file

@ -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({
<body
className={`${geistMono.variable} ${playfair.variable} font-sans antialiased h-dvh flex flex-col`}
>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<div className="h-dvh overflow-hidden">{children}</div>
</ThemeProvider>
<Providers>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<div className="h-dvh overflow-hidden">{children}</div>
</ThemeProvider>
</Providers>
<Toaster />
<ServiceWorkerRegister />
</body>

View file

@ -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 (
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
)
}
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<LoginStep>('email')
// Loading state
const [isSendingCode, setIsSendingCode] = useState(false)
const [isLoggingIn, setIsLoggingIn] = useState(false)
const [error, setError] = useState<string | null>(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 (
<div className="w-full max-w-sm space-y-6">
{/* Logo and Header */}
<div className="flex flex-col items-center text-center space-y-4">
<div className="flex items-center gap-2">
<MulticaIcon bordered noSpin size="md" />
<span className="text-base font-brand">Multica</span>
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight">Sign in</h1>
<p className="mt-1.5 text-sm text-muted-foreground">
Enter your email to continue
</p>
</div>
</div>
{/* Error message */}
{error && (
<div className="rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
{/* Login form */}
<div className="space-y-4">
{/* Email input */}
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={step === 'code'}
onKeyDown={(e) => {
if (e.key === 'Enter' && step === 'email') {
handleSendCode()
}
}}
/>
</div>
{/* Verification code input (shown in step 2) */}
{step === 'code' && (
<div className="space-y-2">
<Label htmlFor="code">Verification Code</Label>
<div className="flex gap-2">
<Input
id="code"
type="text"
placeholder="Enter code"
value={code}
onChange={(e) => setCode(e.target.value)}
maxLength={6}
className="font-mono tracking-widest"
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleLogin()
}
}}
/>
<Button
variant="outline"
onClick={handleSendCode}
disabled={countdown > 0 || isSendingCode}
className="shrink-0 tabular-nums"
>
{countdown > 0 ? `${countdown}s` : 'Resend'}
</Button>
</div>
</div>
)}
{/* Action buttons */}
{step === 'email' ? (
<Button
onClick={handleSendCode}
disabled={isSendingCode || !email}
className="w-full"
>
{isSendingCode ? 'Sending...' : 'Continue'}
</Button>
) : (
<div className="flex flex-col gap-2">
<Button
onClick={handleLogin}
disabled={isLoggingIn || !code}
className="w-full"
>
{isLoggingIn ? 'Signing in...' : 'Sign In'}
</Button>
<Button
variant="ghost"
onClick={() => {
setStep('email')
setCode('')
setError(null)
}}
className="w-full text-muted-foreground"
size="sm"
>
Use a different email
</Button>
</div>
)}
</div>
{/* Divider */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">or</span>
</div>
</div>
{/* Google Login */}
<Button
onClick={() => googleLogin()}
variant="outline"
className="w-full"
disabled={isLoggingIn}
>
<GoogleIcon />
Continue with Google
</Button>
</div>
)
}

View file

@ -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 (
<div className="flex min-h-screen flex-col items-center justify-center bg-background p-8">
<LoginForm />
</div>
)
}

View file

@ -1,5 +1,10 @@
import ChatPage from "@/components/pages/chat-page";
import { AuthGuard } from "@/components/auth-guard";
export default function Page() {
return <ChatPage />;
return (
<AuthGuard>
<ChatPage />
</AuthGuard>
);
}

View file

@ -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 (
<GoogleOAuthProvider clientId={GOOGLE_CLIENT_ID}>
{children}
</GoogleOAuthProvider>
)
}

View file

@ -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 (
<div className="flex h-screen items-center justify-center bg-background">
<div className="size-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
</div>
)
}
if (!authState.authed) {
return null
}
return <>{children}</>
}

60
apps/web/lib/auth.ts Normal file
View file

@ -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()
}

2
apps/web/lib/constant.ts Normal file
View file

@ -0,0 +1,2 @@
// API Host
export const API_HOST = process.env.NEXT_PUBLIC_API_HOST || '';

36
apps/web/lib/device.ts Normal file
View file

@ -0,0 +1,36 @@
const DEVICE_ID = 'MULTICA_DEVICE_ID';
// SHA-256 hash function (using Web Crypto API)
async function sha256(text: string): Promise<string> {
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<string> {
// 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;
}

37
apps/web/lib/interface.ts Normal file
View file

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

View file

@ -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",

View file

@ -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:",

View file

@ -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<T = unknown>(url: string, options: RequestInit = {}): Promise<T> {
// 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<T = unknown>(url: string, params?: Record<string, string | number | boolean>) {
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<string, string>).toString()}`
: '';
return request<T>(url + queryString, { method: 'GET' });
}
// POST request
export function post<T = unknown>(url: string, data?: unknown) {
return request<T>(url, {
method: 'POST',
body: JSON.stringify(data),
});
}

12
apps/web/service/user.ts Normal file
View file

@ -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<LoginResponse>('/api/v1/auth/login', params);
};

View file

@ -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. 启动 WebNext.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

View file

@ -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
}

View file

@ -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 (
<span
className={cn(
"inline-flex items-center justify-center p-1.5 border border-border rounded-md",
"inline-flex items-center justify-center border border-border rounded-md",
sizeConfig.wrapper,
className
)}
aria-hidden="true"
@ -48,23 +70,15 @@ export function MulticaIcon({
>
<span
className={cn(
"block size-3.5",
"block",
sizeConfig.icon,
!entranceDone && "animate-entrance-spin",
entranceDone && !noSpin && "hover:animate-spin"
)}
>
<span
className="block size-full bg-current"
style={{
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%
)`,
}}
style={{ clipPath }}
/>
</span>
</span>
@ -84,16 +98,7 @@ export function MulticaIcon({
>
<span
className="block size-full bg-current"
style={{
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%
)`,
}}
style={{ clipPath }}
/>
</span>
);

37
pnpm-lock.yaml generated
View file

@ -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