diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index e6948a5..09f4dc6 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -34,7 +34,7 @@ "icons/icon.icns", "icons/icon.ico" ], - "createUpdaterArtifacts": "v2Compatible" + "createUpdaterArtifacts": true }, "plugins": { "updater": { diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index be29be0..a88fe72 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -404,32 +404,33 @@ function App() { onClose={async () => { // Skip onboarding but still create a default agent with default personality try { - const { getGatewayClient } = await import('./lib/gateway-client'); - const client = getGatewayClient(); - if (client) { - // Create default agent with versatile assistant personality - const defaultAgent = await client.createClone({ - name: '全能助手', - role: '全能型 AI 助手', - nickname: '小龙', - emoji: '🦞', - personality: 'friendly', - scenarios: ['coding', 'writing', 'research', 'product', 'data'], - userName: 'User', - userRole: 'user', - communicationStyle: '亲切、耐心、善解人意,用易懂的语言解释复杂概念', - }); + // Use agentStore (which uses the correct client from connectionStore) + // instead of directly importing getGatewayClient + const { useAgentStore } = await import('./store/agentStore'); + const agentStore = useAgentStore.getState(); - if (defaultAgent?.clone) { - setCurrentAgent({ - id: defaultAgent.clone.id, - name: defaultAgent.clone.name, - icon: defaultAgent.clone.emoji || '🦞', - color: 'bg-gradient-to-br from-orange-500 to-red-500', - lastMessage: defaultAgent.clone.role || '全能型 AI 助手', - time: '', - }); - } + // Create default agent with versatile assistant personality + const result = await agentStore.createClone({ + name: '全能助手', + role: '全能型 AI 助手', + nickname: '小龙', + emoji: '🦞', + personality: 'friendly', + scenarios: ['coding', 'writing', 'research', 'product', 'data'], + userName: 'User', + userRole: 'user', + communicationStyle: '亲切、耐心、善解人意,用易懂的语言解释复杂概念', + }); + + if (result) { + setCurrentAgent({ + id: result.id, + name: result.name, + icon: result.emoji || '🦞', + color: 'bg-gradient-to-br from-orange-500 to-red-500', + lastMessage: result.role || '全能型 AI 助手', + time: '', + }); } } catch (err) { log.warn('Failed to create default agent on skip:', err); diff --git a/desktop/src/components/LoginPage.tsx b/desktop/src/components/LoginPage.tsx index b17491d..b84919d 100644 --- a/desktop/src/components/LoginPage.tsx +++ b/desktop/src/components/LoginPage.tsx @@ -170,8 +170,8 @@ export function LoginPage() { setLocalError('邮箱格式不正确'); return; } - if (password.length < 6) { - setLocalError('密码长度至少 6 个字符'); + if (password.length < 8) { + setLocalError('密码长度至少 8 个字符'); return; } if (password !== confirmPassword) { @@ -427,7 +427,7 @@ export function LoginPage() { type={showPassword ? 'text' : 'password'} value={password} onChange={(e) => setPassword(e.target.value)} - placeholder={isRegister ? '密码(至少 6 位)' : '密码'} + placeholder={isRegister ? '密码(至少 8 位)' : '密码'} autoComplete={isRegister ? 'new-password' : 'current-password'} disabled={isLoading} className={cn(inputClass, 'pr-10')} diff --git a/desktop/src/components/Settings/Workspace.tsx b/desktop/src/components/Settings/Workspace.tsx index 8103dba..cb0cf63 100644 --- a/desktop/src/components/Settings/Workspace.tsx +++ b/desktop/src/components/Settings/Workspace.tsx @@ -1,5 +1,4 @@ import { useEffect, useState } from 'react'; -import { invoke } from '@tauri-apps/api/core'; import { useConfigStore } from '../../store/configStore'; import { silentErrorHandler } from '../../lib/error-utils'; @@ -11,13 +10,14 @@ export function Workspace() { const [projectDir, setProjectDir] = useState('~/.zclaw/zclaw-workspace'); const [dirStats, setDirStats] = useState<{ fileCount: number; totalSize: number } | null>(null); - // Load real directory stats via Tauri command + // Load real directory stats via Tauri command (safe for browser mode) const loadDirStats = async (dir: string) => { try { + const { invoke } = await import('@tauri-apps/api/core'); const stats = await invoke<{ file_count: number; total_size: number }>('workspace_dir_stats', { path: dir }); setDirStats({ fileCount: stats.file_count, totalSize: stats.total_size }); } catch { - // Command may not exist in all modes — fallback to workspaceInfo + // Not in Tauri environment or command unavailable — fallback to workspaceInfo } }; diff --git a/desktop/src/lib/saas-session.ts b/desktop/src/lib/saas-session.ts index 32b30b5..a7ddaeb 100644 --- a/desktop/src/lib/saas-session.ts +++ b/desktop/src/lib/saas-session.ts @@ -13,6 +13,7 @@ const logger = createLogger('saas-session'); // === Storage Keys === const SAAS_TOKEN_SECURE_KEY = 'zclaw-saas-token'; // OS keyring key +const SAAS_REFRESH_TOKEN_KEY = 'zclaw-saas-refresh-token'; // OS keyring key for refresh token const SAASTOKEN_KEY = 'zclaw-saas-token'; // legacy localStorage — only used for cleanup const SAASURL_KEY = 'zclaw-saas-url'; const SAASACCOUNT_KEY = 'zclaw-saas-account'; @@ -22,6 +23,7 @@ const SAASMODE_KEY = 'zclaw-connection-mode'; export interface SaaSSession { token: string | null; // null when using cookie-based auth (page reload) + refreshToken: string | null; // for token refresh on restore account: SaaSAccountInfo | null; saasUrl: string; } @@ -51,9 +53,11 @@ export async function loadSaaSSession(): Promise { // Load token from secure storage let token: string | null = null; + let refreshToken: string | null = null; try { const { secureStorage } = await import('./secure-storage'); token = await secureStorage.get(SAAS_TOKEN_SECURE_KEY); + refreshToken = await secureStorage.get(SAAS_REFRESH_TOKEN_KEY); } catch (e) { logger.debug('Secure storage unavailable for token load', { error: e }); // Secure storage unavailable — token stays null (cookie auth will be attempted) @@ -64,7 +68,7 @@ export async function loadSaaSSession(): Promise { ? (JSON.parse(accountRaw) as SaaSAccountInfo) : null; - return { token, account, saasUrl }; + return { token, refreshToken, account, saasUrl }; } catch (e) { logger.debug('Corrupted session data, clearing', { error: e }); // Corrupted data - clear all @@ -102,17 +106,26 @@ export function loadSaaSSessionSync(): { saasUrl: string; account: SaaSAccountIn /** * Persist SaaS session. - * Token goes to secure storage (OS keyring), metadata to localStorage. + * Access token + refresh token go to secure storage (OS keyring), metadata to localStorage. */ export async function saveSaaSSession(session: SaaSSession): Promise { - // Store token in secure storage (OS keyring), not plain localStorage + // Store access token in secure storage (OS keyring) if (session.token) { try { const { secureStorage } = await import('./secure-storage'); await secureStorage.set(SAAS_TOKEN_SECURE_KEY, session.token); } catch (e) { logger.debug('Secure storage unavailable for token save', { error: e }); - // Secure storage unavailable — token only in memory + } + } + + // Store refresh token in secure storage (OS keyring) + if (session.refreshToken) { + try { + const { secureStorage } = await import('./secure-storage'); + await secureStorage.set(SAAS_REFRESH_TOKEN_KEY, session.refreshToken); + } catch (e) { + logger.debug('Secure storage unavailable for refresh token save', { error: e }); } } @@ -126,12 +139,18 @@ export async function saveSaaSSession(session: SaaSSession): Promise { * Clear the persisted SaaS session from all storage. */ export async function clearSaaSSession(): Promise { - // Remove from secure storage + // Remove access token from secure storage try { const { secureStorage } = await import('./secure-storage'); await secureStorage.set(SAAS_TOKEN_SECURE_KEY, ''); } catch (e) { logger.debug('Failed to clear secure storage token', { error: e }); } + // Remove refresh token from secure storage + try { + const { secureStorage } = await import('./secure-storage'); + await secureStorage.set(SAAS_REFRESH_TOKEN_KEY, ''); + } catch (e) { logger.debug('Failed to clear secure storage refresh token', { error: e }); } + localStorage.removeItem(SAASTOKEN_KEY); localStorage.removeItem(SAASURL_KEY); localStorage.removeItem(SAASACCOUNT_KEY); diff --git a/desktop/src/store/saasStore.ts b/desktop/src/store/saasStore.ts index 411a7dc..052fc5f 100644 --- a/desktop/src/store/saasStore.ts +++ b/desktop/src/store/saasStore.ts @@ -201,15 +201,18 @@ export const useSaaSStore = create((set, get) => { saasClient.setBaseUrl(normalizedUrl); const loginData: SaaSLoginResponse = await saasClient.login(trimmedUsername, password); - // Persist session: token → secure storage (OS keyring), metadata → localStorage + // Persist session: token + refresh token → secure storage, metadata → localStorage const sessionData = { - token: loginData.token, // Will be stored in OS keyring by saveSaaSSession + token: loginData.token, + refreshToken: loginData.refresh_token, account: loginData.account, saasUrl: normalizedUrl, }; - saveSaaSSession(sessionData).catch((e) => - log.warn('Failed to persist SaaS session after login', { error: e }) - ); + try { + await saveSaaSSession(sessionData); + } catch (e) { + log.warn('Failed to persist SaaS session after login', { error: e }); + } saveConnectionMode('saas'); set({ @@ -306,12 +309,15 @@ export const useSaaSStore = create((set, get) => { const sessionData = { token: loginData.token, + refreshToken: loginData.refresh_token, account: loginData.account, saasUrl: normalizedUrl, }; - saveSaaSSession(sessionData).catch((e) => - log.warn('Failed to persist SaaS session after TOTP login', { error: e }) - ); + try { + await saveSaaSSession(sessionData); + } catch (e) { + log.warn('Failed to persist SaaS session after TOTP login', { error: e }); + } saveConnectionMode('saas'); set({ @@ -373,12 +379,15 @@ export const useSaaSStore = create((set, get) => { const sessionData = { token: registerData.token, + refreshToken: registerData.refresh_token, account: registerData.account, saasUrl: normalizedUrl, }; - saveSaaSSession(sessionData).catch((e) => - log.warn('Failed to persist SaaS session after register', { error: e }) - ); + try { + await saveSaaSSession(sessionData); + } catch (e) { + log.warn('Failed to persist SaaS session after register', { error: e }); + } saveConnectionMode('saas'); set({ @@ -744,8 +753,9 @@ export const useSaaSStore = create((set, get) => { saasClient.setBaseUrl(restored.saasUrl); - // Strategy: try secure storage token first, then cookie auth + // Strategy: access token → refresh token → cookie auth → fail let account: SaaSAccountInfo | null = null; + let newToken: string | null = restored.token; if (restored.token) { // Token from secure storage — use as Bearer @@ -753,8 +763,37 @@ export const useSaaSStore = create((set, get) => { try { account = await saasClient.me(); } catch { - // Token expired — try cookie auth + // Access token expired — clear and try refresh saasClient.setToken(null); + newToken = null; + } + } + + if (!account && restored.refreshToken) { + // Try refresh token from secure storage + saasClient.setRefreshToken(restored.refreshToken); + try { + const refreshed = await saasClient.refreshMutex(); + newToken = refreshed; + saasClient.setToken(refreshed); + account = await saasClient.me(); + // Persist the new tokens back to secure storage + try { + const { saveSaaSSession: save } = await import('../lib/saas-session'); + await save({ + token: refreshed, + refreshToken: saasClient.getRefreshToken(), + account, + saasUrl: restored.saasUrl, + }); + } catch (e) { + log.warn('Failed to persist refreshed session', { error: e }); + } + } catch { + // Refresh token also expired or invalid + saasClient.setRefreshToken(null); + saasClient.setToken(null); + newToken = null; } } @@ -764,7 +803,7 @@ export const useSaaSStore = create((set, get) => { } if (!account) { - // Neither token nor cookie works — user needs to re-login + // All methods failed — user needs to re-login set({ isLoggedIn: false, account: null, @@ -778,7 +817,7 @@ export const useSaaSStore = create((set, get) => { isLoggedIn: true, account, saasUrl: restored.saasUrl, - authToken: restored.token, // In-memory from secure storage (null if cookie-only) + authToken: newToken, connectionMode: loadConnectionMode() === 'saas' ? 'saas' : 'tauri', }); get().fetchAvailableModels().catch(() => {}); @@ -812,7 +851,7 @@ export const useSaaSStore = create((set, get) => { await saasClient.verifyTotp(code); const account = await saasClient.me(); const { saasUrl } = get(); - saveSaaSSession({ token: null, account, saasUrl }).catch((e) => + saveSaaSSession({ token: null, refreshToken: null, account, saasUrl }).catch((e) => log.warn('Failed to persist SaaS session after verifyTotp', { error: e }) ); // Token in saasClient memory only set({ totpSetupData: null, isLoading: false, account }); @@ -830,7 +869,7 @@ export const useSaaSStore = create((set, get) => { await saasClient.disableTotp(password); const account = await saasClient.me(); const { saasUrl } = get(); - saveSaaSSession({ token: null, account, saasUrl }).catch((e) => + saveSaaSSession({ token: null, refreshToken: null, account, saasUrl }).catch((e) => log.warn('Failed to persist SaaS session after disableTotp', { error: e }) ); set({ isLoading: false, account });