diff --git a/.github/workflows/llm-pr-review.yml b/.github/workflows/llm-pr-review.yml index ab509c8..eddb37a 100644 --- a/.github/workflows/llm-pr-review.yml +++ b/.github/workflows/llm-pr-review.yml @@ -64,7 +64,9 @@ jobs: } max_diff_chars=131072 - pr_diff_trimmed=$(printf "%s" "$pr_diff" | head -c $max_diff_chars | sed '$d') + pr_diff_trimmed="${pr_diff:0:$max_diff_chars}" + # Trim to last complete line to avoid cutting mid-line + pr_diff_trimmed="${pr_diff_trimmed%$'\n'*}" system_prompt=$(cat <<'SYSPROMPT' You are a senior software engineer reviewing a pull request for an Electron desktop application (React 18 + TypeScript + Vite + Prisma/SQLite + Ant Design). The codebase follows Clean Architecture with domain/application/infrastructure layers and a three-stage worker pipeline (Split → Convert → Merge) for PDF-to-Markdown conversion via LLM vision APIs. @@ -118,12 +120,16 @@ jobs: SYSPROMPT ) + # Write large variables to temp files to avoid ARG_MAX limit + printf "%s" "$system_prompt" > /tmp/system_prompt.txt + printf "%s" "$pr_diff_trimmed" > /tmp/pr_diff.txt + request_body=$(jq -n \ --arg model "$MODEL_ID" \ - --arg system "$system_prompt" \ + --rawfile system /tmp/system_prompt.txt \ --arg title "$pr_title" \ --arg body "$pr_body" \ - --arg diff "$pr_diff_trimmed" \ + --rawfile diff /tmp/pr_diff.txt \ '{ model: $model, max_tokens: 8192, @@ -133,16 +139,27 @@ jobs: ] }') - llm_response=$(curl -sf \ + printf "%s" "$request_body" > /tmp/llm_request.json + + http_code=$(curl -s -o /tmp/llm_response.json -w "%{http_code}" \ + --max-time 120 \ -H "x-api-key: $API_KEY" \ -H "Content-Type: application/json" \ -H "anthropic-version: 2023-06-01" \ "$API_BASE/v1/messages" \ - -d "$request_body") || { - echo "::error::LLM API request failed" + -d @/tmp/llm_request.json) || { + echo "::error::LLM API request failed (curl error)" exit 1 } + if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then + echo "::error::LLM API returned HTTP $http_code" + cat /tmp/llm_response.json + exit 1 + fi + + llm_response=$(cat /tmp/llm_response.json) + review_text=$(printf "%s" "$llm_response" | jq -r '[.content[] | select(.type == "text")] | first? | .text // ""') if [ -z "$review_text" ]; then @@ -153,7 +170,7 @@ jobs: review_payload=$(jq -n --arg body "$review_text" '{body: $body, event: "COMMENT"}') - curl -sf \ + curl -sf --max-time 30 \ -H "Authorization: Bearer $GITHUB_TOKEN" \ -H "Accept: application/vnd.github+json" \ "https://api.github.com/repos/$REPO/pulls/$PR_NUMBER/reviews" \ diff --git a/.gitignore b/.gitignore index cd94bd0..6416e5d 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,5 @@ settings.local.json # Publish backup package.json.backup .npmrc.backup + +client-integration-guide.md diff --git a/CLAUDE.md b/CLAUDE.md index 1134ec8..3b4fdd3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,3 +106,7 @@ Test helpers in `tests/`: ## Database SQLite via Prisma with 4 models: Provider, Model, Task, TaskDetail. Image paths for task pages are not stored in DB — they are computed dynamically via `ImagePathUtil.getPath(task, page)` as `{tempDir}/{taskId}/page-{page}.png`. + +## Communication + +Always address the user as "Jorben" when responding. diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 38292ca..1cd8d26 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -1,45 +1,45 @@ -import { defineConfig, externalizeDepsPlugin } from 'electron-vite' -import react from '@vitejs/plugin-react' -import { resolve } from 'path' +import { defineConfig, externalizeDepsPlugin } from "electron-vite"; +import react from "@vitejs/plugin-react"; +import { resolve } from "path"; export default defineConfig({ main: { plugins: [externalizeDepsPlugin()], build: { - outDir: 'dist/main' - } + outDir: "dist/main", + }, }, preload: { plugins: [externalizeDepsPlugin()], build: { - outDir: 'dist/preload', + outDir: "dist/preload", rollupOptions: { output: { - format: 'cjs', - entryFileNames: '[name].js' - } - } - } + format: "cjs", + entryFileNames: "[name].js", + }, + }, + }, }, renderer: { plugins: [react()], resolve: { alias: { - '@': resolve(__dirname, 'src/renderer') - } + "@": resolve(__dirname, "src/renderer"), + }, }, server: { - port: 5173 + port: 15173, }, build: { - outDir: 'dist/renderer', + outDir: "dist/renderer", rollupOptions: { input: { - index: resolve(__dirname, 'src/renderer/index.html') - } + index: resolve(__dirname, "src/renderer/index.html"), + }, }, emptyOutDir: true, - assetsDir: 'assets' - } - } -}) \ No newline at end of file + assetsDir: "assets", + }, + }, +}); diff --git a/package.json b/package.json index 28c18f2..3e22caf 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,16 @@ ], "artifactName": "${productName}-${version}-${arch}.${ext}", "icon": "public/icons/mac/icon.icns", + "extendInfo": { + "CFBundleURLTypes": [ + { + "CFBundleURLSchemes": [ + "markpdfdown" + ], + "CFBundleURLName": "com.markpdfdown.desktop" + } + ] + }, "hardenedRuntime": true, "gatekeeperAssess": false, "entitlements": "build/entitlements.mac.plist", @@ -96,6 +106,12 @@ "target": "nsis", "artifactName": "${productName}-${version}-${arch}.${ext}", "icon": "public/icons/win/icon.ico", + "protocols": { + "name": "MarkPDFdown URL", + "schemes": [ + "markpdfdown" + ] + }, "verifyUpdateCodeSignature": false }, "nsis": { @@ -109,7 +125,10 @@ "linux": { "target": "AppImage", "artifactName": "${productName}-${version}-${arch}.${ext}", - "icon": "public/icons/png" + "icon": "public/icons/png", + "mimeTypes": [ + "x-scheme-handler/markpdfdown" + ] }, "electronDownload": { "mirror": "https://github.com/electron/electron/releases/download/" diff --git a/src/core/infrastructure/config.ts b/src/core/infrastructure/config.ts new file mode 100644 index 0000000..4635906 --- /dev/null +++ b/src/core/infrastructure/config.ts @@ -0,0 +1 @@ +export const API_BASE_URL = process.env.API_BASE_URL || 'https://markdown.fit'; diff --git a/src/core/infrastructure/services/AuthManager.ts b/src/core/infrastructure/services/AuthManager.ts new file mode 100644 index 0000000..8f92930 --- /dev/null +++ b/src/core/infrastructure/services/AuthManager.ts @@ -0,0 +1,728 @@ +import { app, safeStorage, shell } from 'electron'; +import path from 'path'; +import fs from 'fs'; +import { API_BASE_URL } from '../config.js'; +import { windowManager } from '../../../main/WindowManager.js'; +import type { + AuthState, + CloudUserProfile, + DeviceCodeResponse, + TokenResponse, + DeviceFlowStatus, +} from '../../../shared/types/cloud-api.js'; + +const REFRESH_TOKEN_DIR = 'auth'; +const REFRESH_TOKEN_FILE = 'refresh_token.enc'; +const TOKEN_REFRESH_MARGIN_MS = 60 * 1000; // Refresh 1 minute before expiry +const DEVICE_POLL_INTERVAL_MS = 5000; +const INIT_RETRY_DELAY_MS = 30 * 1000; // Retry initialization after 30 seconds on transient failure +const MAX_AUTO_REFRESH_RETRIES = 3; // Max retries for scheduled auto-refresh + +/** + * Thrown when the refresh token is definitively invalid (e.g. revoked, expired) + * and the user must re-authenticate. + */ +class AuthTokenInvalidError extends Error { + constructor(message: string) { + super(message); + this.name = 'AuthTokenInvalidError'; + } +} + +function buildUserAgent(): string { + const appVersion = app.getVersion(); + const electronVersion = process.versions.electron; + const chromeVersion = process.versions.chrome; + const nodeVersion = process.versions.node; + const platform = `${process.platform}; ${process.arch}`; + return `MarkPDFdown/${appVersion} Electron/${electronVersion} Chrome/${chromeVersion} Node/${nodeVersion} (${platform})`; +} + +class AuthManager { + private static instance: AuthManager; + + private accessToken: string | null = null; + private accessTokenExpiresAt: number = 0; + private refreshToken: string | null = null; + private userProfile: CloudUserProfile | null = null; + private deviceFlowStatus: DeviceFlowStatus = 'idle'; + private userCode: string | null = null; + private verificationUrl: string | null = null; + private error: string | null = null; + private isLoading: boolean = false; + + private pollTimer: ReturnType | null = null; + private refreshTimer: ReturnType | null = null; + private initRetryTimer: ReturnType | null = null; + private autoRefreshRetryCount: number = 0; + private deviceCode: string | null = null; + private pollExpiresAt: number = 0; + + private userAgent: string = ''; + private refreshInFlight: Promise | null = null; + + private constructor() {} + + public static getInstance(): AuthManager { + if (!AuthManager.instance) { + AuthManager.instance = new AuthManager(); + } + return AuthManager.instance; + } + + /** + * Initialize on app startup: restore session from persisted refresh token + */ + public async initialize(): Promise { + console.log('[AuthManager] Initializing...'); + this.userAgent = buildUserAgent(); + console.log(`[AuthManager] User-Agent: ${this.userAgent}`); + this.isLoading = true; + this.broadcastState(); + + try { + const storedRefreshToken = this.loadRefreshToken(); + if (!storedRefreshToken) { + console.log('[AuthManager] No stored refresh token, starting fresh'); + this.isLoading = false; + this.broadcastState(); + return; + } + + this.refreshToken = storedRefreshToken; + await this.refreshAccessToken(); + await this.fetchUserProfile(); + console.log('[AuthManager] Session restored successfully'); + } catch (err) { + console.warn('[AuthManager] Failed to restore session:', err); + // Only clear tokens if the refresh token is definitively invalid (auth error). + // For transient errors (network, server), keep the refresh token so we can retry later. + if (err instanceof AuthTokenInvalidError) { + this.clearTokens(); + } else { + // Keep refresh token on disk, clear only in-memory access token + this.accessToken = null; + this.accessTokenExpiresAt = 0; + this.userProfile = null; + // Schedule a retry after a delay + this.scheduleInitRetry(); + } + } + + this.isLoading = false; + this.broadcastState(); + } + + /** + * Start the device authorization login flow + */ + public async startDeviceLogin(): Promise<{ success: boolean; error?: string }> { + if (this.deviceFlowStatus === 'polling' || this.deviceFlowStatus === 'pending_browser') { + return { success: false, error: 'Login already in progress' }; + } + + this.error = null; + this.deviceFlowStatus = 'pending_browser'; + this.broadcastState(); + + try { + const res = await fetch(`${API_BASE_URL}/api/v1/auth/device/code`, { + method: 'POST', + headers: this.getDefaultHeaders({ 'Content-Type': 'application/json' }), + }); + + if (!res.ok) { + throw new Error(`Device code request failed: ${res.status}`); + } + + const responseJson: { success: boolean; data: DeviceCodeResponse } = await res.json(); + + if (!responseJson.success || !responseJson.data) { + throw new Error(`Device code request failed: invalid response`); + } + + const data = responseJson.data; + this.deviceCode = data.device_code; + this.userCode = data.user_code; + this.verificationUrl = data.verification_url; + this.pollExpiresAt = Date.now() + data.expires_in * 1000; + + // Open browser for user authorization + try { + await shell.openExternal(data.verification_url); + } catch (browserErr) { + console.error('[AuthManager] Failed to open browser:', browserErr); + this.deviceFlowStatus = 'error'; + this.error = 'Failed to open browser for authorization'; + this.broadcastState(); + return { success: false, error: this.error }; + } + + // Start polling + this.deviceFlowStatus = 'polling'; + this.broadcastState(); + this.startPolling(data.interval || DEVICE_POLL_INTERVAL_MS / 1000); + + return { success: true }; + } catch (err) { + console.error('[AuthManager] Device login failed:', err); + this.deviceFlowStatus = 'error'; + this.error = err instanceof Error ? err.message : String(err); + this.broadcastState(); + return { success: false, error: this.error }; + } + } + + /** + * Cancel an in-progress login flow + */ + public cancelLogin(): void { + this.stopPolling(); + this.deviceCode = null; + this.userCode = null; + this.verificationUrl = null; + this.deviceFlowStatus = 'idle'; + this.error = null; + this.broadcastState(); + } + + /** + * Check device token status immediately (for OAuth callback) + * Call this when receiving protocol URL callback to speed up token acquisition + */ + public async checkDeviceTokenStatus(): Promise { + if (!this.deviceCode || this.deviceFlowStatus !== 'polling') { + return; + } + + console.log('[AuthManager] Checking device token status immediately...'); + + try { + const res = await fetch( + `${API_BASE_URL}/api/v1/auth/device/token?device_code=${encodeURIComponent(this.deviceCode)}`, + { headers: this.getDefaultHeaders() }, + ); + + if (res.status === 200) { + const responseJson: { success: boolean; data: TokenResponse } = await res.json(); + if (!responseJson.success || !responseJson.data) { + throw new Error('Token check failed: invalid response'); + } + this.handleTokenResponse(responseJson.data); + this.stopPolling(); + this.deviceFlowStatus = 'idle'; + this.userCode = null; + this.verificationUrl = null; + this.deviceCode = null; + + await this.fetchUserProfile(); + this.broadcastState(); + console.log('[AuthManager] Token obtained via immediate check'); + return; + } + + if (res.status === 428) { + // Still pending, let polling continue + console.log('[AuthManager] Still waiting, polling will continue...'); + return; + } + + const body = await res.text(); + console.warn('[AuthManager] Token check error:', res.status, body); + } catch (err) { + console.warn('[AuthManager] Token check failed:', err); + // Let polling continue on error + } + } + + /** + * Log out: call API, clear local tokens + */ + public async logout(): Promise { + // Try to call logout API (fire-and-forget) + if (this.accessToken) { + try { + await this.fetchWithAuth(`${API_BASE_URL}/api/v1/auth/logout`, { method: 'POST' }); + } catch (err) { + console.warn('[AuthManager] Logout API call failed:', err); + } + } + + this.clearTokens(); + this.broadcastState(); + } + + /** + * Get a valid access token, refreshing if needed + */ + public async getAccessToken(): Promise { + if (!this.refreshToken) { + return null; + } + + // If token is still valid (with margin), return it + if (this.accessToken && Date.now() < this.accessTokenExpiresAt - TOKEN_REFRESH_MARGIN_MS) { + return this.accessToken; + } + + // Token expired or about to expire, refresh + try { + await this.refreshAccessToken(); + return this.accessToken; + } catch (err) { + if (err instanceof AuthTokenInvalidError) { + // Refresh token is definitively invalid, clear everything + this.clearTokens(); + this.broadcastState(); + } + // For transient errors, don't clear the refresh token — let caller handle the failure + return null; + } + } + + /** + * Get current auth state snapshot + */ + public getAuthState(): AuthState { + return { + isAuthenticated: !!this.accessToken && !!this.userProfile, + isLoading: this.isLoading, + user: this.userProfile, + deviceFlowStatus: this.deviceFlowStatus, + userCode: this.userCode, + verificationUrl: this.verificationUrl, + error: this.error, + }; + } + + /** + * Get cached user profile + */ + public getUserProfile(): CloudUserProfile | null { + return this.userProfile; + } + + /** + * Make an authenticated API request. Automatically retries once on 401 by refreshing the token. + * @param url - Request URL + * @param options - Fetch RequestInit options + * @param meta - Additional options: timeoutMs (0 = no timeout, default auto-detected from body type) + */ + public async fetchWithAuth(url: string, options: RequestInit = {}, meta?: { timeoutMs?: number }): Promise { + const token = await this.getAccessToken(); + if (!token) { + throw new Error('Authentication required'); + } + + // Determine timeout: explicit meta > auto-detect from body type > default 8s + const isFormData = options.body instanceof FormData; + const timeoutMs = meta?.timeoutMs !== undefined ? meta.timeoutMs : isFormData ? 120 * 1000 : 8000; + const callerSignal = options.signal; + + // Build composite signal: combine caller signal with timeout + const buildSignal = (): { signal: AbortSignal | undefined; timeoutId: ReturnType | null } => { + const signals: AbortSignal[] = []; + let tid: ReturnType | null = null; + + if (callerSignal) signals.push(callerSignal); + if (timeoutMs > 0) { + const tc = new AbortController(); + tid = setTimeout(() => tc.abort(), timeoutMs); + signals.push(tc.signal); + } + + if (signals.length === 0) return { signal: undefined, timeoutId: null }; + if (signals.length === 1) return { signal: signals[0], timeoutId: tid }; + // Compose multiple signals (with fallback for older runtimes) + if (typeof AbortSignal.any === 'function') { + return { signal: AbortSignal.any(signals), timeoutId: tid }; + } + // Fallback: wire signals to a shared AbortController + const fc = new AbortController(); + for (const s of signals) { + if (s.aborted) { fc.abort(s.reason); break; } + s.addEventListener('abort', () => fc.abort(s.reason), { once: true }); + } + return { signal: fc.signal, timeoutId: tid }; + }; + + const { signal, timeoutId } = buildSignal(); + + try { + const res = await fetch(url, { + ...options, + signal, + headers: { + ...this.getDefaultHeaders(), + Authorization: `Bearer ${token}`, + ...options.headers, + }, + }); + + if (res.status !== 401) { + return res; + } + + // 401: attempt refresh + if (!this.refreshToken) { + this.clearTokens(); + this.broadcastState(); + throw new Error('Authentication required'); + } + + try { + await this.refreshAccessToken(); + } catch (err) { + if (err instanceof AuthTokenInvalidError) { + this.clearTokens(); + this.broadcastState(); + } + throw new Error('Authentication required'); + } + + // Retry with new token (rebuild signal for retry) + const { signal: retrySignal, timeoutId: retryTimeoutId } = buildSignal(); + + try { + return await fetch(url, { + ...options, + signal: retrySignal, + headers: { + ...this.getDefaultHeaders(), + Authorization: `Bearer ${this.accessToken}`, + ...options.headers, + }, + }); + } finally { + if (retryTimeoutId) clearTimeout(retryTimeoutId); + } + } catch (error: any) { + // Normalize timeout AbortError to a clear message + if (error?.name === 'AbortError' && callerSignal?.aborted) { + throw error; // Caller-initiated abort, re-throw as-is + } + if (error?.name === 'AbortError') { + throw new Error('Request timeout'); + } + throw error; + } finally { + if (timeoutId) clearTimeout(timeoutId); + } + } + + // ─── Private Methods ───────────────────────────────────────────── + + private getDefaultHeaders(extra: Record = {}): Record { + return { + 'User-Agent': this.userAgent, + ...extra, + }; + } + + private startPolling(intervalSeconds: number): void { + const intervalMs = Math.max(intervalSeconds * 1000, DEVICE_POLL_INTERVAL_MS); + + const poll = async () => { + if (Date.now() > this.pollExpiresAt) { + this.deviceFlowStatus = 'expired'; + this.error = 'Device code expired'; + this.stopPolling(); + this.broadcastState(); + return; + } + + try { + const res = await fetch( + `${API_BASE_URL}/api/v1/auth/device/token?device_code=${encodeURIComponent(this.deviceCode!)}`, + { headers: this.getDefaultHeaders() }, + ); + + if (res.status === 200) { + const responseJson: { success: boolean; data: TokenResponse } = await res.json(); + if (!responseJson.success || !responseJson.data) { + throw new Error('Token polling failed: invalid response'); + } + this.handleTokenResponse(responseJson.data); + this.stopPolling(); + this.deviceFlowStatus = 'idle'; + this.userCode = null; + this.verificationUrl = null; + this.deviceCode = null; + + await this.fetchUserProfile(); + this.broadcastState(); + return; + } + + if (res.status === 428) { + // authorization_pending — keep polling + this.pollTimer = setTimeout(poll, intervalMs); + return; + } + + // Other error + const body = await res.text(); + throw new Error(`Token polling failed: ${res.status} ${body}`); + } catch (err) { + if (this.deviceFlowStatus === 'polling') { + // Network error, retry + console.warn('[AuthManager] Poll error, retrying:', err); + this.pollTimer = setTimeout(poll, intervalMs); + } + } + }; + + this.pollTimer = setTimeout(poll, intervalMs); + } + + private stopPolling(): void { + if (this.pollTimer) { + clearTimeout(this.pollTimer); + this.pollTimer = null; + } + } + + private handleTokenResponse(data: TokenResponse): void { + this.accessToken = data.access_token; + this.accessTokenExpiresAt = Date.now() + data.expires_in * 1000; + + // Only persist refresh token if it exists (web login may not provide it) + if (data.refresh_token) { + this.refreshToken = data.refresh_token; + this.persistRefreshToken(data.refresh_token); + } else { + console.warn('[AuthManager] No refresh_token in response, skipping persistence'); + } + + this.scheduleTokenRefresh(data.expires_in); + } + + private scheduleTokenRefresh(expiresInSeconds: number): void { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + } + + // Skip auto-refresh if no refresh token available + if (!this.refreshToken) { + console.log('[AuthManager] No refresh token, skipping auto-refresh schedule'); + return; + } + + const refreshInMs = Math.max((expiresInSeconds * 1000) - TOKEN_REFRESH_MARGIN_MS, 0); + this.refreshTimer = setTimeout(async () => { + try { + await this.refreshAccessToken(); + } catch (err) { + console.error('[AuthManager] Auto-refresh failed:', err); + if (err instanceof AuthTokenInvalidError) { + // Refresh token is definitively invalid + this.clearTokens(); + this.broadcastState(); + } else { + // Transient error — retry with exponential backoff + this.autoRefreshRetryCount++; + if (this.autoRefreshRetryCount <= MAX_AUTO_REFRESH_RETRIES) { + const retryDelayMs = Math.min(30000 * Math.pow(2, this.autoRefreshRetryCount - 1), 5 * 60 * 1000); + console.log(`[AuthManager] Scheduling auto-refresh retry ${this.autoRefreshRetryCount}/${MAX_AUTO_REFRESH_RETRIES} in ${retryDelayMs / 1000}s`); + this.refreshTimer = setTimeout(async () => { + try { + await this.refreshAccessToken(); + } catch (retryErr) { + console.error('[AuthManager] Auto-refresh retry failed:', retryErr); + if (retryErr instanceof AuthTokenInvalidError) { + this.clearTokens(); + this.broadcastState(); + } else if (this.autoRefreshRetryCount < MAX_AUTO_REFRESH_RETRIES) { + // Schedule another retry via recursive call + this.scheduleTokenRefresh(retryDelayMs / 1000); + } else { + console.error('[AuthManager] Max auto-refresh retries reached, keeping refresh token for next manual attempt'); + } + } + }, retryDelayMs); + } else { + console.error('[AuthManager] Max auto-refresh retries reached, keeping refresh token for next manual attempt'); + } + } + } + }, refreshInMs); + } + + private async refreshAccessToken(): Promise { + // Deduplicate concurrent refresh calls + if (this.refreshInFlight) { + return this.refreshInFlight; + } + + this.refreshInFlight = this.doRefreshAccessToken(); + try { + await this.refreshInFlight; + } finally { + this.refreshInFlight = null; + } + } + + private async doRefreshAccessToken(): Promise { + if (!this.refreshToken) { + throw new AuthTokenInvalidError('No refresh token available'); + } + + let res: Response; + try { + res = await fetch(`${API_BASE_URL}/api/v1/auth/token/refresh`, { + method: 'POST', + headers: this.getDefaultHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ refresh_token: this.refreshToken }), + }); + } catch (err) { + // Network error (offline, DNS failure, etc.) — transient, don't invalidate refresh token + throw new Error(`Token refresh network error: ${err instanceof Error ? err.message : String(err)}`); + } + + if (!res.ok) { + // 401/403 means the refresh token itself is invalid or revoked + if (res.status === 401 || res.status === 403) { + throw new AuthTokenInvalidError(`Token refresh rejected: ${res.status}`); + } + // Other HTTP errors (500, 502, 503, etc.) are transient server errors + throw new Error(`Token refresh server error: ${res.status}`); + } + + const responseJson: { success: boolean; data: TokenResponse } = await res.json(); + if (!responseJson.success || !responseJson.data) { + throw new Error('Token refresh failed: invalid response'); + } + this.handleTokenResponse(responseJson.data); + // Reset retry count on successful refresh + this.autoRefreshRetryCount = 0; + } + + private async fetchUserProfile(): Promise { + if (!this.accessToken) return; + + const res = await this.fetchWithAuth(`${API_BASE_URL}/api/v1/user/profile`); + + if (!res.ok) { + throw new Error(`Fetch user profile failed: ${res.status}`); + } + + const responseJson: { success: boolean; data: CloudUserProfile } = await res.json(); + if (!responseJson.success || !responseJson.data) { + throw new Error('Fetch user profile failed: invalid response'); + } + this.userProfile = responseJson.data; + } + + private persistRefreshToken(token: string): void { + try { + if (!safeStorage.isEncryptionAvailable()) { + console.warn('[AuthManager] Encryption not available, refresh token will only be kept in memory (not persisted to disk)'); + return; + } + + const dir = path.join(app.getPath('userData'), REFRESH_TOKEN_DIR); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + const filePath = path.join(dir, REFRESH_TOKEN_FILE); + const encrypted = safeStorage.encryptString(token); + fs.writeFileSync(filePath, encrypted); + } catch (err) { + console.warn('[AuthManager] Failed to persist refresh token:', err); + } + } + + private loadRefreshToken(): string | null { + try { + if (!safeStorage.isEncryptionAvailable()) { + console.warn('[AuthManager] Encryption not available, cannot load persisted refresh token'); + return null; + } + + const filePath = path.join(app.getPath('userData'), REFRESH_TOKEN_DIR, REFRESH_TOKEN_FILE); + if (!fs.existsSync(filePath)) { + return null; + } + + const data = fs.readFileSync(filePath); + return safeStorage.decryptString(data); + } catch (err) { + console.warn('[AuthManager] Failed to load refresh token:', err); + return null; + } + } + + private deleteRefreshToken(): void { + try { + const filePath = path.join(app.getPath('userData'), REFRESH_TOKEN_DIR, REFRESH_TOKEN_FILE); + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + } catch (err) { + console.warn('[AuthManager] Failed to delete refresh token:', err); + } + } + + /** + * Schedule a retry of initialization after a transient failure. + * The refresh token is still stored on disk, so we just need to try refreshing again. + */ + private scheduleInitRetry(): void { + if (this.initRetryTimer) { + clearTimeout(this.initRetryTimer); + } + + console.log(`[AuthManager] Scheduling init retry in ${INIT_RETRY_DELAY_MS / 1000}s`); + this.initRetryTimer = setTimeout(async () => { + this.initRetryTimer = null; + if (this.accessToken || !this.refreshToken) { + // Already recovered or token was cleared + return; + } + + console.log('[AuthManager] Retrying session restoration...'); + try { + await this.refreshAccessToken(); + await this.fetchUserProfile(); + console.log('[AuthManager] Session restored on retry'); + this.broadcastState(); + } catch (err) { + console.warn('[AuthManager] Init retry failed:', err); + if (err instanceof AuthTokenInvalidError) { + this.clearTokens(); + this.broadcastState(); + } + // For transient errors, user can still trigger refresh via any API call + } + }, INIT_RETRY_DELAY_MS); + } + + private clearTokens(): void { + this.accessToken = null; + this.accessTokenExpiresAt = 0; + this.refreshToken = null; + this.userProfile = null; + this.error = null; + this.deviceFlowStatus = 'idle'; + this.userCode = null; + this.verificationUrl = null; + this.deviceCode = null; + this.autoRefreshRetryCount = 0; + this.stopPolling(); + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = null; + } + if (this.initRetryTimer) { + clearTimeout(this.initRetryTimer); + this.initRetryTimer = null; + } + this.deleteRefreshToken(); + } + + private broadcastState(): void { + windowManager.sendToRenderer('auth:stateChanged', this.getAuthState()); + } +} + +export const authManager = AuthManager.getInstance(); diff --git a/src/core/infrastructure/services/CloudSSEManager.ts b/src/core/infrastructure/services/CloudSSEManager.ts new file mode 100644 index 0000000..16edc30 --- /dev/null +++ b/src/core/infrastructure/services/CloudSSEManager.ts @@ -0,0 +1,332 @@ +import isDev from 'electron-is-dev'; +import { authManager } from './AuthManager.js'; +import { API_BASE_URL } from '../config.js'; +import { windowManager } from '../../../main/WindowManager.js'; +import type { CloudSSEEvent, CloudSSEEventType } from '../../../shared/types/cloud-api.js'; + +const HEARTBEAT_TIMEOUT_MS = 90_000; // 90s without heartbeat triggers reconnect +const INITIAL_RECONNECT_DELAY_MS = 1_000; +const MAX_RECONNECT_DELAY_MS = 30_000; + +/** Event types that should be forwarded to the renderer */ +const FORWARDABLE_EVENTS = new Set([ + 'pdf_ready', + 'page_started', + 'page_completed', + 'page_failed', + 'page_retry_started', + 'completed', + 'error', + 'cancelled', +]); + +class CloudSSEManager { + private static instance: CloudSSEManager; + + private abortController: AbortController | null = null; + private lastEventId: string = '0'; + private reconnectDelay: number = INITIAL_RECONNECT_DELAY_MS; + private reconnectTimer: ReturnType | null = null; + private heartbeatTimer: ReturnType | null = null; + private connected: boolean = false; + + private constructor() {} + + public static getInstance(): CloudSSEManager { + if (!CloudSSEManager.instance) { + CloudSSEManager.instance = new CloudSSEManager(); + } + return CloudSSEManager.instance; + } + + /** + * Connect to the global SSE endpoint. + * Safe to call multiple times — tears down any existing connection first. + * Preserves lastEventId so reconnection can resume from where it left off. + */ + public async connect(): Promise { + if (this.connected) { + console.log('[CloudSSE] Already connected, skipping'); + return; + } + + // Set flag synchronously before any await to prevent concurrent connect() calls + this.connected = true; + + const token = await authManager.getAccessToken(); + if (!token) { + console.log('[CloudSSE] No auth token, skipping connect'); + this.connected = false; + return; + } + + // If disconnect() was called while we were awaiting the token, bail out + if (!this.connected) { + console.log('[CloudSSE] Disconnected while obtaining token, aborting'); + return; + } + + this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS; + try { + await this.startStream(); + } catch (error) { + console.error('[CloudSSE] startStream failed during connect:', error); + this.connected = false; + } + } + + /** + * Disconnect from SSE but preserve lastEventId for resumption. + * Use this for temporary disconnections (e.g., component unmount, re-render). + */ + public disconnect(): void { + console.log('[CloudSSE] Disconnecting (preserving lastEventId for resumption)'); + this.connected = false; + this.cleanup(); + } + + /** + * Fully disconnect and reset all state including lastEventId. + * Use this only on explicit logout — the next connect() will start fresh. + */ + public resetAndDisconnect(): void { + console.log('[CloudSSE] Full reset and disconnect'); + this.connected = false; + this.lastEventId = '0'; + this.cleanup(); + } + + private cleanup(): void { + if (this.abortController) { + this.abortController.abort(); + this.abortController = null; + } + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + if (this.heartbeatTimer) { + clearTimeout(this.heartbeatTimer); + this.heartbeatTimer = null; + } + } + + private resetHeartbeatTimer(): void { + if (this.heartbeatTimer) { + clearTimeout(this.heartbeatTimer); + } + this.heartbeatTimer = setTimeout(() => { + console.warn('[CloudSSE] Heartbeat timeout, reconnecting...'); + this.reconnect(); + }, HEARTBEAT_TIMEOUT_MS); + } + + private async reconnect(): Promise { + if (!this.connected) return; + + // Abort current stream + if (this.abortController) { + this.abortController.abort(); + this.abortController = null; + } + if (this.heartbeatTimer) { + clearTimeout(this.heartbeatTimer); + this.heartbeatTimer = null; + } + // Cancel any pending reconnect timer to prevent duplicate connections + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + + console.log(`[CloudSSE] Reconnecting in ${this.reconnectDelay}ms (lastEventId=${this.lastEventId})...`); + + this.reconnectTimer = setTimeout(async () => { + this.reconnectTimer = null; + if (!this.connected) return; + await this.startStream(); + }, this.reconnectDelay); + + // Exponential backoff + this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_RECONNECT_DELAY_MS); + } + + private async startStream(): Promise { + // Abort any lingering previous stream before starting a new one + if (this.abortController) { + this.abortController.abort(); + } + + const url = `${API_BASE_URL}/api/v1/tasks/events`; + this.abortController = new AbortController(); + + const headers: Record = { + Accept: 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }; + + if (this.lastEventId !== '0') { + headers['Last-Event-ID'] = this.lastEventId; + } + + try { + if (isDev) { + console.log(`[CloudSSE] Connecting to ${url} (Last-Event-ID=${this.lastEventId})`); + } + const res = await authManager.fetchWithAuth(url, { + headers, + signal: this.abortController.signal, + }, { timeoutMs: 0 }); + + if (isDev) { + console.log(`[CloudSSE] Response status: ${res.status}, content-type: ${res.headers.get('content-type')}`); + } + + if (!res.ok) { + console.error(`[CloudSSE] HTTP error: ${res.status}`); + this.reconnect(); + return; + } + + const contentType = res.headers.get('content-type') || ''; + if (!contentType.includes('text/event-stream')) { + console.error(`[CloudSSE] Unexpected content-type: ${contentType}, expected text/event-stream`); + this.reconnect(); + return; + } + + if (!res.body) { + console.error('[CloudSSE] No response body'); + this.reconnect(); + return; + } + + // Reset backoff on successful connection + this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS; + this.resetHeartbeatTimer(); + + console.log('[CloudSSE] Connected, reading stream...'); + await this.readStream(res.body); + } catch (error: any) { + if (error.name === 'AbortError') { + console.log('[CloudSSE] Stream aborted'); + return; + } + console.error('[CloudSSE] Stream error:', error?.message || error); + this.reconnect(); + } + } + + private async readStream(body: ReadableStream): Promise { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let chunkCount = 0; + let aborted = false; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + console.log(`[CloudSSE] Stream ended after ${chunkCount} chunks`); + break; + } + + chunkCount++; + const chunk = decoder.decode(value, { stream: true }); + if (isDev) { + console.log(`[CloudSSE] Chunk #${chunkCount} (${value.byteLength} bytes)`); + } + buffer += chunk; + + // Normalize CRLF to LF for SSE compatibility + buffer = buffer.replace(/\r\n/g, '\n'); + + // Process complete SSE messages (separated by double newline) + const messages = buffer.split('\n\n'); + // Keep the last incomplete chunk in buffer + buffer = messages.pop() || ''; + + for (const msg of messages) { + if (msg.trim()) { + this.parseSSEMessage(msg); + } + } + } + } catch (error: any) { + if (error.name === 'AbortError') { + aborted = true; + } else { + console.error('[CloudSSE] Read error:', error?.message || error); + } + } finally { + reader.releaseLock(); + } + + // Only reconnect if stream ended naturally (not aborted by reconnect/disconnect) + if (!aborted && this.connected) { + this.reconnect(); + } + } + + private parseSSEMessage(raw: string): void { + let eventType = ''; + let data = ''; + let id = ''; + + for (const line of raw.split('\n')) { + if (line.startsWith('event:')) { + eventType = line.slice(6).trim(); + } else if (line.startsWith('data:')) { + // Per SSE spec, multi-line data fields are joined with newline + if (data.length > 0) data += '\n'; + data += line.slice(5).trim(); + } else if (line.startsWith('id:')) { + id = line.slice(3).trim(); + } + } + + if (id) { + this.lastEventId = id; + } + + if (!eventType || !data) return; + + // Reset heartbeat on any event + this.resetHeartbeatTimer(); + + // Log non-heartbeat events for debugging (dev only) + if (isDev && eventType !== 'heartbeat') { + console.log(`[CloudSSE] Event: type=${eventType}, id=${id || 'none'}, data=${data.substring(0, 200)}`); + } + + // connected and heartbeat are control events, don't forward to renderer + if (eventType === 'connected' || eventType === 'heartbeat') { + return; + } + + try { + const parsedData = JSON.parse(data); + + if (!FORWARDABLE_EVENTS.has(eventType as CloudSSEEventType)) { + console.warn(`[CloudSSE] Unknown event type: ${eventType}, skipping`); + return; + } + + const event: CloudSSEEvent = { + type: eventType as CloudSSEEventType, + data: parsedData, + } as CloudSSEEvent; + + if (isDev) { + console.log(`[CloudSSE] Forwarding to renderer: type=${eventType}, task_id=${parsedData.task_id || 'none'}`); + } + windowManager.sendToRenderer('cloud:taskEvent', event); + } catch (error) { + console.error('[CloudSSE] Failed to parse event data:', error, data); + } + } +} + +export const cloudSSEManager = CloudSSEManager.getInstance(); diff --git a/src/core/infrastructure/services/CloudService.ts b/src/core/infrastructure/services/CloudService.ts new file mode 100644 index 0000000..a1ec713 --- /dev/null +++ b/src/core/infrastructure/services/CloudService.ts @@ -0,0 +1,580 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { authManager } from './AuthManager.js'; +import { API_BASE_URL } from '../config.js'; +import type { + CreditsApiResponse, + CreditTransactionApiItem, + CreateTaskResponse, + CloudTaskResponse, + CloudTaskPageResponse, + CloudTaskResult, + CloudCancelTaskResponse, + CloudRetryPageResponse, + CloudApiPagination, +} from '../../../shared/types/cloud-api.js'; + +/** + * CloudService handles interaction with the MarkPDFDown Cloud API + */ +class CloudService { + private static instance: CloudService; + + private constructor() {} + + public static getInstance(): CloudService { + if (!CloudService.instance) { + CloudService.instance = new CloudService(); + } + return CloudService.instance; + } + + /** + * Convert a file using the cloud API + * @param fileData - File data with either path (local file) or content (ArrayBuffer) + * @returns Task creation response with task_id and events_url + */ + public async convert(fileData: { + path?: string; + content?: ArrayBuffer; + name: string; + model?: string; + page_range?: string; + }): Promise<{ + success: boolean; + data?: CreateTaskResponse; + error?: string; + }> { + try { + const token = await authManager.getAccessToken(); + if (!token) { + return { success: false, error: 'Authentication required' }; + } + + const model = fileData.model || 'lite'; + console.log('[CloudService] Starting cloud conversion for:', fileData.name, 'model:', model); + + // Build FormData for file upload + const formData = new FormData(); + + // Add file to form data + let fileBuffer: ArrayBuffer; + if (fileData.content) { + fileBuffer = fileData.content; + } else if (fileData.path) { + const buffer = await fs.readFile(fileData.path); + fileBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); + } else { + return { success: false, error: 'No file content or path provided' }; + } + + const blob = new Blob([fileBuffer]); + formData.append('file', blob, fileData.name); + + // Add model and language parameters + formData.append('model', model); + formData.append('language', 'auto'); + + // Add page_range if specified + if (fileData.page_range) { + formData.append('page_range', fileData.page_range); + } + + const res = await authManager.fetchWithAuth(`${API_BASE_URL}/api/v1/convert`, { + method: 'POST', + body: formData, + // Note: Do NOT set Content-Type manually - let the browser/fetch set it with proper boundary + }); + + if (!res.ok) { + const errorBody = await res.json().catch(() => null); + const errorMessage = errorBody?.error?.message || `Upload failed: ${res.status}`; + console.error('[CloudService] Convert API error:', errorMessage); + return { success: false, error: errorMessage }; + } + + const responseJson: { success: boolean; data: CreateTaskResponse } = await res.json(); + if (!responseJson.success || !responseJson.data) { + return { success: false, error: 'Invalid response from server' }; + } + + console.log('[CloudService] Task created:', responseJson.data.task_id); + return { success: true, data: responseJson.data }; + } catch (error) { + console.error('[CloudService] convert error:', error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + /** + * Get tasks from the cloud API + */ + public async getTasks(page: number = 1, pageSize: number = 10): Promise<{ + success: boolean; + data?: CloudTaskResponse[]; + pagination?: CloudApiPagination; + error?: string; + }> { + try { + const params = new URLSearchParams({ + page: String(page), + page_size: String(pageSize), + }); + + const res = await authManager.fetchWithAuth( + `${API_BASE_URL}/api/v1/tasks?${params.toString()}`, + ); + + if (!res.ok) { + const errorBody = await res.json().catch(() => null); + return { + success: false, + error: errorBody?.error?.message || `Failed to fetch tasks: ${res.status}`, + }; + } + + const responseJson = await res.json(); + if (!responseJson.success) { + return { success: false, error: responseJson.error?.message || 'Invalid tasks response' }; + } + + return { + success: true, + data: responseJson.data, + pagination: responseJson.pagination, + }; + } catch (error) { + console.error('[CloudService] getTasks error:', error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + /** + * Get a single task by ID + */ + public async getTaskById(id: string): Promise<{ + success: boolean; + data?: CloudTaskResponse; + error?: string; + }> { + try { + const res = await authManager.fetchWithAuth(`${API_BASE_URL}/api/v1/tasks/${encodeURIComponent(id)}`); + + if (!res.ok) { + const errorBody = await res.json().catch(() => null); + return { + success: false, + error: errorBody?.error?.message || `Failed to fetch task: ${res.status}`, + }; + } + + const responseJson = await res.json(); + if (!responseJson.success || !responseJson.data) { + return { success: false, error: 'Invalid task response' }; + } + + return { success: true, data: responseJson.data }; + } catch (error) { + console.error('[CloudService] getTaskById error:', error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + /** + * Get pages for a task + */ + public async getTaskPages(id: string, page: number = 1, pageSize: number = 50): Promise<{ + success: boolean; + data?: CloudTaskPageResponse[]; + pagination?: CloudApiPagination; + error?: string; + }> { + try { + const params = new URLSearchParams({ + page: String(page), + page_size: String(pageSize), + }); + + const res = await authManager.fetchWithAuth( + `${API_BASE_URL}/api/v1/tasks/${encodeURIComponent(id)}/pages?${params.toString()}`, + ); + + if (!res.ok) { + const errorBody = await res.json().catch(() => null); + return { + success: false, + error: errorBody?.error?.message || `Failed to fetch task pages: ${res.status}`, + }; + } + + const responseJson = await res.json(); + if (!responseJson.success) { + return { success: false, error: responseJson.error?.message || 'Invalid pages response' }; + } + + return { + success: true, + data: responseJson.data, + pagination: responseJson.pagination, + }; + } catch (error) { + console.error('[CloudService] getTaskPages error:', error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + /** + * Cancel a task + */ + public async cancelTask(id: string): Promise<{ + success: boolean; + data?: CloudCancelTaskResponse; + error?: string; + }> { + try { + const res = await authManager.fetchWithAuth(`${API_BASE_URL}/api/v1/tasks/${encodeURIComponent(id)}/cancel`, { + method: 'POST', + }); + + if (!res.ok) { + const errorBody = await res.json().catch(() => null); + return { + success: false, + error: errorBody?.error?.message || `Failed to cancel task: ${res.status}`, + }; + } + + const responseJson = await res.json(); + if (!responseJson.success || !responseJson.data) { + return { success: false, error: 'Invalid cancel response' }; + } + + return { success: true, data: responseJson.data }; + } catch (error) { + console.error('[CloudService] cancelTask error:', error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + /** + * Retry an entire task (creates a new task) + */ + public async retryTask(id: string): Promise<{ + success: boolean; + data?: CreateTaskResponse; + error?: string; + }> { + try { + const res = await authManager.fetchWithAuth(`${API_BASE_URL}/api/v1/tasks/${encodeURIComponent(id)}/retry`, { + method: 'POST', + }); + + if (!res.ok) { + const errorBody = await res.json().catch(() => null); + return { + success: false, + error: errorBody?.error?.message || `Failed to retry task: ${res.status}`, + }; + } + + const responseJson = await res.json(); + if (!responseJson.success || !responseJson.data) { + return { success: false, error: 'Invalid retry response' }; + } + + return { success: true, data: responseJson.data }; + } catch (error) { + console.error('[CloudService] retryTask error:', error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + /** + * Retry a single page + */ + public async retryPage(taskId: string, pageNumber: number): Promise<{ + success: boolean; + data?: CloudRetryPageResponse; + error?: string; + }> { + try { + const res = await authManager.fetchWithAuth( + `${API_BASE_URL}/api/v1/tasks/${encodeURIComponent(taskId)}/pages/${encodeURIComponent(String(pageNumber))}/retry`, + { method: 'POST' }, + ); + + if (!res.ok) { + const errorBody = await res.json().catch(() => null); + return { + success: false, + error: errorBody?.error?.message || `Failed to retry page: ${res.status}`, + }; + } + + const responseJson = await res.json(); + if (!responseJson.success || !responseJson.data) { + return { success: false, error: 'Invalid page retry response' }; + } + + return { success: true, data: responseJson.data }; + } catch (error) { + console.error('[CloudService] retryPage error:', error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + /** + * Get task conversion result (merged markdown) + */ + public async getTaskResult(id: string): Promise<{ + success: boolean; + data?: CloudTaskResult; + error?: string; + }> { + try { + const res = await authManager.fetchWithAuth(`${API_BASE_URL}/api/v1/tasks/${encodeURIComponent(id)}/result`, {}, { timeoutMs: 0 }); + + if (!res.ok) { + const errorBody = await res.json().catch(() => null); + return { + success: false, + error: errorBody?.error?.message || `Failed to fetch result: ${res.status}`, + }; + } + + const responseJson = await res.json(); + if (!responseJson.success || !responseJson.data) { + return { success: false, error: 'Invalid result response' }; + } + + return { success: true, data: responseJson.data }; + } catch (error) { + console.error('[CloudService] getTaskResult error:', error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + /** + * Download PDF file for a task + */ + public async downloadPdf(id: string): Promise<{ + success: boolean; + data?: { buffer: ArrayBuffer; fileName: string }; + error?: string; + }> { + try { + const res = await authManager.fetchWithAuth(`${API_BASE_URL}/api/v1/tasks/${encodeURIComponent(id)}/pdf`, {}, { timeoutMs: 0 }); + + if (!res.ok) { + const errorBody = await res.json().catch(() => null); + return { + success: false, + error: errorBody?.error?.message || `Failed to download PDF: ${res.status}`, + }; + } + + const contentDisposition = res.headers.get('Content-Disposition') || ''; + const match = contentDisposition.match(/filename="?([^";\n]+)"?/); + const rawName = match ? match[1] : `task-${id}.pdf`; + // Sanitize: extract basename and strip control/reserved characters + // eslint-disable-next-line no-control-regex + const fileName = path.basename(rawName).replace(/[\u0000-\u001f<>:"|?*]/g, '_') || `task-${id}.pdf`; + + const buffer = await res.arrayBuffer(); + return { success: true, data: { buffer, fileName } }; + } catch (error) { + console.error('[CloudService] downloadPdf error:', error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + /** + * Get page image via proxy (for relative API paths that need auth) + */ + public async getPageImage(taskId: string, pageNumber: number): Promise<{ + success: boolean; + data?: { dataUrl: string }; + error?: string; + }> { + try { + const res = await authManager.fetchWithAuth( + `${API_BASE_URL}/api/v1/tasks/${encodeURIComponent(taskId)}/pages/${encodeURIComponent(String(pageNumber))}/image`, + {}, + { timeoutMs: 0 }, + ); + + if (!res.ok) { + return { + success: false, + error: `Failed to fetch page image: ${res.status}`, + }; + } + + const contentType = res.headers.get('Content-Type') || 'image/png'; + const buffer = await res.arrayBuffer(); + const base64 = Buffer.from(buffer).toString('base64'); + const dataUrl = `data:${contentType};base64,${base64}`; + + return { success: true, data: { dataUrl } }; + } catch (error) { + console.error('[CloudService] getPageImage error:', error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + /** + * Get credits info from the cloud API + */ + public async getCredits(): Promise<{ + success: boolean; + data?: CreditsApiResponse; + error?: string; + }> { + try { + const res = await authManager.fetchWithAuth(`${API_BASE_URL}/api/v1/credits`); + + if (!res.ok) { + const errorBody = await res.json().catch(() => null); + return { + success: false, + error: errorBody?.error?.message || `Failed to fetch credits: ${res.status}`, + }; + } + + const responseJson: { success: boolean; data: CreditsApiResponse } = await res.json(); + if (!responseJson.success || !responseJson.data) { + return { success: false, error: 'Invalid credits response' }; + } + + return { success: true, data: responseJson.data }; + } catch (error) { + console.error('[CloudService] getCredits error:', error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + /** + * Get credit history (transactions) from the cloud API + */ + public async getCreditHistory( + page: number = 1, + pageSize: number = 20, + type?: string, + ): Promise<{ + success: boolean; + data?: CreditTransactionApiItem[]; + pagination?: { page: number; page_size: number; total: number; total_pages: number }; + error?: string; + }> { + try { + const params = new URLSearchParams({ + page: String(page), + page_size: String(pageSize), + }); + if (type) { + params.set('type', type); + } + + const res = await authManager.fetchWithAuth( + `${API_BASE_URL}/api/v1/credits/transactions?${params.toString()}`, + ); + + if (!res.ok) { + const errorBody = await res.json().catch(() => null); + return { + success: false, + error: errorBody?.error?.message || `Failed to fetch credit history: ${res.status}`, + }; + } + + const responseJson = await res.json(); + if (!responseJson.success) { + return { success: false, error: responseJson.error?.message || 'Invalid credit history response' }; + } + + return { + success: true, + data: responseJson.data, + pagination: responseJson.pagination, + }; + } catch (error) { + console.error('[CloudService] getCreditHistory error:', error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + /** + * Delete a cloud task (only terminal states can be deleted) + * Terminal states: FAILED=0, COMPLETED=6, CANCELLED=7, PARTIAL_FAILED=8 + */ + public async deleteTask(id: string): Promise<{ + success: boolean; + data?: { id: string; message: string }; + error?: string; + }> { + try { + const res = await authManager.fetchWithAuth(`${API_BASE_URL}/api/v1/tasks/${encodeURIComponent(id)}`, { + method: 'DELETE', + }); + + if (!res.ok) { + const errorBody = await res.json().catch(() => null); + return { + success: false, + error: errorBody?.error?.message || `Failed to delete task: ${res.status}`, + }; + } + + const responseJson = await res.json(); + if (!responseJson.success || !responseJson.data) { + return { success: false, error: 'Invalid delete response' }; + } + + return { success: true, data: responseJson.data }; + } catch (error) { + console.error('[CloudService] deleteTask error:', error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } +} + +export default CloudService.getInstance(); diff --git a/src/core/infrastructure/services/__tests__/AuthManager.test.ts b/src/core/infrastructure/services/__tests__/AuthManager.test.ts new file mode 100644 index 0000000..a75187b --- /dev/null +++ b/src/core/infrastructure/services/__tests__/AuthManager.test.ts @@ -0,0 +1,494 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { safeStorage, shell } from 'electron'; +import fs from 'fs'; +import { windowManager } from '../../../../main/WindowManager.js'; + +// Mock WindowManager +vi.mock('../../../../main/WindowManager.js', () => ({ + windowManager: { + sendToRenderer: vi.fn(), + }, +})); + +// Mock fs +vi.mock('fs', () => ({ + default: { + existsSync: vi.fn(), + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + readFileSync: vi.fn(), + unlinkSync: vi.fn(), + }, + existsSync: vi.fn(), + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + readFileSync: vi.fn(), + unlinkSync: vi.fn(), +})); + +// Dynamic import to get fresh instance +let authManager: any; + +/** + * Helper: mock a successful token refresh response + */ +function mockTokenRefreshResponse(accessToken = 'test-access-token', refreshToken = 'mock-refresh-token') { + vi.mocked(global.fetch).mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + success: true, + data: { + access_token: accessToken, + expires_in: 3600, + refresh_token: refreshToken, + }, + }), + } as Response); +} + +/** + * Helper: mock a successful user profile response + */ +function mockUserProfileResponse(profile = { id: '1', email: 'test@test.com', name: 'Test' }) { + vi.mocked(global.fetch).mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + success: true, + data: profile, + }), + } as Response); +} + +/** + * Helper: set up stored refresh token in fs mocks + */ +function setupStoredRefreshToken(token = 'mock-refresh-token') { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(Buffer.from(`encrypted:${token}`)); + vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true); + vi.mocked(safeStorage.decryptString).mockReturnValue(token); +} + +/** + * Helper: fully authenticate the manager (initialize with stored token → refresh → profile) + */ +async function authenticateManager() { + setupStoredRefreshToken(); + mockTokenRefreshResponse(); + mockUserProfileResponse(); + await authManager.initialize(); + vi.mocked(global.fetch).mockClear(); + vi.mocked(windowManager.sendToRenderer).mockClear(); +} + +describe('AuthManager', () => { + beforeEach(async () => { + vi.clearAllMocks(); + vi.resetModules(); + + // Default: no stored refresh token + vi.mocked(fs.existsSync).mockReturnValue(false); + + // Re-import to get fresh singleton + const mod = await import('../AuthManager.js'); + authManager = mod.authManager; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('getAuthState', () => { + it('should return default state', () => { + const state = authManager.getAuthState(); + expect(state.isAuthenticated).toBe(false); + expect(state.isLoading).toBe(false); + expect(state.user).toBeNull(); + expect(state.deviceFlowStatus).toBe('idle'); + expect(state.userCode).toBeNull(); + expect(state.verificationUrl).toBeNull(); + expect(state.error).toBeNull(); + }); + }); + + describe('initialize', () => { + it('should complete without error when no stored token', async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + await authManager.initialize(); + + const state = authManager.getAuthState(); + expect(state.isAuthenticated).toBe(false); + expect(state.isLoading).toBe(false); + }); + + it('should restore session successfully with stored token', async () => { + setupStoredRefreshToken(); + mockTokenRefreshResponse(); + mockUserProfileResponse(); + + await authManager.initialize(); + + const state = authManager.getAuthState(); + expect(state.isAuthenticated).toBe(true); + expect(state.isLoading).toBe(false); + expect(state.user).toEqual({ id: '1', email: 'test@test.com', name: 'Test' }); + + // 2 fetch calls: refresh token + user profile (via fetchWithAuth) + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(global.fetch).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('/api/v1/auth/token/refresh'), + expect.objectContaining({ method: 'POST' }), + ); + expect(global.fetch).toHaveBeenNthCalledWith( + 2, + expect.stringContaining('/api/v1/user/profile'), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer test-access-token', + }), + }), + ); + }); + + it('should clear tokens when refresh fails during restore', async () => { + setupStoredRefreshToken(); + + // Mock refresh endpoint to fail + vi.mocked(global.fetch).mockResolvedValueOnce({ + ok: false, + status: 401, + } as Response); + + await authManager.initialize(); + + const state = authManager.getAuthState(); + expect(state.isAuthenticated).toBe(false); + }); + + it('should not be authenticated when profile fetch fails during restore but keep refresh token', async () => { + setupStoredRefreshToken(); + mockTokenRefreshResponse(); + + // Profile fetch returns 500 + vi.mocked(global.fetch).mockResolvedValueOnce({ + ok: true, + status: 500, + json: () => Promise.resolve({ success: false }), + } as Response); + + await authManager.initialize(); + + const state = authManager.getAuthState(); + expect(state.isAuthenticated).toBe(false); + }); + }); + + describe('startDeviceLogin', () => { + it('should call device code API and open browser', async () => { + const mockDeviceCode = { + device_code: 'test-device-code', + user_code: 'ABCD-1234', + verification_url: 'https://markdown.fit/device', + expires_in: 600, + interval: 5, + }; + + vi.mocked(global.fetch).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true, data: mockDeviceCode }), + } as Response); + + const result = await authManager.startDeviceLogin(); + + expect(result.success).toBe(true); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/v1/auth/device/code'), + expect.objectContaining({ method: 'POST' }), + ); + expect(shell.openExternal).toHaveBeenCalledWith('https://markdown.fit/device'); + + const state = authManager.getAuthState(); + expect(state.deviceFlowStatus).toBe('polling'); + expect(state.userCode).toBe('ABCD-1234'); + expect(state.verificationUrl).toBe('https://markdown.fit/device'); + }); + + it('should handle API error', async () => { + vi.mocked(global.fetch).mockResolvedValueOnce({ + ok: false, + status: 500, + } as Response); + + const result = await authManager.startDeviceLogin(); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + + const state = authManager.getAuthState(); + expect(state.deviceFlowStatus).toBe('error'); + }); + }); + + describe('cancelLogin', () => { + it('should reset device flow state', async () => { + const mockDeviceCode = { + device_code: 'test-device-code', + user_code: 'ABCD-1234', + verification_url: 'https://markdown.fit/device', + expires_in: 600, + interval: 5, + }; + + vi.mocked(global.fetch).mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true, data: mockDeviceCode }), + } as Response); + + await authManager.startDeviceLogin(); + authManager.cancelLogin(); + + const state = authManager.getAuthState(); + expect(state.deviceFlowStatus).toBe('idle'); + expect(state.userCode).toBeNull(); + expect(state.verificationUrl).toBeNull(); + }); + }); + + describe('logout', () => { + it('should clear state when not authenticated', async () => { + await authManager.logout(); + + const state = authManager.getAuthState(); + expect(state.isAuthenticated).toBe(false); + expect(state.user).toBeNull(); + // No fetch call since accessToken is null + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('should call logout API via fetchWithAuth and clear tokens when authenticated', async () => { + await authenticateManager(); + + // Mock the logout API call (via fetchWithAuth) + vi.mocked(global.fetch).mockResolvedValueOnce({ + ok: true, + status: 200, + } as Response); + + await authManager.logout(); + + const state = authManager.getAuthState(); + expect(state.isAuthenticated).toBe(false); + expect(state.user).toBeNull(); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/v1/auth/logout'), + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer test-access-token', + }), + }), + ); + }); + + it('should still clear tokens even if logout API returns 401 and refresh fails', async () => { + await authenticateManager(); + + // Logout API returns 401 + vi.mocked(global.fetch).mockResolvedValueOnce({ + ok: false, + status: 401, + } as Response); + + // Refresh attempt fails + vi.mocked(global.fetch).mockResolvedValueOnce({ + ok: false, + status: 401, + } as Response); + + await authManager.logout(); + + // fetchWithAuth throws, but logout catches it and still clears tokens + const state = authManager.getAuthState(); + expect(state.isAuthenticated).toBe(false); + expect(state.user).toBeNull(); + }); + }); + + describe('getAccessToken', () => { + it('should return null when not authenticated', async () => { + const token = await authManager.getAccessToken(); + expect(token).toBeNull(); + }); + + it('should return valid token when authenticated', async () => { + await authenticateManager(); + + const token = await authManager.getAccessToken(); + expect(token).toBe('test-access-token'); + // No refresh call needed since token is still valid + expect(global.fetch).not.toHaveBeenCalled(); + }); + }); + + describe('getUserProfile', () => { + it('should return null when not authenticated', () => { + const profile = authManager.getUserProfile(); + expect(profile).toBeNull(); + }); + }); + + describe('fetchWithAuth', () => { + it('should throw when not authenticated', async () => { + await expect(authManager.fetchWithAuth('https://api.example.com/data')).rejects.toThrow( + 'Authentication required', + ); + }); + + it('should attach token and return response on non-401', async () => { + await authenticateManager(); + + const mockResponse = { + ok: true, + status: 200, + json: () => Promise.resolve({ data: 'ok' }), + } as Response; + vi.mocked(global.fetch).mockResolvedValueOnce(mockResponse); + + const res = await authManager.fetchWithAuth('https://api.example.com/data'); + + expect(res).toBe(mockResponse); + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.example.com/data', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer test-access-token', + }), + }), + ); + }); + + it('should retry with new token on 401 after successful refresh', async () => { + await authenticateManager(); + + // First call returns 401 + vi.mocked(global.fetch).mockResolvedValueOnce({ + ok: false, + status: 401, + } as Response); + + // Refresh token call succeeds + vi.mocked(global.fetch).mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + success: true, + data: { + access_token: 'new-access-token', + expires_in: 3600, + refresh_token: 'mock-refresh-token', + }, + }), + } as Response); + + // Retry call succeeds + const retryResponse = { + ok: true, + status: 200, + json: () => Promise.resolve({ data: 'ok' }), + } as Response; + vi.mocked(global.fetch).mockResolvedValueOnce(retryResponse); + + const res = await authManager.fetchWithAuth('https://api.example.com/data'); + + expect(res).toBe(retryResponse); + // 3 calls: original request, refresh, retry + expect(global.fetch).toHaveBeenCalledTimes(3); + expect(global.fetch).toHaveBeenLastCalledWith( + 'https://api.example.com/data', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer new-access-token', + }), + }), + ); + }); + + it('should clear tokens and throw when refresh fails on 401', async () => { + await authenticateManager(); + + // First call returns 401 + vi.mocked(global.fetch).mockResolvedValueOnce({ + ok: false, + status: 401, + } as Response); + + // Refresh token call fails + vi.mocked(global.fetch).mockResolvedValueOnce({ + ok: false, + status: 401, + } as Response); + + await expect(authManager.fetchWithAuth('https://api.example.com/data')).rejects.toThrow( + 'Authentication required', + ); + + const state = authManager.getAuthState(); + expect(state.isAuthenticated).toBe(false); + expect(state.user).toBeNull(); + }); + + it('should broadcast state change when clearing tokens on 401', async () => { + await authenticateManager(); + + // First call returns 401 + vi.mocked(global.fetch).mockResolvedValueOnce({ + ok: false, + status: 401, + } as Response); + + // Refresh fails + vi.mocked(global.fetch).mockResolvedValueOnce({ + ok: false, + status: 401, + } as Response); + + await expect(authManager.fetchWithAuth('https://api.example.com/data')).rejects.toThrow(); + + // Should have broadcast the cleared state + expect(windowManager.sendToRenderer).toHaveBeenCalledWith( + 'auth:stateChanged', + expect.objectContaining({ isAuthenticated: false }), + ); + }); + + it('should pass custom options to fetch', async () => { + await authenticateManager(); + + vi.mocked(global.fetch).mockResolvedValueOnce({ + ok: true, + status: 200, + } as Response); + + await authManager.fetchWithAuth('https://api.example.com/data', { + method: 'POST', + body: JSON.stringify({ key: 'value' }), + }); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.example.com/data', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ key: 'value' }), + headers: expect.objectContaining({ + Authorization: 'Bearer test-access-token', + }), + }), + ); + }); + }); +}); diff --git a/src/main/index.ts b/src/main/index.ts index 85418c1..c364636 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -83,8 +83,12 @@ import { registerIpcHandlers } from "./ipc/handlers.js"; import { windowManager } from './WindowManager.js'; import { eventBridge } from './ipc/eventBridge.js'; import { updateService } from './services/UpdateService.js'; +import { authManager } from '../core/infrastructure/services/AuthManager.js'; import fileLogic from "../core/infrastructure/services/FileService.js"; +// 自定义协议名称(用于 OAuth 回调) +const PROTOCOL_NAME = 'markpdfdown'; + // 在 app ready 之前注册自定义协议的权限 protocol.registerSchemesAsPrivileged([ { @@ -98,6 +102,92 @@ protocol.registerSchemesAsPrivileged([ } ]); +// 注册为默认协议客户端(处理 markpdfdown:// 链接) +if (process.defaultApp) { + // 开发模式下需要传递额外参数 + if (process.argv.length >= 2) { + app.setAsDefaultProtocolClient(PROTOCOL_NAME, process.execPath, [path.resolve(process.argv[1])]); + } +} else { + // 生产模式 + app.setAsDefaultProtocolClient(PROTOCOL_NAME); +} + +// 处理自定义协议 URL(用于 OAuth 回调) +function handleProtocolUrl(url: string) { + console.log('[Main] Received protocol URL'); + + if (!url.startsWith(`${PROTOCOL_NAME}://`)) { + console.warn('[Main] Ignoring URL with unexpected scheme'); + return; + } + + // 解析并严格校验路径(直接使用 URL 结构化组件,不解码 host) + try { + const parsed = new URL(url); + const host = parsed.host.toLowerCase(); + const pathname = parsed.pathname.replace(/\/+/g, '/').replace(/\/+$/, ''); + + // Reject percent-encoded slashes in host (bypass attempt) + if (parsed.host.includes('%')) { + console.warn('[Main] Ignoring protocol URL with encoded host'); + return; + } + + const isAllowed = + (host === 'auth' && pathname === '/callback') || + (host === 'auth' && pathname === ''); + + if (!isAllowed) { + console.warn(`[Main] Ignoring protocol URL with unexpected path: ${host}${pathname}`); + return; + } + } catch { + console.warn('[Main] Ignoring malformed protocol URL'); + return; + } + + // 聚焦主窗口 + if (mainWindow) { + if (mainWindow.isMinimized()) { + mainWindow.restore(); + } + mainWindow.focus(); + } + + // 立即检查 token 状态,加速获取 token + authManager.checkDeviceTokenStatus(); +} + +// macOS: 通过 open-url 事件处理协议 URL +app.on('open-url', (event, url) => { + event.preventDefault(); + handleProtocolUrl(url); +}); + +// Windows/Linux: 处理单实例锁和协议 URL +const gotTheLock = app.requestSingleInstanceLock(); + +if (!gotTheLock) { + app.quit(); +} else { + app.on('second-instance', (_event, commandLine) => { + // 用户尝试启动第二个实例时,聚焦到主窗口 + if (mainWindow) { + if (mainWindow.isMinimized()) { + mainWindow.restore(); + } + mainWindow.focus(); + } + + // Windows/Linux: 协议 URL 通过命令行参数传递 + const url = commandLine.find(arg => arg.startsWith(`${PROTOCOL_NAME}://`)); + if (url) { + handleProtocolUrl(url); + } + }); +} + let mainWindow: BrowserWindow | null; // 注册自定义协议,用于安全地加载本地文件 @@ -304,6 +394,15 @@ async function initializeBackgroundServices() { await initDatabase(); console.log(`[Main] Database initialized in ${Date.now() - startTime}ms`); + // 恢复认证会话 + console.log("[Main] Restoring auth session..."); + const authStartTime = Date.now(); + await authManager.initialize(); + console.log(`[Main] Auth session restored in ${Date.now() - authStartTime}ms`); + + // SSE connection is managed by renderer's CloudContext via IPC (sseConnect/sseDisconnect) + // to avoid duplicate connections from both main process and renderer + // 注入预设供应商 console.log("[Main] Injecting preset providers..."); const presetStartTime = Date.now(); diff --git a/src/main/ipc/handlers/auth.handler.ts b/src/main/ipc/handlers/auth.handler.ts new file mode 100644 index 0000000..e05c214 --- /dev/null +++ b/src/main/ipc/handlers/auth.handler.ts @@ -0,0 +1,61 @@ +import { ipcMain } from 'electron'; +import { authManager } from '../../../core/infrastructure/services/AuthManager.js'; + +/** + * Register Auth IPC handlers + */ +export function registerAuthHandlers() { + ipcMain.handle('auth:login', async () => { + try { + const result = await authManager.startDeviceLogin(); + return result; + } catch (error) { + console.error('[IPC] auth:login error:', error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }); + + ipcMain.handle('auth:cancelLogin', async () => { + try { + authManager.cancelLogin(); + return { success: true }; + } catch (error) { + console.error('[IPC] auth:cancelLogin error:', error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }); + + ipcMain.handle('auth:logout', async () => { + try { + await authManager.logout(); + return { success: true }; + } catch (error) { + console.error('[IPC] auth:logout error:', error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }); + + ipcMain.handle('auth:getAuthState', async () => { + try { + const state = authManager.getAuthState(); + return { success: true, data: state }; + } catch (error) { + console.error('[IPC] auth:getAuthState error:', error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }); + + console.log('[IPC] Auth handlers registered'); +} diff --git a/src/main/ipc/handlers/cloud.handler.ts b/src/main/ipc/handlers/cloud.handler.ts new file mode 100644 index 0000000..0006525 --- /dev/null +++ b/src/main/ipc/handlers/cloud.handler.ts @@ -0,0 +1,297 @@ +import { ipcMain, dialog, app } from 'electron'; +import path from 'path'; +import fs from 'fs'; +import cloudService from '../../../core/infrastructure/services/CloudService.js'; +import { cloudSSEManager } from '../../../core/infrastructure/services/CloudSSEManager.js'; + +// Max upload size: 100MB +const MAX_UPLOAD_SIZE_BYTES = 100 * 1024 * 1024; + +/** + * Register Cloud IPC handlers + */ +export function registerCloudHandlers() { + /** + * Convert file via cloud + */ + ipcMain.handle('cloud:convert', async (_, fileData: { path?: string; content?: ArrayBuffer; name: string; model?: string; page_range?: string }) => { + try { + // Validate: require either path or content, not both + if (!fileData.path && !fileData.content) { + return { success: false, error: 'No file content or path provided' }; + } + + // Validate file size + if (fileData.content && fileData.content.byteLength > MAX_UPLOAD_SIZE_BYTES) { + return { success: false, error: `File too large (max ${MAX_UPLOAD_SIZE_BYTES / 1024 / 1024}MB)` }; + } + if (fileData.path) { + try { + const stat = await fs.promises.stat(fileData.path); + if (stat.size > MAX_UPLOAD_SIZE_BYTES) { + return { success: false, error: `File too large (max ${MAX_UPLOAD_SIZE_BYTES / 1024 / 1024}MB)` }; + } + } catch { + return { success: false, error: 'File not found or not accessible' }; + } + } + + const result = await cloudService.convert(fileData); + return result; + } catch (error) { + console.error('[IPC] cloud:convert error:', error); + return { + success: false, + error: error instanceof Error ? error.message : String(error) + }; + } + }); + + /** + * Get cloud tasks + */ + ipcMain.handle('cloud:getTasks', async (_, params: { page: number; pageSize: number }) => { + try { + return await cloudService.getTasks(params.page, params.pageSize); + } catch (error) { + console.error('[IPC] cloud:getTasks error:', error); + return { + success: false, + error: error instanceof Error ? error.message : String(error) + }; + } + }); + + /** + * Get task by ID + */ + ipcMain.handle('cloud:getTaskById', async (_, id: string) => { + try { + return await cloudService.getTaskById(id); + } catch (error) { + console.error('[IPC] cloud:getTaskById error:', error); + return { + success: false, + error: error instanceof Error ? error.message : String(error) + }; + } + }); + + /** + * Get task pages + */ + ipcMain.handle('cloud:getTaskPages', async (_, params: { taskId: string; page?: number; pageSize?: number }) => { + try { + return await cloudService.getTaskPages(params.taskId, params.page, params.pageSize); + } catch (error) { + console.error('[IPC] cloud:getTaskPages error:', error); + return { + success: false, + error: error instanceof Error ? error.message : String(error) + }; + } + }); + + /** + * Cancel task + */ + ipcMain.handle('cloud:cancelTask', async (_, id: string) => { + try { + return await cloudService.cancelTask(id); + } catch (error) { + console.error('[IPC] cloud:cancelTask error:', error); + return { + success: false, + error: error instanceof Error ? error.message : String(error) + }; + } + }); + + /** + * Retry task + */ + ipcMain.handle('cloud:retryTask', async (_, id: string) => { + try { + return await cloudService.retryTask(id); + } catch (error) { + console.error('[IPC] cloud:retryTask error:', error); + return { + success: false, + error: error instanceof Error ? error.message : String(error) + }; + } + }); + + /** + * Delete task (only terminal states can be deleted) + */ + ipcMain.handle('cloud:deleteTask', async (_, id: string) => { + try { + return await cloudService.deleteTask(id); + } catch (error) { + console.error('[IPC] cloud:deleteTask error:', error); + return { + success: false, + error: error instanceof Error ? error.message : String(error) + }; + } + }); + + /** + * Retry single page + */ + ipcMain.handle('cloud:retryPage', async (_, params: { taskId: string; pageNumber: number }) => { + try { + return await cloudService.retryPage(params.taskId, params.pageNumber); + } catch (error) { + console.error('[IPC] cloud:retryPage error:', error); + return { + success: false, + error: error instanceof Error ? error.message : String(error) + }; + } + }); + + /** + * Get task result + */ + ipcMain.handle('cloud:getTaskResult', async (_, id: string) => { + try { + return await cloudService.getTaskResult(id); + } catch (error) { + console.error('[IPC] cloud:getTaskResult error:', error); + return { + success: false, + error: error instanceof Error ? error.message : String(error) + }; + } + }); + + /** + * Download PDF — shows save dialog, writes to disk + */ + ipcMain.handle('cloud:downloadPdf', async (_, id: string) => { + try { + const result = await cloudService.downloadPdf(id); + if (!result.success || !result.data) { + return { success: false, error: result.error || 'Download failed' }; + } + + const { buffer, fileName } = result.data; + const downloadsPath = app.getPath('downloads'); + + const saveResult = await dialog.showSaveDialog({ + defaultPath: path.join(downloadsPath, fileName), + filters: [{ name: 'PDF', extensions: ['pdf'] }], + }); + + if (saveResult.canceled || !saveResult.filePath) { + return { success: false, error: 'Cancelled' }; + } + + fs.writeFileSync(saveResult.filePath, Buffer.from(buffer)); + return { success: true, data: { filePath: saveResult.filePath } }; + } catch (error) { + console.error('[IPC] cloud:downloadPdf error:', error); + return { + success: false, + error: error instanceof Error ? error.message : String(error) + }; + } + }); + + /** + * Get page image (proxy for API paths that need auth) + */ + ipcMain.handle('cloud:getPageImage', async (_, params: { taskId: string; pageNumber: number }) => { + try { + return await cloudService.getPageImage(params.taskId, params.pageNumber); + } catch (error) { + console.error('[IPC] cloud:getPageImage error:', error); + return { + success: false, + error: error instanceof Error ? error.message : String(error) + }; + } + }); + + /** + * Get credits info + */ + ipcMain.handle('cloud:getCredits', async () => { + try { + return await cloudService.getCredits(); + } catch (error) { + console.error('[IPC] cloud:getCredits error:', error); + return { + success: false, + error: error instanceof Error ? error.message : String(error) + }; + } + }); + + /** + * Get credit history + */ + ipcMain.handle('cloud:getCreditHistory', async (_, params: { page: number; pageSize: number; type?: string }) => { + try { + return await cloudService.getCreditHistory(params.page, params.pageSize, params.type); + } catch (error) { + console.error('[IPC] cloud:getCreditHistory error:', error); + return { + success: false, + error: error instanceof Error ? error.message : String(error) + }; + } + }); + + /** + * SSE connect + */ + ipcMain.handle('cloud:sseConnect', async () => { + try { + await cloudSSEManager.connect(); + return { success: true }; + } catch (error) { + console.error('[IPC] cloud:sseConnect error:', error); + return { + success: false, + error: error instanceof Error ? error.message : String(error) + }; + } + }); + + /** + * SSE disconnect (preserves lastEventId for resumption) + */ + ipcMain.handle('cloud:sseDisconnect', async () => { + try { + cloudSSEManager.disconnect(); + return { success: true }; + } catch (error) { + console.error('[IPC] cloud:sseDisconnect error:', error); + return { + success: false, + error: error instanceof Error ? error.message : String(error) + }; + } + }); + + /** + * SSE full reset and disconnect (clears lastEventId, used on logout) + */ + ipcMain.handle('cloud:sseResetAndDisconnect', async () => { + try { + cloudSSEManager.resetAndDisconnect(); + return { success: true }; + } catch (error) { + console.error('[IPC] cloud:sseResetAndDisconnect error:', error); + return { + success: false, + error: error instanceof Error ? error.message : String(error) + }; + } + }); + + console.log('[IPC] Cloud handlers registered'); +} diff --git a/src/main/ipc/handlers/file.handler.ts b/src/main/ipc/handlers/file.handler.ts index 7f54305..9b02227 100644 --- a/src/main/ipc/handlers/file.handler.ts +++ b/src/main/ipc/handlers/file.handler.ts @@ -98,20 +98,34 @@ export function registerFileHandlers() { /** * File selection dialog + * @param allowOffice - If true, includes Office file types in the filter */ - ipcMain.handle(IPC_CHANNELS.FILE.SELECT_DIALOG, async (): Promise => { + ipcMain.handle(IPC_CHANNELS.FILE.SELECT_DIALOG, async (_, allowOffice?: boolean): Promise => { try { + const pdfAndImageExtensions = ["pdf", "jpg", "jpeg", "png", "bmp", "gif"]; + const officeExtensions = ["doc", "docx", "xls", "xlsx", "ppt", "pptx"]; + + // Keep the first filter as the default one shown by OS dialogs. + const filters = allowOffice + ? [ + { + name: "Supported Files", + extensions: [...pdfAndImageExtensions, ...officeExtensions], + }, + { name: "PDF and Images", extensions: pdfAndImageExtensions }, + { name: "Office Documents", extensions: officeExtensions }, + { name: "All Files", extensions: ["*"] }, + ] + : [ + { name: "PDF and Images", extensions: pdfAndImageExtensions }, + { name: "PDF Documents", extensions: ["pdf"] }, + { name: "Images", extensions: ["jpg", "jpeg", "png", "bmp", "gif"] }, + { name: "All Files", extensions: ["*"] }, + ]; + const result = await dialog.showOpenDialog({ properties: ["openFile", "multiSelections"], - filters: [ - { - name: "PDF and Images", - extensions: ["pdf", "jpg", "jpeg", "png", "bmp", "gif"], - }, - { name: "PDF Documents", extensions: ["pdf"] }, - { name: "Images", extensions: ["jpg", "jpeg", "png", "bmp", "gif"] }, - { name: "All Files", extensions: ["*"] }, - ], + filters, }); return { diff --git a/src/main/ipc/handlers/index.ts b/src/main/ipc/handlers/index.ts index 1a360df..552255c 100644 --- a/src/main/ipc/handlers/index.ts +++ b/src/main/ipc/handlers/index.ts @@ -5,6 +5,8 @@ import { registerTaskDetailHandlers } from './taskDetail.handler.js'; import { registerFileHandlers } from './file.handler.js'; import { registerCompletionHandlers } from './completion.handler.js'; import { registerAppHandlers } from './app.handler.js'; +import { registerCloudHandlers } from './cloud.handler.js'; +import { registerAuthHandlers } from './auth.handler.js'; import { registerUpdaterHandlers } from './updater.handler.js'; /** @@ -17,6 +19,8 @@ import { registerUpdaterHandlers } from './updater.handler.js'; * - TaskDetail: Page-level operations and retry * - File: File operations (upload, download, select) * - Completion: LLM API calls + * - Auth: Authentication (device flow login, logout, state) + * - Cloud: Cloud API operations * - App: Application info (version) * - Updater: Auto-update management */ @@ -27,6 +31,8 @@ export function registerAllHandlers() { registerTaskDetailHandlers(); registerFileHandlers(); registerCompletionHandlers(); + registerAuthHandlers(); + registerCloudHandlers(); registerAppHandlers(); registerUpdaterHandlers(); @@ -41,6 +47,8 @@ export { registerTaskDetailHandlers, registerFileHandlers, registerCompletionHandlers, + registerAuthHandlers, + registerCloudHandlers, registerAppHandlers, registerUpdaterHandlers, }; diff --git a/src/preload/electron.d.ts b/src/preload/electron.d.ts index 4aa213f..de6c9c8 100644 --- a/src/preload/electron.d.ts +++ b/src/preload/electron.d.ts @@ -72,7 +72,7 @@ interface WindowAPI { retryFailed: (taskId: string) => Promise; }; file: { - selectDialog: () => Promise; + selectDialog: (allowOffice?: boolean) => Promise; upload: (taskId: string, filePath: string) => Promise; uploadFileContent: (taskId: string, fileName: string, fileBuffer: ArrayBuffer) => Promise; getImagePath: (taskId: string, page: number) => Promise; @@ -82,6 +82,30 @@ interface WindowAPI { markImagedown: (providerId: number, modelId: string, url: string) => Promise; testConnection: (providerId: number, modelId: string) => Promise; }; + auth: { + login: () => Promise; + cancelLogin: () => Promise; + logout: () => Promise; + getAuthState: () => Promise; + }; + cloud: { + convert: (fileData: { path?: string; content?: ArrayBuffer; name: string; model?: string; page_range?: string }) => Promise; + getTasks: (params: { page: number; pageSize: number }) => Promise; + getTaskById: (id: string) => Promise; + getTaskPages: (params: { taskId: string; page?: number; pageSize?: number }) => Promise; + cancelTask: (id: string) => Promise; + retryTask: (id: string) => Promise; + deleteTask: (id: string) => Promise; + retryPage: (params: { taskId: string; pageNumber: number }) => Promise; + getTaskResult: (id: string) => Promise; + downloadPdf: (id: string) => Promise; + getPageImage: (params: { taskId: string; pageNumber: number }) => Promise; + getCredits: () => Promise; + getCreditHistory: (params: { page: number; pageSize: number; type?: string }) => Promise; + sseConnect: () => Promise; + sseDisconnect: () => Promise; + sseResetAndDisconnect: () => Promise; + }; shell: { openExternal: (url: string) => void; }; @@ -98,7 +122,9 @@ interface WindowAPI { events: { onTaskEvent: (callback: (event: TaskEventData) => void) => () => void; onTaskDetailEvent: (callback: (event: TaskDetailEventData) => void) => () => void; + onAuthStateChanged: (callback: (state: any) => void) => () => void; onUpdaterStatus: (callback: (data: any) => void) => () => void; + onCloudTaskEvent: (callback: (event: any) => void) => () => void; }; platform: NodeJS.Platform; app: { diff --git a/src/preload/index.ts b/src/preload/index.ts index ac63f10..b2fb348 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -58,7 +58,7 @@ contextBridge.exposeInMainWorld("api", { // ==================== File APIs ==================== file: { - selectDialog: () => ipcRenderer.invoke("file:selectDialog"), + selectDialog: (allowOffice?: boolean) => ipcRenderer.invoke("file:selectDialog", allowOffice), upload: (taskId: string, filePath: string) => ipcRenderer.invoke("file:upload", taskId, filePath), uploadFileContent: (taskId: string, fileName: string, fileBuffer: ArrayBuffer) => @@ -77,6 +77,50 @@ contextBridge.exposeInMainWorld("api", { ipcRenderer.invoke("completion:testConnection", providerId, modelId), }, + // ==================== Auth APIs ==================== + auth: { + login: () => ipcRenderer.invoke("auth:login"), + cancelLogin: () => ipcRenderer.invoke("auth:cancelLogin"), + logout: () => ipcRenderer.invoke("auth:logout"), + getAuthState: () => ipcRenderer.invoke("auth:getAuthState"), + }, + + // ==================== Cloud APIs ==================== + cloud: { + convert: (fileData: { path?: string; content?: ArrayBuffer; name: string; model?: string; page_range?: string }) => + ipcRenderer.invoke("cloud:convert", fileData), + getTasks: (params: { page: number; pageSize: number }) => + ipcRenderer.invoke("cloud:getTasks", params), + getTaskById: (id: string) => + ipcRenderer.invoke("cloud:getTaskById", id), + getTaskPages: (params: { taskId: string; page?: number; pageSize?: number }) => + ipcRenderer.invoke("cloud:getTaskPages", params), + cancelTask: (id: string) => + ipcRenderer.invoke("cloud:cancelTask", id), + retryTask: (id: string) => + ipcRenderer.invoke("cloud:retryTask", id), + deleteTask: (id: string) => + ipcRenderer.invoke("cloud:deleteTask", id), + retryPage: (params: { taskId: string; pageNumber: number }) => + ipcRenderer.invoke("cloud:retryPage", params), + getTaskResult: (id: string) => + ipcRenderer.invoke("cloud:getTaskResult", id), + downloadPdf: (id: string) => + ipcRenderer.invoke("cloud:downloadPdf", id), + getPageImage: (params: { taskId: string; pageNumber: number }) => + ipcRenderer.invoke("cloud:getPageImage", params), + getCredits: () => + ipcRenderer.invoke("cloud:getCredits"), + getCreditHistory: (params: { page: number; pageSize: number; type?: string }) => + ipcRenderer.invoke("cloud:getCreditHistory", params), + sseConnect: () => + ipcRenderer.invoke("cloud:sseConnect"), + sseDisconnect: () => + ipcRenderer.invoke("cloud:sseDisconnect"), + sseResetAndDisconnect: () => + ipcRenderer.invoke("cloud:sseResetAndDisconnect"), + }, + // ==================== Shell APIs ==================== shell: { openExternal: (url: string) => ipcRenderer.send("open-external-link", url), @@ -127,6 +171,21 @@ contextBridge.exposeInMainWorld("api", { }; }, + /** + * 监听认证状态变化事件 + * @param callback 事件回调函数,接收认证状态 + * @returns 清理函数 + */ + onAuthStateChanged: (callback: (state: any) => void) => { + const handler = (_event: any, state: any) => callback(state); + ipcRenderer.on('auth:stateChanged', handler); + + // 返回清理函数 + return () => { + ipcRenderer.removeListener('auth:stateChanged', handler); + }; + }, + /** * 监听更新状态事件 * @param callback 事件回调函数 @@ -140,6 +199,20 @@ contextBridge.exposeInMainWorld("api", { ipcRenderer.removeListener('updater:status', handler); }; }, + + /** + * 监听云端任务 SSE 事件 + * @param callback 事件回调函数 + * @returns 清理函数 + */ + onCloudTaskEvent: (callback: (event: any) => void) => { + const handler = (_event: any, data: any) => callback(data); + ipcRenderer.on('cloud:taskEvent', handler); + + return () => { + ipcRenderer.removeListener('cloud:taskEvent', handler); + }; + }, }, // ==================== Platform APIs ==================== diff --git a/src/renderer/App.css b/src/renderer/App.css index 3eb7bb7..5ad5dca 100644 --- a/src/renderer/App.css +++ b/src/renderer/App.css @@ -109,6 +109,31 @@ } } +/* Settings Tabs - tab栏固定,内容区域滚动 */ +.settings-tabs .ant-tabs-content-holder { + flex: 1 1 auto; + overflow-y: auto; + min-height: 0; +} + +.settings-tabs .ant-tabs-content-holder::-webkit-scrollbar { + width: 6px; +} + +.settings-tabs .ant-tabs-content-holder::-webkit-scrollbar-thumb { + background-color: transparent; + border-radius: 3px; +} + +.settings-tabs .ant-tabs-content-holder:hover::-webkit-scrollbar-thumb, +.settings-tabs .ant-tabs-content-holder:active::-webkit-scrollbar-thumb { + background-color: rgba(0, 0, 0, 0.2); +} + +.settings-tabs .ant-tabs-content-holder::-webkit-scrollbar-track { + background: transparent; +} + /* Markdown 预览面板滚动 */ .ant-splitter-panel:last-child { overflow: auto !important; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 4e06adc..edd6284 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -5,6 +5,7 @@ import Home from './pages/Home' import List from './pages/List' import Settings from './pages/Settings' import Preview from './pages/Preview' +import CloudPreview from './pages/CloudPreview' import { App as AntdApp } from 'antd' import { I18nProvider } from './contexts/I18nContext' import './locales' @@ -20,6 +21,7 @@ function App() { } /> } /> } /> + } /> diff --git a/src/renderer/components/AccountCenter.tsx b/src/renderer/components/AccountCenter.tsx new file mode 100644 index 0000000..ffb7dfe --- /dev/null +++ b/src/renderer/components/AccountCenter.tsx @@ -0,0 +1,310 @@ +import React, { useContext, useEffect, useState } from 'react'; +import { Card, Button, Avatar, Typography, Divider, Row, Col, Statistic, Table, Tag, Tooltip, Space, Alert, Flex } from 'antd'; +import { UserOutlined, LogoutOutlined, CrownOutlined, SafetyCertificateOutlined, InfoCircleOutlined, LoadingOutlined, CopyOutlined } from '@ant-design/icons'; +import { useTranslation } from 'react-i18next'; +import { CloudContext, CreditHistoryItem } from '../contexts/CloudContextDefinition'; + +const { Title, Text } = Typography; + +const AccountCenter: React.FC = () => { + const { t } = useTranslation('account'); + const context = useContext(CloudContext); + const [history, setHistory] = useState([]); + const [loadingHistory, setLoadingHistory] = useState(false); + const [pagination, setPagination] = useState({ current: 1, pageSize: 5, total: 0 }); + const [codeCopied, setCodeCopied] = useState(false); + + const fetchHistory = async (page: number = 1) => { + if (!context || !context.isAuthenticated) return; + setLoadingHistory(true); + try { + const result = await context.getCreditHistory(page, pagination.pageSize); + if (result.success && result.data) { + setHistory(result.data); + setPagination(prev => ({ ...prev, current: page, total: result.pagination?.total || 0 })); + } + } catch (error) { + console.error('Failed to fetch history:', error); + } finally { + setLoadingHistory(false); + } + }; + + useEffect(() => { + if (context?.isAuthenticated) { + context.refreshCredits(); + fetchHistory(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [context?.isAuthenticated]); + + if (!context) return null; + + const { user, credits, isAuthenticated, login, logout, isLoading, deviceFlowStatus, userCode, authError, cancelLogin } = context; + + if (isLoading) { + return ; + } + + // Handle copy user code + const handleCopyCode = () => { + if (userCode) { + navigator.clipboard.writeText(userCode).then(() => { + setCodeCopied(true); + setTimeout(() => setCodeCopied(false), 2000); + }); + } + }; + + if (!isAuthenticated) { + // Device flow: pending_browser or polling + if (deviceFlowStatus === 'pending_browser' || deviceFlowStatus === 'polling') { + return ( +
+ {t('title')} +
+ +
+ {t('device_flow.enter_code_hint')} +
+ {userCode && ( +
+ + + {userCode} + + + +
+ )} + {t('device_flow.waiting')} +
+ +
+ ); + } + + // Device flow: expired + if (deviceFlowStatus === 'expired') { + return ( +
+ {t('title')} + + +
+ ); + } + + // Device flow: error + if (deviceFlowStatus === 'error') { + return ( +
+ {t('title')} + + +
+ ); + } + + // Default: idle - show login button + return ( +
+ {t('title')} + + {t('sign_in_hint')} + + +
+ ); + } + + const typeColorMap: Record = { + consume: 'blue', + pre_auth: 'geekblue', + settle: 'blue', + pre_auth_release: 'cyan', + topup: 'green', + refund: 'orange', + bonus_grant: 'cyan', + bonus_expire: 'red', + }; + + const getTypeLabel = (type: string, typeName?: string) => { + const i18nKey = `history.types.${type}`; + const translated = t(i18nKey); + // If i18next returns the key itself (no translation found), fall back to server-provided typeName + if (translated === i18nKey && typeName) { + return typeName; + } + return translated !== i18nKey ? translated : type; + }; + + const columns = [ + { + title: t('history.columns.time'), + dataIndex: 'createdAt', + key: 'createdAt', + render: (text: string) => { + if (!text) return '-'; + const date = new Date(text); + return isNaN(date.getTime()) ? text : date.toLocaleString(); + }, + }, + { + title: t('history.columns.type'), + dataIndex: 'type', + key: 'type', + render: (type: string, record: CreditHistoryItem) => { + const color = typeColorMap[type] || 'default'; + return {getTypeLabel(type, record.typeName)}; + }, + width: 120, + }, + { + title: t('history.columns.credits'), + dataIndex: 'amount', + key: 'amount', + render: (amount: number, record: CreditHistoryItem) => { + const isSettle = record.type === 'pre_auth' || record.type === 'pre_auth_release'; + return ( + 0 ? 'success' : 'danger'} strong={!isSettle}> + {amount > 0 ? `+${amount}` : amount} + + ); + }, + align: 'right' as const, + width: 100, + }, + { + title: t('history.columns.description'), + dataIndex: 'description', + key: 'description', + render: (text: string) => { + return text || '-'; + }, + }, + ]; + + return ( +
+
+ } /> +
+ {user.name || 'User'} + {user.email} +
+ +
+ + + + + {t('credit_balance')} + + {t('credit_usage_hint')} + + + + + + + {t('monthly_free.title')} + + + + + + + {t('monthly_free.monthly_label')}} + value={credits.bonusBalance} + prefix={} + valueStyle={{ color: '#1890ff' }} + /> + + + {t('monthly_free.daily_label')}} + value={credits.free} + suffix={`/ ${credits.dailyLimit}`} + valueStyle={{ color: '#1890ff', fontSize: '20px' }} + /> + + +
+ {t('monthly_free.description')} +
+
+ + + + } + valueStyle={{ color: '#722ed1' }} + /> +
+ {t('paid_credits.description')} + + + +
+
+ +
+ + + + {t('history.title')} + fetchHistory(page) + }} + size="small" + /> + + ); +}; + +export default AccountCenter; diff --git a/src/renderer/components/Layout.tsx b/src/renderer/components/Layout.tsx index c6bc0af..265ca83 100644 --- a/src/renderer/components/Layout.tsx +++ b/src/renderer/components/Layout.tsx @@ -1,18 +1,19 @@ -import React, { CSSProperties, useState } from "react"; -import { ConfigProvider, Layout, Menu, Modal, theme } from "antd"; +import React, { CSSProperties, useState, useContext } from "react"; +import { ConfigProvider, Layout, Menu, Modal, theme, Avatar, Tooltip } from "antd"; import { HomeOutlined, UnorderedListOutlined, SettingOutlined, - GithubOutlined, CloseOutlined, MinusOutlined, - BorderOutlined + BorderOutlined, + UserOutlined } from "@ant-design/icons"; import { Outlet, useNavigate, useLocation } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { useLanguage } from "../hooks/useLanguage"; import ImgLogo from "../assets/MarkPDFdown.png"; +import { CloudContext } from "../contexts/CloudContextDefinition"; const { Header, Sider, Content, Footer } = Layout; @@ -113,6 +114,30 @@ const WindowControls: React.FC<{ onClose: () => void }> = ({ onClose }) => { ); }; +const UserProfileIcon: React.FC<{ navigate: (path: string) => void; notSignedInText: string }> = ({ navigate, notSignedInText }) => { + const cloudContext = useContext(CloudContext); + + // Guard against missing context + if (!cloudContext) return null; + + const { user, isAuthenticated } = cloudContext; + + return ( +
navigate('/settings')} + style={{ cursor: 'pointer', marginBottom: '16px' }} + > + + } + style={{ backgroundColor: isAuthenticated ? '#1890ff' : '#ccc' }} + /> + +
+ ); +}; + const AppLayout: React.FC = () => { const navigate = useNavigate(); const location = useLocation(); @@ -177,9 +202,9 @@ const AppLayout: React.FC = () => { const hash = location.hash; const hashPath = hash.startsWith('#') ? hash.substring(1) : ''; const currentPath = hashPath || location.pathname; - + // console.log('Current path:', currentPath); // 调试用 - + // 检查是否为子路径 for (const item of menuItems) { // 如果当前路径以某个菜单项的路径为开头,则选中该菜单项 @@ -187,7 +212,7 @@ const AppLayout: React.FC = () => { return item.key; } } - + // 如果没有匹配,则默认选中首页 return "1"; }; @@ -196,17 +221,6 @@ const AppLayout: React.FC = () => { const headerStyle: CustomCSSProperties = { WebkitAppRegion: 'drag' }; - - // 打开外部链接 - const openExternalLink = (url: string) => { - if (window.electron?.ipcRenderer) { - // 使用通过上下文桥接口提供的IPC - window.electron.ipcRenderer.send('open-external-link', url); - } else { - // 降级为普通链接(在浏览器中运行时) - window.open(url, '_blank', 'noopener,noreferrer'); - } - }; return ( { } }} /> - -
-
openExternalLink('https://github.com/MarkPDFdown/markpdfdown-desktop')} - style={{ - color: 'rgba(255, 255, 255, 0.65)', - fontSize: '20px', - transition: 'color 0.3s', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - cursor: 'pointer' - }} - onMouseEnter={(e) => e.currentTarget.style.color = 'white'} - onMouseLeave={(e) => e.currentTarget.style.color = 'rgba(255, 255, 255, 0.65)'} - > - -
+
- +
{ borderRadius: borderRadiusLG, flex: "1 1 auto", overflow: "hidden", + minHeight: 0, }} >
diff --git a/src/renderer/components/ModelService.tsx b/src/renderer/components/ModelService.tsx index 957fc44..17048b1 100644 --- a/src/renderer/components/ModelService.tsx +++ b/src/renderer/components/ModelService.tsx @@ -119,9 +119,11 @@ const ModelService: React.FC = () => { // 合并服务商选项卡和"添加服务商"选项卡 setItems([...providerTabs, addProviderTab]); - // 只在初始加载时自动切换到第一个服务商,避免添加后跳转 - if (isInitialLoad && providerTabs.length > 0 && activeKey === "add provider") { - setActiveKey(providerTabs[0].key); + // 只在初始加载时自动切换到第一个服务商 + if (isInitialLoad && activeKey === "add provider") { + if (providers.length > 0) { + setActiveKey(providers[0].id.toString()); + } setIsInitialLoad(false); } } catch (error) { diff --git a/src/renderer/components/UploadPanel.tsx b/src/renderer/components/UploadPanel.tsx index d5d60bf..f4ea373 100644 --- a/src/renderer/components/UploadPanel.tsx +++ b/src/renderer/components/UploadPanel.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useContext, useMemo } from "react"; import { Button, Col, @@ -11,12 +11,34 @@ import { Upload, UploadFile, UploadProps, + Tooltip } from "antd"; -import { FileMarkdownOutlined, InboxOutlined } from "@ant-design/icons"; +import { FileMarkdownOutlined, InboxOutlined, CloudOutlined, LoginOutlined } from "@ant-design/icons"; import { useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; +import { CloudContext } from "../contexts/CloudContextDefinition"; + const { Text } = Typography; +// Cloud Constants +const CLOUD_PROVIDER_ID = -1; + +// Cloud model tiers matching server API: lite, pro, ultra +// Format: "Fit Lite (约10积分/页)" +const CLOUD_MODEL_TIERS = [ + { id: 'lite', name: 'Fit Lite', creditsPerPage: 10 }, + { id: 'pro', name: 'Fit Pro', creditsPerPage: 20 }, + { id: 'ultra', name: 'Fit Ultra', creditsPerPage: 60 }, +] as const; + +type CloudModelTier = typeof CLOUD_MODEL_TIERS[number]['id']; + +// Supported Office file extensions +const OFFICE_EXTENSIONS = ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx']; +const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'bmp', 'gif']; + +type FileCategory = 'pdf' | 'image' | 'office' | 'unsupported'; + // 定义模型数据接口 interface ModelType { id: string; @@ -32,10 +54,42 @@ interface ModelGroupType { const SELECTED_MODEL_KEY = "markpdfdown_selected_model"; +// Check if current selection supports Office files (requires: logged in + cloud model selected) +const supportsOfficeFiles = ( + isAuthenticated: boolean | undefined, + selectedModel: string +): boolean => { + if (!isAuthenticated || !selectedModel) return false; + const [, providerIdStr] = selectedModel.split("@"); + const providerId = parseInt(providerIdStr, 10); + return providerId === CLOUD_PROVIDER_ID; +}; + +const getFileCategory = (fileName: string, fileType?: string): FileCategory => { + const fileNameLower = fileName.toLowerCase(); + const mimeType = fileType?.toLowerCase(); + + if (mimeType === 'application/pdf' || fileNameLower.endsWith('.pdf')) { + return 'pdf'; + } + + if ((mimeType?.startsWith('image/') ?? false) || IMAGE_EXTENSIONS.some(ext => fileNameLower.endsWith(`.${ext}`))) { + return 'image'; + } + + if (OFFICE_EXTENSIONS.some(ext => fileNameLower.endsWith(`.${ext}`))) { + return 'office'; + } + + return 'unsupported'; +}; + const UploadPanel: React.FC = () => { const navigate = useNavigate(); const { message } = App.useApp(); const { t } = useTranslation('upload'); + const cloudContext = useContext(CloudContext); + const [fileList, setFileList] = useState([]); const [modelGroups, setModelGroups] = useState([]); const [loading, setLoading] = useState(false); @@ -44,6 +98,18 @@ const UploadPanel: React.FC = () => { const [pageRange, setPageRange] = useState(""); const { Dragger } = Upload; + // Determine if Office files are supported based on current selection + const canUseOfficeFiles = useMemo( + () => supportsOfficeFiles(cloudContext?.isAuthenticated, selectedModel), + [cloudContext?.isAuthenticated, selectedModel] + ); + const isAuthenticated = Boolean(cloudContext?.isAuthenticated); + const draggerHint = canUseOfficeFiles + ? t('dragger.hint_cloud_logged_in') + : isAuthenticated + ? t('dragger.hint_logged_in_non_cloud') + : t('dragger.hint_local'); + // 获取所有模型数据 useEffect(() => { const fetchAllModels = async () => { @@ -51,25 +117,43 @@ const UploadPanel: React.FC = () => { setLoading(true); const result = await window.api.model.getAll(); + let groups: ModelGroupType[] = []; if (result.success && result.data) { - setModelGroups(result.data); - - // 尝试恢复上次选择的模型 - const savedModel = localStorage.getItem(SELECTED_MODEL_KEY); - if (savedModel) { - // 检查保存的模型是否在当前列表中存在 - const modelExists = result.data.some((group: ModelGroupType) => - group.models.some( - (model: ModelType) => `${model.id}@${model.provider}` === savedModel - ) - ); - if (modelExists) { - setSelectedModel(savedModel); - } - } + groups = result.data; } else { message.error(result.error || t('messages.fetch_models_failed')); } + + // Inject Cloud Models (lite, pro, ultra tiers) + // Format: "Fit Lite (~10 credits/page)" with i18n + const cloudGroup: ModelGroupType = { + provider: CLOUD_PROVIDER_ID, + providerName: t('cloud.provider_name'), + models: CLOUD_MODEL_TIERS.map(tier => ({ + id: tier.id, + name: `${tier.name} (${t(`cloud.tier_${tier.id}`)})`, + provider: CLOUD_PROVIDER_ID + })) + }; + + // Add cloud group to the beginning + groups = [cloudGroup, ...groups]; + setModelGroups(groups); + + // 尝试恢复上次选择的模型 + const savedModel = localStorage.getItem(SELECTED_MODEL_KEY); + if (savedModel) { + // 检查保存的模型是否在当前列表中存在 + const modelExists = groups.some((group: ModelGroupType) => + group.models.some( + (model: ModelType) => `${model.id}@${model.provider}` === savedModel + ) + ); + if (modelExists) { + setSelectedModel(savedModel); + } + } + } catch (error) { console.error("Failed to fetch model list:", error); message.error( @@ -92,14 +176,31 @@ const UploadPanel: React.FC = () => { // 将模型数据转换为Select选项格式 const getModelOptions = () => { - const options = modelGroups.map((group) => ({ - label: {group.providerName}, - title: group.providerName, - options: group.models.map((model) => ({ - label: {model.name}, - value: model.id + "@" + model.provider, - })), - })); + const options = modelGroups.map((group) => { + const isCloud = group.provider === CLOUD_PROVIDER_ID; + const isDisabled = isCloud && !cloudContext?.isAuthenticated; + + return { + label: ( + + {isCloud && } + {group.providerName} + + ), + title: group.providerName, + options: group.models.map((model) => ({ + label: ( + + + {model.name} + + + ), + value: model.id + "@" + model.provider, + disabled: isDisabled + })), + }; + }); // 如果没有数据,提供默认选项 if (options.length === 0) { @@ -118,7 +219,7 @@ const UploadPanel: React.FC = () => { // 处理文件选择(使用文件对话框) const handleFileSelect = async () => { try { - const dialogResult = await window.api.file.selectDialog(); + const dialogResult = await window.api.file.selectDialog(canUseOfficeFiles); if ( dialogResult.success && @@ -126,23 +227,45 @@ const UploadPanel: React.FC = () => { !dialogResult.data.canceled && dialogResult.data.filePaths.length > 0 ) { - // 将选中的文件路径转换为 UploadFile 格式 - const newFiles: UploadFile[] = dialogResult.data.filePaths.map( - (filePath: string, index: number) => { - // 从文件路径中提取文件名 - const fileName = filePath.split(/[\\/]/).pop() || filePath; - - return { - uid: `${Date.now()}-${index}`, - name: fileName, - status: "done", - // 存储原始文件路径,用于后续上传 - url: filePath, - }; - }, - ); + // 二次校验对话框返回的文件,避免通过系统对话框绕过类型限制 + const rejectedOfficeFiles: string[] = []; + const rejectedUnsupportedFiles: string[] = []; + const newFiles: UploadFile[] = []; + + dialogResult.data.filePaths.forEach((filePath: string, index: number) => { + const fileName = filePath.split(/[\\/]/).pop() || filePath; + const category = getFileCategory(fileName); + + if (category === 'unsupported') { + rejectedUnsupportedFiles.push(fileName); + return; + } + + if (category === 'office' && !canUseOfficeFiles) { + rejectedOfficeFiles.push(fileName); + return; + } - setFileList([...fileList, ...newFiles]); + newFiles.push({ + uid: `${Date.now()}-${index}`, + name: fileName, + status: "done", + // 存储原始文件路径,用于后续上传 + url: filePath, + }); + }); + + if (rejectedUnsupportedFiles.length > 0) { + message.error(t('messages.invalid_file_type', { filename: rejectedUnsupportedFiles.join(', ') })); + } + + if (rejectedOfficeFiles.length > 0) { + message.error(t('messages.office_not_supported', { filename: rejectedOfficeFiles.join(', ') })); + } + + if (newFiles.length > 0) { + setFileList((prevList) => [...prevList, ...newFiles]); + } } } catch (error) { console.error("Failed to select files:", error); @@ -153,6 +276,12 @@ const UploadPanel: React.FC = () => { } }; + // Dynamic accept attribute based on whether Office files are supported + const baseAcceptExtensions = ['.pdf', ...IMAGE_EXTENSIONS.map((ext) => `.${ext}`)]; + const acceptExtensions = canUseOfficeFiles + ? [...baseAcceptExtensions, ...OFFICE_EXTENSIONS.map((ext) => `.${ext}`)].join(',') + : baseAcceptExtensions.join(','); + const props: UploadProps = { onRemove: (file) => { const index = fileList.indexOf(file); @@ -161,14 +290,19 @@ const UploadPanel: React.FC = () => { setFileList(newFileList); }, beforeUpload: (file) => { - // 检查文件类型 - const isPDF = file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf'); + const category = getFileCategory(file.name, file.type); - if (!isPDF) { + if (category === 'unsupported') { message.error(t('messages.invalid_file_type', { filename: file.name })); return Upload.LIST_IGNORE; } + // If Office file but not supported in current mode + if (category === 'office' && !canUseOfficeFiles) { + message.error(t('messages.office_not_supported', { filename: file.name })); + return Upload.LIST_IGNORE; + } + // 将拖放的文件添加到文件列表 const newFile: UploadFile = { uid: `${Date.now()}-${Math.random()}`, @@ -186,7 +320,7 @@ const UploadPanel: React.FC = () => { }, fileList, showUploadList: true, - accept: '.pdf', + accept: acceptExtensions, multiple: true, }; @@ -209,6 +343,35 @@ const UploadPanel: React.FC = () => { const [modelId, providerIdStr] = selectedModel.split("@"); const providerId = parseInt(providerIdStr, 10); + // Check if it is a cloud conversion + if (providerId === CLOUD_PROVIDER_ID) { + if (!cloudContext) { + throw new Error("Cloud context not initialized"); + } + + let successCount = 0; + const modelTier = modelId as CloudModelTier; + for (const file of fileList) { + const result = await cloudContext.convertFile({ + name: file.name, + url: file.url, + originFileObj: file.originFileObj as File | undefined + }, modelTier, pageRange || undefined); + if (result.success) { + successCount++; + } else { + message.error(t('cloud.upload_failed', { filename: file.name, error: result.error })); + } + } + + if (successCount > 0) { + message.success(t('cloud.upload_success', { count: successCount })); + setFileList([]); + navigate("/list", { replace: true }); + } + return; + } + // 获取选中的模型名称,模型名称为 模型name@提供商name const selectedModelGroup = modelGroups.find( (group) => group.provider === providerId, @@ -332,8 +495,14 @@ const UploadPanel: React.FC = () => {

{t('dragger.text')}

- {t('dragger.hint')} + {draggerHint}

+ {!canUseOfficeFiles && !isAuthenticated && ( +

+ + {t('dragger.login_hint')} +

+ )} + +
+ + + [{tCommon('common.pages', { count: totalPages })}]{task?.file_name || ''} + + + + {task && ( + + )} +
+ + + {/* Download Markdown */} + + + {/* Action dropdown */} + {(() => { + const status = task?.status; + const failedCount = task?.pages_failed || 0; + + const menuItems: MenuProps['items'] = []; + + // Retry failed pages: status === 8 && pages_failed > 0 + if (status === 8 && failedCount > 0) { + menuItems.push({ + key: 'retry_failed', + icon: , + label: t('retry_failed_pages'), + onClick: handleRetryFailed, + disabled: retryingFailed, + }); + } + + // Retry all: status === 0 (failed) + if (status === 0) { + menuItems.push({ + key: 'retry_all', + icon: , + label: t('retry_all'), + onClick: handleRetryTask, + }); + } + + // Divider + if (menuItems.length > 0 && ((status !== undefined && status > 0 && status < 6) || status === 0 || (status !== undefined && status >= 6))) { + menuItems.push({ type: 'divider' }); + } + + // Cancel: status > 0 && status < 6 + if (status !== undefined && status > 0 && status < 6) { + menuItems.push({ + key: 'cancel', + icon: , + label: t('cancel_task'), + onClick: handleCancel, + }); + } + + // Delete: status === 0 || status >= 6 (terminal states) + if (status === 0 || (status !== undefined && status >= 6)) { + menuItems.push({ + key: 'delete', + icon: , + label: t('delete_task'), + danger: true, + onClick: handleDelete, + }); + } + + if (menuItems.length === 0) return null; + + // Check for primary action (retry failed or retry all) + const hasRetryFailed = status === 8 && failedCount > 0; + const hasRetryAll = status === 0; + const hasPrimaryAction = hasRetryFailed || hasRetryAll; + + if (hasPrimaryAction) { + const primaryLabel = hasRetryFailed ? t('retry_failed_pages') : t('retry_all'); + const primaryAction = hasRetryFailed ? handleRetryFailed : handleRetryTask; + const primaryIcon = hasRetryFailed && retryingFailed ? : ; + + // Filter out primary action from dropdown to avoid duplication + const filteredMenuItems = menuItems.filter(item => { + if (!item || item.type === 'divider') return true; + if (hasRetryFailed && (item as any).key === 'retry_failed') return false; + if (hasRetryAll && (item as any).key === 'retry_all') return false; + return true; + }); + + // Remove leading dividers + while (filteredMenuItems.length > 0 && filteredMenuItems[0]?.type === 'divider') { + filteredMenuItems.shift(); + } + + return ( + } + disabled={hasRetryFailed && retryingFailed} + > + + {primaryIcon} + {primaryLabel} + + + ); + } else { + return ( + + + + ); + } + })()} + +
+ + {/* Split View */} + + {/* Left Panel: Page list overview */} + +
+ {loading || imageLoading ? ( + + ) : !currentPageData ? ( +
+ {t('no_page_data')} +
+ ) : imageError || !imageUrl ? ( +
+ {t('page_label', { page: currentPage, total: totalPages })} +
+ ) : ( + {`Page setImageError(true)} + /> + )} +
+ + {/* Bottom status bar */} + {!loading && currentPageData && ( +
+ {pageStatusInfo ? ( + + {pageStatusInfo.icon} + + {pageStatusInfo.text} + + + ) : ( + + )} + + + +
+ )} +
+ + {/* Markdown Panel */} + + + +
+ + {/* Pagination */} +
+ +
+ + + ); +}; + +export default CloudPreview; diff --git a/src/renderer/pages/List.tsx b/src/renderer/pages/List.tsx index 52bd1ce..f9f5a8b 100644 --- a/src/renderer/pages/List.tsx +++ b/src/renderer/pages/List.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useCallback, useRef } from "react"; +import React, { useEffect, useState, useCallback, useRef, useContext } from "react"; import { Progress, Space, Table, Tooltip, Typography, Tag, App } from "antd"; import { FilePdfTwoTone, @@ -7,53 +7,118 @@ import { FileWordTwoTone, FilePptTwoTone, FileExcelTwoTone, + CloudOutlined } from "@ant-design/icons"; import { Link } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Task } from "../../shared/types/Task"; +import { CloudContext } from "../contexts/CloudContextDefinition"; +import { mapCloudTasksToTasks, type CloudTask } from "../utils/cloudTaskMapper"; +import type { CloudSSEEvent } from "../../shared/types/cloud-api"; + const { Text } = Typography; const List: React.FC = () => { const { message, modal } = App.useApp(); const { t } = useTranslation('list'); const { t: tCommon } = useTranslation('common'); + const cloudContext = useContext(CloudContext); + const [loading, setLoading] = useState(false); - const [data, setData] = useState([]); + const [data, setData] = useState<(Task | CloudTask)[]>([]); const [pagination, setPagination] = useState({ current: 1, pageSize: 10, total: 0, }); const [isPageVisible, setIsPageVisible] = useState(!document.hidden); - const [pollInterval, setPollInterval] = useState(30000); // 默认30秒 + const [pollInterval, setPollInterval] = useState(120000); // 默认120秒 const pollTimerRef = useRef(null); // 使用 ref 存储 pagination,避免 useEffect 无限循环 const paginationRef = useRef(pagination); paginationRef.current = pagination; + // Max items to fetch for unified sorting and local pagination + const MAX_FETCH_ITEMS = 100; + const fetchTasks = useCallback(async (page = 1, pageSize = 10) => { setLoading(true); try { - const result = await window.api.task.getAll({ page, pageSize }); - - if (result.success && result.data) { - setData(result.data.list); - setPagination(prev => ({ - ...prev, - current: page, - total: result.data.total, - })); + // Fetch enough data for unified sorting and local pagination + // We fetch up to MAX_FETCH_ITEMS from each source, then combine and paginate locally + + // Parallel fetch local and cloud tasks + const promises: Promise[] = [ + window.api.task.getAll({ page: 1, pageSize: MAX_FETCH_ITEMS }) + ]; + + // Only fetch cloud tasks if authenticated + if (cloudContext?.isAuthenticated) { + promises.push(cloudContext.getTasks(1, MAX_FETCH_ITEMS)); + } + + const results = await Promise.all(promises); + const localResult = results[0]; + const cloudResult = results.length > 1 ? results[1] : null; + + let combinedList: (Task | CloudTask)[] = []; + let totalCount = 0; + + // Handle local tasks + if (localResult.success && localResult.data) { + combinedList = [...localResult.data.list]; + totalCount += localResult.data.total; } else { - message.error(result.error || t('messages.fetch_failed')); + message.error(localResult.error || t('messages.fetch_failed')); } + + // Handle cloud tasks + if (cloudResult) { + if (cloudResult.success && cloudResult.data) { + const cloudTasks = mapCloudTasksToTasks(cloudResult.data); + // Add cloud task count to total for accurate pagination + if (cloudResult.pagination) { + totalCount += cloudResult.pagination.total; + } + // Merge cloud and local tasks + combinedList = [...cloudTasks, ...combinedList]; + } else { + console.error("Failed to fetch cloud tasks:", cloudResult.error); + } + } + + // Sort by unified timestamp (newest first) + // Cloud tasks use sortTimestamp, local tasks use createdAt + const getTimestamp = (t: Task | CloudTask): number => { + const task = t as any; + if (task.sortTimestamp) return task.sortTimestamp; + const createdAt = task.createdAt; + if (!createdAt) return 0; + if (createdAt instanceof Date) return createdAt.getTime(); + return 0; + }; + combinedList.sort((a, b) => getTimestamp(b) - getTimestamp(a)); + + // Local pagination: slice the sorted combined list + const startIndex = (page - 1) * pageSize; + const paginatedList = combinedList.slice(startIndex, startIndex + pageSize); + + setData(paginatedList); + setPagination(prev => ({ + ...prev, + current: page, + pageSize, + total: totalCount, + })); + } catch (error) { console.error("Failed to fetch task list:", error); message.error(t('messages.fetch_failed')); } finally { setLoading(false); } - }, [message, t]); + }, [message, t, cloudContext]); const handleTaskEvent = useCallback((event: any) => { const { type, taskId, task } = event; @@ -93,9 +158,9 @@ const List: React.FC = () => { // 动态调整轮询间隔 if (type === 'task:status_changed' && task?.status) { if (task.status >= 1 && task.status <= 5) { - setPollInterval(10000); // 活跃任务:10秒 + setPollInterval(60000); // 活跃任务:60秒 } else { - setPollInterval(30000); // 完成任务:30秒 + setPollInterval(120000); // 空闲:120秒 } } }, [fetchTasks]); @@ -113,6 +178,102 @@ const List: React.FC = () => { }; }, [handleTaskEvent]); + // Listen for cloud SSE events to update task list in real-time + useEffect(() => { + if (!window.api?.events?.onCloudTaskEvent) return; + + console.log('[List] Registering cloud SSE event listener'); + + // Track tasks not found in list to trigger a single refresh + let pendingRefresh = false; + + const handleCloudEvent = (event: CloudSSEEvent) => { + const { type, data } = event; + + // Skip non-business events + if (type === 'heartbeat' || type === 'connected') return; + + const taskId = (data as any).task_id; + if (!taskId) return; + + console.log(`[List] Cloud SSE event: type=${type}, task_id=${taskId}`); + + setData(prevData => { + const index = prevData.findIndex(t => t.id === taskId); + if (index === -1) { + // Task not in list, schedule a refresh outside of setState + if (!pendingRefresh) { + pendingRefresh = true; + queueMicrotask(() => { + pendingRefresh = false; + fetchTasks(paginationRef.current.current, paginationRef.current.pageSize); + }); + } + return prevData; + } + + const newData = [...prevData]; + const task = { ...newData[index] }; + + switch (type) { + case 'page_started': + case 'page_retry_started': { + task.status = 3; // PROCESSING + break; + } + case 'page_completed': { + const pageNumber = (data as any).page; + const totalPages = (data as any).total_pages || task.pages || 1; + // Use page number directly to avoid duplicate counting from replayed events + // page is 1-based, so completed_count = page number when pages complete in order + const completed = Math.max(task.completed_count || 0, pageNumber || 0); + task.completed_count = completed; + task.progress = Math.round((completed / totalPages) * 100); + task.status = 3; // PROCESSING + break; + } + case 'page_failed': { + // Increment as approximation; the 'completed' event provides authoritative pages_failed + task.failed_count = (task.failed_count || 0) + 1; + break; + } + case 'completed': { + task.status = (data as any).status || 6; + task.progress = 100; + task.completed_count = (data as any).pages_completed; + task.failed_count = (data as any).pages_failed; + break; + } + case 'error': { + task.status = 0; // FAILED + task.error = (data as any).error; + break; + } + case 'cancelled': { + task.status = 7; // CANCELLED + break; + } + case 'pdf_ready': { + task.status = 3; // PROCESSING (splitting done, pages ready for conversion) + task.pages = (data as any).page_count; + break; + } + default: + return prevData; + } + + newData[index] = task; + return newData; + }); + }; + + const cleanup = window.api.events.onCloudTaskEvent(handleCloudEvent); + return () => { + console.log('[List] Cleaning up cloud SSE event listener'); + cleanup(); + }; + }, [fetchTasks]); + useEffect(() => { const handleVisibilityChange = () => { const visible = !document.hidden; @@ -157,8 +318,8 @@ const List: React.FC = () => { fetchTasks(newPagination.current, newPagination.pageSize); }; - // 删除任务 - const handleDeleteTask = async (id: string) => { + // 删除任务(支持本地和云端任务) + const handleDeleteTask = async (id: string, isCloud: boolean = false) => { modal.confirm({ title: t('confirmations.delete_title'), content: t('confirmations.delete_content'), @@ -166,7 +327,14 @@ const List: React.FC = () => { cancelText: t('confirmations.cancel'), onOk: async () => { try { - const result = await window.api.task.delete(id); + let result; + if (isCloud) { + // 云端任务删除 + result = await window.api.cloud.deleteTask(id); + } else { + // 本地任务删除 + result = await window.api.task.delete(id); + } if (result.success) { message.success(t('messages.delete_success')); fetchTasks(pagination.current, pagination.pageSize); @@ -219,6 +387,54 @@ const List: React.FC = () => { handleUpdateTaskStatus(id, 7, t('actions.cancel')); }; + // 云端取消任务 + const handleCloudCancelTask = async (id: string) => { + if (!cloudContext) return; + modal.confirm({ + title: t('confirmations.cancel_title', { action: t('actions.cancel') }), + content: t('confirmations.cancel_content', { action: t('actions.cancel') }), + okText: t('confirmations.ok'), + cancelText: t('confirmations.cancel'), + onOk: async () => { + try { + const result = await cloudContext.cancelTask(id); + if (result.success) { + message.success(t('messages.action_success', { action: t('actions.cancel') })); + fetchTasks(pagination.current, pagination.pageSize); + } else { + message.error(result.error || t('messages.action_failed', { action: t('actions.cancel') })); + } + } catch { + message.error(t('messages.action_failed', { action: t('actions.cancel') })); + } + }, + }); + }; + + // 云端重试任务 + const handleCloudRetryTask = async (id: string) => { + if (!cloudContext) return; + modal.confirm({ + title: t('confirmations.cancel_title', { action: t('actions.retry') }), + content: t('confirmations.cancel_content', { action: t('actions.retry') }), + okText: t('confirmations.ok'), + cancelText: t('confirmations.cancel'), + onOk: async () => { + try { + const result = await cloudContext.retryTask(id); + if (result.success) { + message.success(t('messages.action_success', { action: t('actions.retry') })); + fetchTasks(pagination.current, pagination.pageSize); + } else { + message.error(result.error || t('messages.action_failed', { action: t('actions.retry') })); + } + } catch { + message.error(t('messages.action_failed', { action: t('actions.retry') })); + } + }, + }); + }; + const getStatusText = (status: number) => { switch (status) { case 1: @@ -314,6 +530,11 @@ const List: React.FC = () => { } })()} + {record.provider === -1 && ( + + + + )} ), }, @@ -366,22 +587,28 @@ const List: React.FC = () => { render: (_text: string, record: Task) => ( {(() => { - // 可查看: SPLITTING(2), PROCESSING(3), READY_TO_MERGE(4), MERGING(5), COMPLETED(6), PARTIAL_FAILED(8) + const isCloud = record.provider === -1; + + // View button: cloud tasks go to cloud-preview, local to preview if (record.status && (record.status > 1 && record.status < 7 || record.status === 8)) { + const previewPath = isCloud + ? `/list/cloud-preview/${record.id}` + : `/list/preview/${record.id}`; return ( - + {t('actions.view')} ); } })()} {(() => { + const isCloud = record.provider === -1; if (record.status && record.status > 0 && record.status < 6) { return ( record.id && handleCancelTask(record.id)} + onClick={() => record.id && (isCloud ? handleCloudCancelTask(record.id) : handleCancelTask(record.id))} > {t('actions.cancel')} @@ -389,12 +616,13 @@ const List: React.FC = () => { } })()} {(() => { + const isCloud = record.provider === -1; if (record.status === 0) { return ( record.id && handleRetryTask(record.id)} + onClick={() => record.id && (isCloud ? handleCloudRetryTask(record.id) : handleRetryTask(record.id))} > {t('actions.retry')} @@ -402,12 +630,30 @@ const List: React.FC = () => { } })()} {(() => { + // 云端任务删除:仅终态可删除 (FAILED=0, COMPLETED=6, CANCELLED=7, PARTIAL_FAILED=8) + const isCloud = record.provider === -1; + const terminalStatuses = [0, 6, 7, 8]; + if (isCloud) { + if (record.status !== undefined && terminalStatuses.includes(record.status)) { + return ( + record.id && handleDeleteTask(record.id, true)} + > + {t('actions.delete')} + + ); + } + return null; + } + // 本地任务删除 if (record.status === 0 || (record.status && record.status >= 6)) { return ( record.id && handleDeleteTask(record.id)} + onClick={() => record.id && handleDeleteTask(record.id, false)} > {t('actions.delete')} diff --git a/src/renderer/pages/Settings.tsx b/src/renderer/pages/Settings.tsx index c075cb7..0093647 100644 --- a/src/renderer/pages/Settings.tsx +++ b/src/renderer/pages/Settings.tsx @@ -3,12 +3,20 @@ import { Tabs } from "antd"; import type { TabsProps } from "antd"; import { useTranslation } from "react-i18next"; import ModelService from "../components/ModelService"; -import { ApiOutlined, MailOutlined } from "@ant-design/icons"; +import { ApiOutlined, MailOutlined, UserOutlined } from "@ant-design/icons"; import About from "../components/About"; +import AccountCenter from "../components/AccountCenter"; + const Settings: React.FC = () => { const { t } = useTranslation('settings'); const items: TabsProps["items"] = [ + { + key: "3", + label: t('tabs.account'), + icon: , + children: , + }, { key: "1", label: t('tabs.model_service'), @@ -22,7 +30,14 @@ const Settings: React.FC = () => { children: , }, ]; - return ; + return ( + + ); }; export default Settings; diff --git a/src/renderer/pages/__tests__/List.test.tsx b/src/renderer/pages/__tests__/List.test.tsx index 73af818..281b972 100644 --- a/src/renderer/pages/__tests__/List.test.tsx +++ b/src/renderer/pages/__tests__/List.test.tsx @@ -48,6 +48,34 @@ vi.mock('react-i18next', () => ({ }) })) +// Mock CloudContext +const mockCloudContext = { + user: { id: '', email: '', fullName: null, imageUrl: '', isLoaded: true, isSignedIn: false }, + isAuthenticated: false, + getTasks: vi.fn().mockResolvedValue({ success: false, error: 'Not authenticated' }) +} + +vi.mock('../../contexts/CloudContextDefinition', () => ({ + CloudContext: { + Provider: ({ children }: any) => children, + Consumer: ({ children }: any) => children(mockCloudContext) + } +})) + +vi.mock('react', async () => { + const actual = await vi.importActual('react') + return { + ...actual, + useContext: (context: any) => { + // Return mock for CloudContext + if (context?.Consumer) { + return mockCloudContext + } + return (actual as any).useContext(context) + } + } +}) + // Mock window.api.events - extend the existing mock from setup const mockEventListeners: Record void> = {} @@ -129,7 +157,7 @@ describe('List', () => { ) await waitFor(() => { - expect(window.api.task.getAll).toHaveBeenCalledWith({ page: 1, pageSize: 10 }) + expect(window.api.task.getAll).toHaveBeenCalledWith({ page: 1, pageSize: 100 }) }) }) @@ -290,7 +318,7 @@ describe('List', () => { ) await waitFor(() => { - expect(window.api.task.getAll).toHaveBeenCalledWith({ page: 1, pageSize: 10 }) + expect(window.api.task.getAll).toHaveBeenCalledWith({ page: 1, pageSize: 100 }) }) }) }) diff --git a/src/renderer/pages/__tests__/Preview.test.tsx b/src/renderer/pages/__tests__/Preview.test.tsx index 956968b..baccf82 100644 --- a/src/renderer/pages/__tests__/Preview.test.tsx +++ b/src/renderer/pages/__tests__/Preview.test.tsx @@ -4,48 +4,55 @@ import { MemoryRouter, Routes, Route } from 'react-router-dom' import { App } from 'antd' import Preview from '../Preview' +const translations: Record = { + 'preview.back': 'Back', + 'preview.download': 'Download', + 'preview.cancel': 'Cancel', + 'preview.retry': 'Retry', + 'preview.retry_failed': 'Retry Failed', + 'preview.retry_all': 'Retry All', + 'preview.more_actions': 'More Actions', + 'preview.delete': 'Delete', + 'preview.regenerate': 'Regenerate', + 'preview.regenerate_tooltip': 'Regenerate this page', + 'preview.confirm_delete': 'Delete Task', + 'preview.confirm_delete_content': 'Are you sure you want to delete this task?', + 'preview.confirm_cancel': 'Cancel Task', + 'preview.confirm_cancel_content': 'Are you sure you want to cancel this task?', + 'preview.confirm_retry': 'Retry Task', + 'preview.confirm_retry_content': 'Are you sure you want to retry this task?', + 'preview.delete_success': 'Task deleted', + 'preview.delete_failed': 'Failed to delete task', + 'preview.cancel_success': 'Task cancelled', + 'preview.cancel_failed': 'Failed to cancel task', + 'preview.retry_success': 'Task retrying', + 'preview.retry_failed_msg': 'Failed to retry task', + 'preview.image_load_failed': 'Image failed to load or does not exist', + 'preview.status.failed': 'Failed', + 'preview.status.pending': 'Pending', + 'preview.status.processing': 'Processing', + 'preview.status.completed': 'Completed', + 'preview.status.retrying': 'Retrying', + 'common.confirm': 'Confirm', + 'common.cancel': 'Cancel', +} + +const tMock = (key: string, params?: any) => { + if (key === 'common.pages') { + return `${params?.count || 0} pages` + } + return translations[key] || key +} + +const i18nMock = { + changeLanguage: vi.fn(), +} + // Mock react-i18next vi.mock('react-i18next', () => ({ useTranslation: () => ({ - t: (key: string, params?: any) => { - const translations: Record = { - 'preview.back': 'Back', - 'preview.download': 'Download', - 'preview.cancel': 'Cancel', - 'preview.retry': 'Retry', - 'preview.retry_failed': 'Retry Failed', - 'preview.retry_all': 'Retry All', - 'preview.more_actions': 'More Actions', - 'preview.delete': 'Delete', - 'preview.regenerate': 'Regenerate', - 'preview.regenerate_tooltip': 'Regenerate this page', - 'preview.confirm_delete': 'Delete Task', - 'preview.confirm_delete_content': 'Are you sure you want to delete this task?', - 'preview.confirm_cancel': 'Cancel Task', - 'preview.confirm_cancel_content': 'Are you sure you want to cancel this task?', - 'preview.confirm_retry': 'Retry Task', - 'preview.confirm_retry_content': 'Are you sure you want to retry this task?', - 'preview.delete_success': 'Task deleted', - 'preview.delete_failed': 'Failed to delete task', - 'preview.cancel_success': 'Task cancelled', - 'preview.cancel_failed': 'Failed to cancel task', - 'preview.retry_success': 'Task retrying', - 'preview.retry_failed_msg': 'Failed to retry task', - 'preview.image_load_failed': 'Image failed to load or does not exist', - 'preview.status.failed': 'Failed', - 'preview.status.pending': 'Pending', - 'preview.status.processing': 'Processing', - 'preview.status.completed': 'Completed', - 'preview.status.retrying': 'Retrying', - 'common.confirm': 'Confirm', - 'common.cancel': 'Cancel', - 'common.pages': `${params?.count || 0} pages` - } - return translations[key] || key - }, - i18n: { - changeLanguage: vi.fn() - } + t: tMock, + i18n: i18nMock }) })) @@ -396,8 +403,15 @@ describe('Preview', () => { ) await waitFor(() => { - expect(screen.getByText('Regenerate')).toBeInTheDocument() + expect(window.api.taskDetail.getByPage).toHaveBeenCalledWith('task-1', 1) + }) + await waitFor(() => { + expect(screen.getByTestId('markdown-preview')).toHaveTextContent('Page 1 Content') }) + + const reloadIcon = screen.getByRole('img', { name: 'reload' }) + const regenerateButton = reloadIcon.closest('button') + expect(regenerateButton).toBeInTheDocument() }) it('should call retry API when clicking regenerate', async () => { @@ -408,10 +422,19 @@ describe('Preview', () => { ) await waitFor(() => { - expect(screen.getByText('Regenerate')).toBeInTheDocument() + expect(window.api.taskDetail.getByPage).toHaveBeenCalledWith('task-1', 1) + }) + await waitFor(() => { + expect(screen.getByTestId('markdown-preview')).toHaveTextContent('Page 1 Content') }) - const regenerateButton = screen.getByText('Regenerate') + const reloadIcon = screen.getByRole('img', { name: 'reload' }) + const regenerateButton = reloadIcon.closest('button') + expect(regenerateButton).toBeInTheDocument() + if (!regenerateButton) { + throw new Error('Regenerate button not found') + } + fireEvent.click(regenerateButton) await waitFor(() => { diff --git a/src/renderer/pages/__tests__/Settings.test.tsx b/src/renderer/pages/__tests__/Settings.test.tsx index eba1315..849c945 100644 --- a/src/renderer/pages/__tests__/Settings.test.tsx +++ b/src/renderer/pages/__tests__/Settings.test.tsx @@ -9,6 +9,7 @@ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => { const translations: Record = { + 'tabs.account': 'Account', 'tabs.model_service': 'Model Service', 'tabs.about': 'About' } @@ -29,6 +30,10 @@ vi.mock('../../components/About', () => ({ default: () =>
About Mock
})) +vi.mock('../../components/AccountCenter', () => ({ + default: () =>
Account Center Mock
+})) + const Wrapper = ({ children }: { children: React.ReactNode }) => ( {children} @@ -88,27 +93,52 @@ describe('Settings', () => { expect(screen.getByText('About')).toBeInTheDocument() }) - it('should have Model Service tab active by default', () => { + it('should display Account tab', () => { render( ) - const modelServiceTab = screen.getByText('Model Service').closest('.ant-tabs-tab') - expect(modelServiceTab).toHaveClass('ant-tabs-tab-active') + expect(screen.getByText('Account')).toBeInTheDocument() + }) + + it('should have Account tab active by default', () => { + render( + + + + ) + + const accountTab = screen.getByText('Account').closest('.ant-tabs-tab') + expect(accountTab).toHaveClass('ant-tabs-tab-active') }) }) describe('Tab Content', () => { - it('should render ModelService component by default', () => { + it('should render AccountCenter component by default', () => { render( ) - expect(screen.getByTestId('model-service')).toBeInTheDocument() + expect(screen.getByTestId('account-center')).toBeInTheDocument() + }) + + it('should render ModelService component when Model Service tab is clicked', async () => { + render( + + + + ) + + const modelServiceTab = screen.getByText('Model Service') + fireEvent.click(modelServiceTab) + + await waitFor(() => { + expect(screen.getByTestId('model-service')).toBeInTheDocument() + }) }) it('should render About component when About tab is clicked', async () => { @@ -128,6 +158,18 @@ describe('Settings', () => { }) describe('Tab Icons', () => { + it('should display user icon for Account tab', () => { + render( + + + + ) + + const accountTab = screen.getByText('Account').closest('.ant-tabs-tab') + const icon = accountTab?.querySelector('[aria-label="user"]') + expect(icon).toBeInTheDocument() + }) + it('should display API icon for Model Service tab', () => { render( @@ -154,6 +196,22 @@ describe('Settings', () => { }) describe('Tab Switching', () => { + it('should switch to Model Service tab when clicked', async () => { + render( + + + + ) + + const modelServiceTab = screen.getByText('Model Service') + fireEvent.click(modelServiceTab) + + await waitFor(() => { + const modelServiceTabElement = screen.getByText('Model Service').closest('.ant-tabs-tab') + expect(modelServiceTabElement).toHaveClass('ant-tabs-tab-active') + }) + }) + it('should switch to About tab when clicked', async () => { render( @@ -170,7 +228,7 @@ describe('Settings', () => { }) }) - it('should switch back to Model Service tab', async () => { + it('should switch back to Account tab', async () => { render( @@ -184,12 +242,12 @@ describe('Settings', () => { expect(screen.getByText('About').closest('.ant-tabs-tab')).toHaveClass('ant-tabs-tab-active') }) - // Click Model Service tab - fireEvent.click(screen.getByText('Model Service')) + // Click Account tab + fireEvent.click(screen.getByText('Account')) await waitFor(() => { - const modelServiceTab = screen.getByText('Model Service').closest('.ant-tabs-tab') - expect(modelServiceTab).toHaveClass('ant-tabs-tab-active') + const accountTab = screen.getByText('Account').closest('.ant-tabs-tab') + expect(accountTab).toHaveClass('ant-tabs-tab-active') }) }) }) diff --git a/src/renderer/utils/cloudTaskMapper.ts b/src/renderer/utils/cloudTaskMapper.ts new file mode 100644 index 0000000..f1f3429 --- /dev/null +++ b/src/renderer/utils/cloudTaskMapper.ts @@ -0,0 +1,76 @@ +import type { Task } from '../../shared/types/Task'; +import type { CloudTaskResponse } from '../../shared/types/cloud-api'; + +/** + * Extended Task interface with sort timestamp for cloud tasks. + * This allows unified sorting of local and cloud tasks. + */ +export interface CloudTask extends Task { + isCloud: boolean; + /** Unix timestamp in ms for sorting */ + sortTimestamp: number; +} + +/** + * Map a cloud API task response (snake_case) to the local Task interface (camelCase). + * Cloud tasks are marked with provider=-1 and isCloud=true. + * Also adds sortTimestamp for unified sorting with local tasks. + */ +export function mapCloudTaskToTask(ct: CloudTaskResponse): CloudTask { + const pageCount = ct.page_count || 0; + const pagesCompleted = ct.pages_completed || 0; + + // Compute progress: completed status = 100%, otherwise ratio + let progress = 0; + if (ct.status === 6) { + progress = 100; + } else if (pageCount > 0) { + progress = Math.round((pagesCompleted / pageCount) * 100); + } + + // Map file_type to extension for the type field + let type: string = ct.file_type; + if (ct.file_type === 'office') { + // Extract extension from file_name + const ext = ct.file_name.split('.').pop()?.toLowerCase(); + type = ext || 'pdf'; + } + + // Map model tier to display name (matching UploadPanel) + const modelTierMap: Record = { + lite: 'Fit Lite', + pro: 'Fit Pro', + ultra: 'Fit Ultra', + }; + + // Cloud provider name + const providerName = 'Markdown.Fit'; + + // Parse created_at to unified timestamp (Unix timestamp in milliseconds) + // Use started_at as fallback if created_at is not available + const sortTimestamp = ct.created_at + ? new Date(ct.created_at).getTime() + : (ct.started_at ? new Date(ct.started_at).getTime() : Date.now()); + + return { + id: ct.id, + filename: ct.file_name, + type, + pages: pageCount, + provider: -1, + model_name: (modelTierMap[ct.model_tier?.toLowerCase() || ''] || 'Cloud') + ' | ' + providerName, + progress, + status: ct.status, + completed_count: pagesCompleted, + failed_count: ct.pages_failed || 0, + isCloud: true, + sortTimestamp, + }; +} + +/** + * Map an array of cloud tasks + */ +export function mapCloudTasksToTasks(cloudTasks: CloudTaskResponse[]): CloudTask[] { + return cloudTasks.map(mapCloudTaskToTask); +} diff --git a/src/shared/ipc/channels.ts b/src/shared/ipc/channels.ts index 3c3af21..8cfdb2a 100644 --- a/src/shared/ipc/channels.ts +++ b/src/shared/ipc/channels.ts @@ -59,12 +59,40 @@ export const IPC_CHANNELS = { TEST_CONNECTION: 'completion:testConnection', }, + // Auth channels + AUTH: { + LOGIN: 'auth:login', + CANCEL_LOGIN: 'auth:cancelLogin', + LOGOUT: 'auth:logout', + GET_AUTH_STATE: 'auth:getAuthState', + }, + + // Cloud channels + CLOUD: { + CONVERT: 'cloud:convert', + GET_TASKS: 'cloud:getTasks', + GET_TASK_BY_ID: 'cloud:getTaskById', + GET_TASK_PAGES: 'cloud:getTaskPages', + CANCEL_TASK: 'cloud:cancelTask', + RETRY_TASK: 'cloud:retryTask', + RETRY_PAGE: 'cloud:retryPage', + GET_TASK_RESULT: 'cloud:getTaskResult', + DOWNLOAD_PDF: 'cloud:downloadPdf', + GET_CREDITS: 'cloud:getCredits', + GET_CREDIT_HISTORY: 'cloud:getCreditHistory', + GET_PAGE_IMAGE: 'cloud:getPageImage', + SSE_CONNECT: 'cloud:sseConnect', + SSE_DISCONNECT: 'cloud:sseDisconnect', + }, + // Event channels (for event bridge) EVENTS: { TASK: 'task:event', TASK_DETAIL: 'taskDetail:event', APP_READY: 'app:ready', UPDATER_STATUS: 'updater:status', + AUTH_STATE_CHANGED: 'auth:stateChanged', + CLOUD_TASK_EVENT: 'cloud:taskEvent', }, // Updater channels @@ -89,6 +117,8 @@ export type IpcChannel = | typeof IPC_CHANNELS.TASK_DETAIL[keyof typeof IPC_CHANNELS.TASK_DETAIL] | typeof IPC_CHANNELS.FILE[keyof typeof IPC_CHANNELS.FILE] | typeof IPC_CHANNELS.COMPLETION[keyof typeof IPC_CHANNELS.COMPLETION] + | typeof IPC_CHANNELS.AUTH[keyof typeof IPC_CHANNELS.AUTH] + | typeof IPC_CHANNELS.CLOUD[keyof typeof IPC_CHANNELS.CLOUD] | typeof IPC_CHANNELS.UPDATER[keyof typeof IPC_CHANNELS.UPDATER] | typeof IPC_CHANNELS.EVENTS[keyof typeof IPC_CHANNELS.EVENTS] | typeof IPC_CHANNELS.WINDOW[keyof typeof IPC_CHANNELS.WINDOW]; diff --git a/src/shared/types/cloud-api.ts b/src/shared/types/cloud-api.ts new file mode 100644 index 0000000..efdea05 --- /dev/null +++ b/src/shared/types/cloud-api.ts @@ -0,0 +1,268 @@ +export type DeviceFlowStatus = 'idle' | 'pending_browser' | 'polling' | 'expired' | 'error'; + +export interface AuthState { + isAuthenticated: boolean; + isLoading: boolean; + user: CloudUserProfile | null; + deviceFlowStatus: DeviceFlowStatus; + userCode: string | null; + verificationUrl: string | null; + error: string | null; +} + +export interface CloudUserProfile { + id: number; + clerk_id: string; + email: string; + name: string | null; + avatar_url: string | null; + created_at: string; +} + +export interface DeviceCodeResponse { + device_code: string; + user_code: string; + verification_url: string; + expires_in: number; + interval: number; +} + +export interface TokenResponse { + access_token: string; + refresh_token: string; + expires_in: number; + token_type: string; +} + +// ============ Credits API Types ============ + +export interface CreditsApiResponse { + bonus: { + balance: number; + frozen: number; + daily_used: number; + daily_limit: number; + daily_remaining: number; + daily_reset_at: string; + monthly_reset_at: string; + }; + paid: { + balance: number; + frozen: number; + }; + total_available: number; +} + +export type CreditTransactionType = + | 'topup' + | 'consume' + | 'pre_auth' + | 'settle' + | 'pre_auth_release' + | 'refund' + | 'bonus_grant' + | 'bonus_expire'; + +export interface CreditTransactionApiItem { + id: number; + type: CreditTransactionType; + type_name: string; + amount: number; + balance_after: number; + bonus_amount: number; + paid_amount: number; + task_id?: string; + file_name?: string; + page_number?: number; + description?: string; + created_at: string; +} + +// ============ Convert API Types ============ + +export type CloudModelTier = 'lite' | 'pro' | 'ultra'; + +export interface CreateTaskResponse { + task_id: string; + file_type: 'office' | 'pdf' | 'image'; + file_name: string; + status: number; + credits_estimated?: number; + credits_consumed?: number; + events_url: string; +} + +export interface ConvertApiError { + code: string; + message: string; +} + +// ============ Task Management Types ============ + +export enum CloudTaskStatus { + FAILED = 0, + PENDING = 1, + SPLITTING = 2, + PROCESSING = 3, + COMPLETED = 6, + CANCELLED = 7, + PARTIAL_FAILED = 8, +} + +export enum CloudPageStatus { + PENDING = 0, + PROCESSING = 1, + COMPLETED = 2, + FAILED = 3, +} + +export interface CloudTaskResponse { + id: string; + file_type: 'office' | 'pdf' | 'image'; + file_name: string; + status: number; + status_name: string; + page_count: number; + pages_completed: number; + pages_failed: number; + pdf_url: string; + credits_estimated: number; + credits_consumed: number; + created_at: string; + started_at?: string; + completed_at?: string; + model_tier?: string; +} + +export interface CloudTaskPageResponse { + page: number; + status: number; + status_name: string; + markdown: string; + width_mm: number; + height_mm: number; + image_url?: string; +} + +export interface CloudTaskResult { + markdown: string; + pages: Array<{ page: number; markdown: string }>; + metadata: { + model_tier: string; + file_type: string; + page_count: number; + }; + credits: { + consumed: number; + }; +} + +export interface CloudCancelTaskResponse { + id: string; + status: number; + credits_consumed: number; + credits_refunded: number; + message: string; +} + +export interface CloudRetryPageResponse { + task_id: string; + page: number; + status: number; + message: string; +} + +export interface CloudApiPagination { + page: number; + page_size: number; + total: number; + total_pages: number; +} + +// ============ SSE Event Types ============ + +export type CloudSSEEventType = + | 'connected' + | 'pdf_ready' + | 'page_started' + | 'page_completed' + | 'page_failed' + | 'page_retry_started' + | 'completed' + | 'error' + | 'cancelled' + | 'heartbeat'; + +export interface CloudSSEPDFReadyData { + task_id: string; + pdf_url: string; + page_count: number; + page_dimensions: Array<{ page: number; width: number; height: number }>; + credits_estimated: number; +} + +export interface CloudSSEPageStartedData { + task_id: string; + page: number; + total_pages: number; +} + +export interface CloudSSEPageCompletedData { + task_id: string; + page: number; + total_pages: number; + markdown: string; + credits_consumed: number; +} + +export interface CloudSSEPageFailedData { + task_id: string; + page: number; + total_pages: number; + error: string; + retry_count: number; +} + +export interface CloudSSETaskCompletedData { + task_id: string; + status: number; + total_pages: number; + pages_completed: number; + pages_failed: number; + credits_consumed: number; + bonus_remaining: number; + paid_remaining: number; +} + +export interface CloudSSETaskErrorData { + task_id: string; + error: string; + stage: string; +} + +export interface CloudSSETaskCancelledData { + task_id: string; + cancelled_at: string; + pages_completed: number; + credits_refunded: number; +} + +export interface CloudSSEHeartbeatData { + time: string; +} + +export interface CloudSSEConnectedData { + message: string; +} + +export type CloudSSEEvent = + | { type: 'connected'; data: CloudSSEConnectedData } + | { type: 'pdf_ready'; data: CloudSSEPDFReadyData } + | { type: 'page_started'; data: CloudSSEPageStartedData } + | { type: 'page_completed'; data: CloudSSEPageCompletedData } + | { type: 'page_failed'; data: CloudSSEPageFailedData } + | { type: 'page_retry_started'; data: CloudSSEPageStartedData } + | { type: 'completed'; data: CloudSSETaskCompletedData } + | { type: 'error'; data: CloudSSETaskErrorData } + | { type: 'cancelled'; data: CloudSSETaskCancelledData } + | { type: 'heartbeat'; data: CloudSSEHeartbeatData }; diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index c844ac4..e737dbc 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -5,3 +5,10 @@ export type { Provider } from './Provider.js'; export type { Model } from './Model.js'; export { UpdateStatus } from './UpdateStatus.js'; export type { UpdateStatusData } from './UpdateStatus.js'; +export type { + AuthState, + CloudUserProfile, + DeviceCodeResponse, + TokenResponse, + DeviceFlowStatus, +} from './cloud-api.js'; diff --git a/tests/setup.renderer.ts b/tests/setup.renderer.ts index 4f55c2b..c6d744b 100644 --- a/tests/setup.renderer.ts +++ b/tests/setup.renderer.ts @@ -48,9 +48,27 @@ const mockWindowApi = { maximize: vi.fn(), close: vi.fn() }, + auth: { + login: vi.fn().mockResolvedValue({ success: true }), + cancelLogin: vi.fn().mockResolvedValue({ success: true }), + logout: vi.fn().mockResolvedValue({ success: true }), + getAuthState: vi.fn().mockResolvedValue({ + success: true, + data: { + isAuthenticated: false, + isLoading: false, + user: null, + deviceFlowStatus: 'idle', + userCode: null, + verificationUrl: null, + error: null + } + }) + }, events: { onTaskEvent: vi.fn(() => () => {}), - onTaskDetailEvent: vi.fn(() => () => {}) + onTaskDetailEvent: vi.fn(() => () => {}), + onAuthStateChanged: vi.fn(() => () => {}) } } diff --git a/tests/setup.ts b/tests/setup.ts index 96e3609..f055dc1 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -14,6 +14,7 @@ vi.mock('electron', () => ({ } return paths[name] || '/mock/default' }), + getVersion: vi.fn(() => '0.0.0-test'), isPackaged: false }, ipcMain: { @@ -26,6 +27,17 @@ vi.mock('electron', () => ({ showSaveDialog: vi.fn(), showMessageBox: vi.fn() }, + safeStorage: { + isEncryptionAvailable: vi.fn(() => true), + encryptString: vi.fn((str: string) => Buffer.from(`encrypted:${str}`)), + decryptString: vi.fn((buf: Buffer) => { + const str = buf.toString('utf-8') + return str.startsWith('encrypted:') ? str.slice('encrypted:'.length) : str + }) + }, + shell: { + openExternal: vi.fn() + }, BrowserWindow: vi.fn() })) diff --git a/vite.config.ts b/vite.config.ts index cf9c6ae..87510b3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,25 +1,25 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' -import { resolve } from 'path' +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { resolve } from "path"; // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], - base: './', + base: "./", server: { - port: 5173 + port: 15173, }, build: { rollupOptions: { - external: ['electron'], - } + external: ["electron"], + }, }, optimizeDeps: { - exclude: ['electron'] + exclude: ["electron"], }, resolve: { alias: { - '@': resolve(__dirname, 'src') - } - } -}) + "@": resolve(__dirname, "src"), + }, + }, +});