diff --git a/scripts/build-desktop.js b/scripts/build-desktop.js index cf49905c..8ad5b70e 100644 --- a/scripts/build-desktop.js +++ b/scripts/build-desktop.js @@ -35,7 +35,9 @@ function createWindow() { nodeIntegration: false, contextIsolation: true, enableRemoteModule: false, - webSecurity: true + // Disable webSecurity to allow cross-origin requests to local services (aria2 RPC, etc.) + // Safe for desktop app: only loads local files, no arbitrary web content + webSecurity: false }, icon: path.join(__dirname, '../dist/icon.svg'), titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default', diff --git a/server/src/routes/proxy.ts b/server/src/routes/proxy.ts index 62c33501..bc15a363 100644 --- a/server/src/routes/proxy.ts +++ b/server/src/routes/proxy.ts @@ -2,7 +2,7 @@ import { Router } from 'express'; import { getDb } from '../db/connection.js'; import { encrypt, decrypt } from '../services/crypto.js'; import { config } from '../config.js'; -import { proxyRequest, ProxyConfig } from '../services/proxyService.js'; +import { proxyRequest, ProxyConfig, validateUrl } from '../services/proxyService.js'; import { logger } from '../services/logger.js'; function getProxyConfig(): ProxyConfig | null { @@ -550,4 +550,236 @@ router.post('/api/settings/proxy/test', async (req, res) => { } }); +// --- RPC Download endpoints --- + +function getRpcDownloadConfig(): { host: string; port: number; secret: string } | null { + try { + const db = getDb(); + const row = db.prepare('SELECT value FROM settings WHERE key = ?').get('rpc_download_config') as { value: string } | undefined; + if (!row?.value) return null; + const parsed = JSON.parse(row.value); + if (parsed && parsed.enabled && parsed.host && parsed.port) { + let secret = ''; + if (parsed.secret_encrypted) { + secret = decrypt(parsed.secret_encrypted, config.encryptionKey); + } + return { host: parsed.host, port: parsed.port, secret }; + } + return null; + } catch { + return null; + } +} + +// GET /api/settings/rpc-download +router.get('/api/settings/rpc-download', (_req, res) => { + try { + const db = getDb(); + const row = db.prepare('SELECT value FROM settings WHERE key = ?').get('rpc_download_config') as { value: string } | undefined; + if (!row?.value) { + res.json({ enabled: false, host: '', port: 6800 }); + return; + } + const parsed = JSON.parse(row.value); + if (parsed.secret_encrypted) { + parsed.hasSecret = true; + } + delete parsed.secret_encrypted; + delete parsed.secret; + res.json(parsed); + } catch { + res.json({ enabled: false, host: '', port: 6800 }); + } +}); + +// PUT /api/settings/rpc-download +router.put('/api/settings/rpc-download', (req, res) => { + try { + const db = getDb(); + const { enabled, host, port, secret } = req.body; + const secretProvided = 'secret' in req.body; + + const configToStore: Record = { enabled, host, port }; + if (secretProvided && secret) { + configToStore.secret_encrypted = encrypt(secret, config.encryptionKey); + } else if (secretProvided && !secret) { + // Explicitly empty secret - clear stored secret + } else { + // Secret field omitted - preserve existing encrypted secret + const existing = db.prepare('SELECT value FROM settings WHERE key = ?').get('rpc_download_config') as { value: string } | undefined; + if (existing?.value) { + try { + const parsed = JSON.parse(existing.value); + if (parsed.secret_encrypted) { + configToStore.secret_encrypted = parsed.secret_encrypted; + } + } catch { /* ignore */ } + } + } + + db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)') + .run('rpc_download_config', JSON.stringify(configToStore)); + + res.json({ success: true }); + } catch (err) { + logger.errorFromError('rpc.settings', 'Failed to save RPC download config', err); + res.status(500).json({ error: 'Failed to save RPC download config' }); + } +}); + +// Helper: fetch with timeout using AbortController (compatible with older Node) +async function fetchWithTimeout(url: string, init: RequestInit & { timeoutMs?: number } = {}): Promise { + const { timeoutMs = 5000, ...fetchInit } = init; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...fetchInit, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + +// POST /api/settings/rpc-download/test +router.post('/api/settings/rpc-download/test', async (req, res) => { + const { host, port, secret: requestSecret } = req.body; + if (!host || !port) { + res.json({ success: false, error: 'Host and port are required' }); + return; + } + + // Fall back to stored secret only when field is omitted, not when empty + const secretProvided = Object.prototype.hasOwnProperty.call(req.body, 'secret'); + let secret = secretProvided ? requestSecret : undefined; + if (!secretProvided) { + const stored = getRpcDownloadConfig(); + if (stored && stored.secret) { + secret = stored.secret; + } + } + + try { + const rpcUrl = `http://${host}:${port}/jsonrpc`; + const params = secret ? [`token:${secret}`] : []; + + logger.info('rpc.test', `Testing connection to ${host}:${port}`); + + const response = await fetchWithTimeout(rpcUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 'test', + method: 'aria2.getVersion', + params, + }), + timeoutMs: 5000, + }); + + if (!response.ok) { + logger.warn('rpc.test', `aria2 returned HTTP ${response.status}`); + res.json({ success: false, error: `aria2 returned HTTP ${response.status}` }); + return; + } + + const data = await response.json() as Record; + if (data.error) { + const error = data.error as { message?: string }; + logger.warn('rpc.test', 'aria2 RPC error', error); + res.json({ success: false, error: error.message || 'RPC error' }); + return; + } + const result = data.result as Record | undefined; + logger.info('rpc.test', `Connected to aria2 v${result?.version || 'unknown'}`); + res.json({ + success: true, + version: result?.version || 'unknown', + }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Connection failed'; + const isAbort = err instanceof Error && err.name === 'AbortError'; + const isConnRefused = message.includes('ECONNREFUSED') || message.includes('fetch failed'); + const errorMsg = isAbort + ? `Connection timeout (${host}:${port})` + : isConnRefused + ? 'RPC service not running' + : message; + logger.errorFromError('rpc.test', `Test connection failed: ${errorMsg}`, err, { host, port }); + res.json({ success: false, error: errorMsg }); + } +}); + +// POST /api/download/rpc +router.post('/api/download/rpc', async (req, res) => { + const rpcConfig = getRpcDownloadConfig(); + if (!rpcConfig) { + res.status(400).json({ success: false, error: 'RPC download not configured or disabled' }); + return; + } + + try { + const { url, filename } = req.body; + if (!url) { + res.status(400).json({ success: false, error: 'URL is required' }); + return; + } + + // SSRF protection: validate the download URL + try { + validateUrl(url); + } catch (e) { + res.status(400).json({ success: false, error: e instanceof Error ? e.message : 'Invalid URL' }); + return; + } + + const rpcUrl = `http://${rpcConfig.host}:${rpcConfig.port}/jsonrpc`; + const params: unknown[] = rpcConfig.secret + ? [`token:${rpcConfig.secret}`, [url]] + : [[url]]; + if (filename) { + params.push({ out: filename }); + } + + logger.info('rpc.download', `Sending to aria2: ${filename || url}`); + + const response = await fetchWithTimeout(rpcUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 'download', + method: 'aria2.addUri', + params, + }), + timeoutMs: 10000, + }); + + if (!response.ok) { + logger.warn('rpc.download', `aria2 returned HTTP ${response.status}`); + res.json({ success: false, error: `aria2 returned HTTP ${response.status}` }); + return; + } + + const data = await response.json() as Record; + if (data.error) { + const error = data.error as { message?: string }; + logger.warn('rpc.download', 'aria2 RPC error', error); + res.json({ success: false, error: error.message || 'RPC error' }); + return; + } + logger.info('rpc.download', `Download queued, GID: ${data.result}`); + res.json({ success: true, gid: data.result }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Connection failed'; + const isAbort = err instanceof Error && err.name === 'AbortError'; + const isConnRefused = message.includes('ECONNREFUSED') || message.includes('fetch failed'); + const errorMsg = isAbort + ? `Connection timeout (${rpcConfig?.host}:${rpcConfig?.port})` + : isConnRefused + ? 'RPC service not running' + : message; + logger.errorFromError('rpc.download', `Download failed: ${errorMsg}`, err); + res.json({ success: false, error: errorMsg }); + } +}); + export default router; diff --git a/server/src/services/proxyService.ts b/server/src/services/proxyService.ts index ec01d970..0c281667 100644 --- a/server/src/services/proxyService.ts +++ b/server/src/services/proxyService.ts @@ -29,7 +29,7 @@ export interface ProxyResponse { const BLOCKED_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0', '169.254.169.254']); const PRIVATE_IP_PATTERNS = [/^10\./, /^172\.(1[6-9]|2\d|3[01])\./, /^192\.168\./]; -function validateUrl(rawUrl: string): void { +export function validateUrl(rawUrl: string): void { const parsed = new URL(rawUrl); if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { throw new Error(`Blocked proxy request: unsupported protocol '${parsed.protocol}'`); diff --git a/src/components/ReleaseCard.tsx b/src/components/ReleaseCard.tsx index 7c9cb8a1..548e80ea 100644 --- a/src/components/ReleaseCard.tsx +++ b/src/components/ReleaseCard.tsx @@ -1,8 +1,11 @@ -import React, { memo, useCallback } from 'react'; -import { ExternalLink, GitBranch, Calendar, Download, ChevronDown, ChevronUp, BookOpen, ArrowUpRight, FolderOpen, Folder, BellOff, FileArchive, Code2 } from 'lucide-react'; +import React, { memo, useCallback, useRef, useState } from 'react'; +import { ExternalLink, GitBranch, Calendar, Download, ChevronDown, ChevronUp, BookOpen, ArrowUpRight, FolderOpen, Folder, BellOff, FileArchive, Code2, Loader2, CheckCircle2 } from 'lucide-react'; import { Release } from '../types'; import { formatDistanceToNow } from 'date-fns'; import MarkdownRenderer from './MarkdownRenderer'; +import { useAppStore } from '../store/useAppStore'; +import { useDialog } from '../hooks/useDialog'; +import { sendToRpcDownload } from '../services/rpcDownloadService'; interface DownloadLink { name: string; @@ -51,6 +54,40 @@ const ReleaseCard: React.FC = memo(({ }) => { const t = useCallback((zh: string, en: string) => language === 'zh' ? zh : en, [language]); + // RPC download support — use refs to avoid stale closure in async handler + const { rpcDownloadConfig, backendApiSecret } = useAppStore(); + const { toast } = useDialog(); + const downloadingRef = useRef>({}); + const downloadedRef = useRef>({}); + const [, forceUpdate] = useState(0); + + const handleRpcDownload = useCallback(async (link: DownloadLink) => { + const key = link.url; + if (downloadingRef.current[key] || downloadedRef.current[key]) return; + + downloadingRef.current = { ...downloadingRef.current, [key]: true }; + forceUpdate(n => n + 1); + try { + const result = await sendToRpcDownload(link.url, link.name, backendApiSecret || undefined); + if (result.success) { + downloadedRef.current = { ...downloadedRef.current, [key]: true }; + toast(t('已发送到远程下载器', 'Sent to remote downloader'), 'success'); + } else { + toast( + result.error === 'RPC service not running' + ? t('远程下载服务未运行,请检查配置', 'Remote download service not running, please check config') + : result.error || t('发送失败', 'Send failed'), + 'error' + ); + } + } catch { + toast(t('远程下载服务未运行,请检查配置', 'Remote download service not running, please check config'), 'error'); + } finally { + downloadingRef.current = { ...downloadingRef.current, [key]: false }; + forceUpdate(n => n + 1); + } + }, [backendApiSecret, toast, t]); + // 判断是否有任何内容展开 const isAnyExpanded = isAssetsExpanded || isReleaseNotesExpanded; @@ -203,37 +240,82 @@ const ReleaseCard: React.FC = memo(({
- {downloadLinks.map((link, index) => ( - e.stopPropagation()} - > -
- {link.isSourceCode ? ( - - ) : ( - - )} - - {link.name} - -
-
- {link.size > 0 && ( - {formatFileSize(link.size)} - )} - {link.downloadCount > 0 && ( - {link.downloadCount.toLocaleString()} {t('下载', 'downloads')} - )} -
-
- ))} + {downloadLinks.map((link, index) => { + const isRpcEnabled = rpcDownloadConfig.enabled; + const isDownloading = downloadingRef.current[link.url]; + const isDownloaded = downloadedRef.current[link.url]; + + if (isRpcEnabled) { + return ( + + ); + } + + return ( + e.stopPropagation()} + > +
+ {link.isSourceCode ? ( + + ) : ( + + )} + + {link.name} + +
+
+ {link.size > 0 && ( + {formatFileSize(link.size)} + )} + {link.downloadCount > 0 && ( + {link.downloadCount.toLocaleString()} {t('下载', 'downloads')} + )} +
+
+ ); + })}
)} diff --git a/src/components/settings/NetworkPanel.tsx b/src/components/settings/NetworkPanel.tsx index 773e83e7..00e730aa 100644 --- a/src/components/settings/NetworkPanel.tsx +++ b/src/components/settings/NetworkPanel.tsx @@ -1,17 +1,19 @@ import React, { useState, useEffect } from 'react'; -import { Wifi, Eye, EyeOff, Loader2, CheckCircle2, XCircle } from 'lucide-react'; +import { Wifi, Download, Eye, EyeOff, Loader2, CheckCircle2, XCircle } from 'lucide-react'; import { useAppStore } from '../../store/useAppStore'; import { backend } from '../../services/backendAdapter'; import { isElectron, electronProxy } from '../../services/electronProxy'; -import type { ProxyConfig, ProxyType } from '../../types'; +import { testRpcDownload } from '../../services/rpcDownloadService'; +import type { ProxyConfig, ProxyType, RpcDownloadConfig } from '../../types'; interface NetworkPanelProps { t: (zh: string, en: string) => string; } export const NetworkPanel: React.FC = ({ t }) => { - const { proxyConfig, setProxyConfig, backendApiSecret } = useAppStore(); + const { proxyConfig, setProxyConfig, rpcDownloadConfig, setRpcDownloadConfig, backendApiSecret } = useAppStore(); + // --- Proxy state --- const [form, setForm] = useState(proxyConfig); const [showPassword, setShowPassword] = useState(false); const [showAuth, setShowAuth] = useState(false); @@ -19,7 +21,26 @@ export const NetworkPanel: React.FC = ({ t }) => { const [testResult, setTestResult] = useState<{ success: boolean; error?: string } | null>(null); const [saving, setSaving] = useState(false); - // Sync form when store changes externally + // --- RPC Download state --- + const [rpcForm, setRpcForm] = useState(rpcDownloadConfig); + const [showSecret, setShowSecret] = useState(false); + const [hasStoredSecret, setHasStoredSecret] = useState(false); + const [rpcTesting, setRpcTesting] = useState(false); + const [rpcTestResult, setRpcTestResult] = useState<{ success: boolean; error?: string; version?: string } | null>(null); + const [rpcSaving, setRpcSaving] = useState(false); + + // Ensure backend is initialized, then return base URL + const getRpcBaseUrl = async (): Promise => { + if (!backend.isAvailable) { + await backend.init(); + } + if (!backend.backendUrl) { + throw new Error('Backend not available'); + } + return backend.backendUrl; + }; + + // Sync proxy form when store changes externally useEffect(() => { setForm(proxyConfig); if (proxyConfig.username || proxyConfig.password) { @@ -27,11 +48,43 @@ export const NetworkPanel: React.FC = ({ t }) => { } }, [proxyConfig]); + // Sync RPC form when store changes externally + useEffect(() => { + setRpcForm(rpcDownloadConfig); + }, [rpcDownloadConfig]); + + // Load RPC config from backend on mount (to get hasSecret flag) + useEffect(() => { + const loadRpcConfig = async () => { + try { + const base = await getRpcBaseUrl(); + const authHeaders: Record = {}; + if (backendApiSecret) { + authHeaders['Authorization'] = `Bearer ${backendApiSecret}`; + } + const resp = await fetch(`${base}/settings/rpc-download`, { headers: authHeaders }); + if (resp.ok) { + const data = await resp.json(); + if (data.hasSecret) { + setHasStoredSecret(true); + } + // Merge backend state into store (enabled/host/port) + if (data.enabled !== undefined || data.host || data.port) { + setRpcDownloadConfig({ + enabled: data.enabled ?? rpcDownloadConfig.enabled, + host: data.host || rpcDownloadConfig.host, + port: data.port || rpcDownloadConfig.port, + }); + } + } + } catch { /* best effort */ } + }; + loadRpcConfig(); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + const canUseProxy = isElectron() || backend.isAvailable; - if (!canUseProxy) { - return null; - } + // --- Proxy handlers --- const isFormValid = !form.enabled || (form.host.trim() && form.port >= 1 && form.port <= 65535); @@ -105,219 +158,452 @@ export const NetworkPanel: React.FC = ({ t }) => { const hasChanges = JSON.stringify(form) !== JSON.stringify(proxyConfig); + // --- RPC Download handlers --- + + const isRpcFormValid = !rpcForm.enabled || (rpcForm.host.trim() && rpcForm.port >= 1 && rpcForm.port <= 65535); + + const handleRpcSave = async () => { + if (!isRpcFormValid) return; + + setRpcSaving(true); + setRpcTestResult(null); + try { + // Sync to backend if available + if (backend.isAvailable) { + const base = await getRpcBaseUrl(); + const authHeaders: Record = { 'Content-Type': 'application/json' }; + if (backendApiSecret) { + authHeaders['Authorization'] = `Bearer ${backendApiSecret}`; + } + const body: Record = { + enabled: rpcForm.enabled, + host: rpcForm.host, + port: rpcForm.port, + }; + if (rpcForm.secret) { + body.secret = rpcForm.secret; + } + + const resp = await fetch(`${base}/settings/rpc-download`, { + method: 'PUT', + headers: authHeaders, + body: JSON.stringify(body), + }); + if (!resp.ok) { + throw new Error(`Backend returned ${resp.status}`); + } + } + + // Always persist to local store + setRpcDownloadConfig(rpcForm); + if (rpcForm.secret) { + setHasStoredSecret(true); + } + } catch (e) { + setRpcTestResult({ success: false, error: e instanceof Error ? e.message : t('保存失败', 'Save failed') }); + } finally { + setRpcSaving(false); + } + }; + + const handleRpcTest = async () => { + setRpcTesting(true); + setRpcTestResult(null); + try { + const result = await testRpcDownload(rpcForm, backendApiSecret || undefined); + setRpcTestResult(result); + } catch (e) { + setRpcTestResult({ success: false, error: e instanceof Error ? e.message : 'Unknown error' }); + } finally { + setRpcTesting(false); + } + }; + + const rpcHasChanges = JSON.stringify(rpcForm) !== JSON.stringify(rpcDownloadConfig); + + const handleRpcToggle = async () => { + const newForm = { ...rpcForm, enabled: !rpcForm.enabled }; + setRpcForm(newForm); + setRpcDownloadConfig(newForm); + if (backend.isAvailable) { + try { + const base = await getRpcBaseUrl(); + const authHeaders: Record = { 'Content-Type': 'application/json' }; + if (backendApiSecret) { + authHeaders['Authorization'] = `Bearer ${backendApiSecret}`; + } + await fetch(`${base}/settings/rpc-download`, { + method: 'PUT', + headers: authHeaders, + body: JSON.stringify(newForm), + }); + } catch { /* best effort */ } + } + }; + return ( -
-
-
- -

- {t('网络代理', 'Network Proxy')} -

+
+ {/* Network Proxy Card — only available with backend or Electron */} + {canUseProxy && ( +
+
+
+ +

+ {t('网络代理', 'Network Proxy')} +

+
+
- -
- {form.enabled && ( -
- {/* Proxy Type */} -
- -
- {(['http', 'socks5'] as ProxyType[]).map((type) => ( - - ))} + {form.enabled && ( +
+ {/* Proxy Type */} +
+ +
+ {(['http', 'socks5'] as ProxyType[]).map((type) => ( + + ))} +
-
- {/* Host and Port */} -
-
- - setForm({ ...form, host: e.target.value })} - placeholder="127.0.0.1" - className="w-full px-3 py-2 bg-light-surface dark:bg-white/[0.04] border border-black/[0.06] dark:border-white/[0.04] rounded-lg text-gray-900 dark:text-text-primary text-sm focus:ring-2 focus:ring-brand-violet focus:border-transparent outline-none" - /> + {/* Host and Port */} +
+
+ + setForm({ ...form, host: e.target.value })} + placeholder="127.0.0.1" + className="w-full px-3 py-2 bg-light-surface dark:bg-white/[0.04] border border-black/[0.06] dark:border-white/[0.04] rounded-lg text-gray-900 dark:text-text-primary text-sm focus:ring-2 focus:ring-brand-violet focus:border-transparent outline-none" + /> +
+
+ + setForm({ ...form, port: parseInt(e.target.value) || 0 })} + placeholder="7890" + min={1} + max={65535} + className="w-full px-3 py-2 bg-light-surface dark:bg-white/[0.04] border border-black/[0.06] dark:border-white/[0.04] rounded-lg text-gray-900 dark:text-text-primary text-sm focus:ring-2 focus:ring-brand-violet focus:border-transparent outline-none" + /> +
+ + {/* Authentication (collapsible) */}
- - setForm({ ...form, port: parseInt(e.target.value) || 0 })} - placeholder="7890" - min={1} - max={65535} - className="w-full px-3 py-2 bg-light-surface dark:bg-white/[0.04] border border-black/[0.06] dark:border-white/[0.04] rounded-lg text-gray-900 dark:text-text-primary text-sm focus:ring-2 focus:ring-brand-violet focus:border-transparent outline-none" - /> -
-
+ - {/* Authentication (collapsible) */} -
- - - {showAuth && ( -
-
- - setForm({ ...form, username: e.target.value || undefined })} - placeholder={t('可选', 'Optional')} - className="w-full px-3 py-2 bg-light-surface dark:bg-white/[0.04] border border-black/[0.06] dark:border-white/[0.04] rounded-lg text-gray-900 dark:text-text-primary text-sm focus:ring-2 focus:ring-brand-violet focus:border-transparent outline-none" - /> -
-
- -
+ {showAuth && ( +
+
+ setForm({ ...form, password: e.target.value || undefined })} + type="text" + value={form.username || ''} + onChange={(e) => setForm({ ...form, username: e.target.value || undefined })} placeholder={t('可选', 'Optional')} - className="w-full px-3 py-2 pr-10 bg-light-surface dark:bg-white/[0.04] border border-black/[0.06] dark:border-white/[0.04] rounded-lg text-gray-900 dark:text-text-primary text-sm focus:ring-2 focus:ring-brand-violet focus:border-transparent outline-none" + className="w-full px-3 py-2 bg-light-surface dark:bg-white/[0.04] border border-black/[0.06] dark:border-white/[0.04] rounded-lg text-gray-900 dark:text-text-primary text-sm focus:ring-2 focus:ring-brand-violet focus:border-transparent outline-none" /> - +
+
+ +
+ setForm({ ...form, password: e.target.value || undefined })} + placeholder={t('可选', 'Optional')} + className="w-full px-3 py-2 pr-10 bg-light-surface dark:bg-white/[0.04] border border-black/[0.06] dark:border-white/[0.04] rounded-lg text-gray-900 dark:text-text-primary text-sm focus:ring-2 focus:ring-brand-violet focus:border-transparent outline-none" + /> + +
+ )} +
+ + {/* Actions */} +
+ + + +
+ + {/* Test Result */} + {testResult && ( +
+ {testResult.success ? ( + + ) : ( + + )} + + {testResult.success + ? t('代理连接成功', 'Proxy connection successful') + : testResult.error || t('代理连接失败', 'Proxy connection failed')} +
)}
+ )} +
+ )} - {/* Actions */} -
- - - + {/* RPC Download Card */} +
+
+
+ +

+ {t('远程下载', 'Remote Download')} +

+ +
+ + {rpcForm.enabled && ( +
+ {/* Host and Port */} +
+
+ + setRpcForm({ ...rpcForm, host: e.target.value })} + placeholder="127.0.0.1" + className="w-full px-3 py-2 bg-light-surface dark:bg-white/[0.04] border border-black/[0.06] dark:border-white/[0.04] rounded-lg text-gray-900 dark:text-text-primary text-sm focus:ring-2 focus:ring-brand-violet focus:border-transparent outline-none" + /> +
+
+ + setRpcForm({ ...rpcForm, port: parseInt(e.target.value) || 0 })} + placeholder="6800" + min={1} + max={65535} + className="w-full px-3 py-2 bg-light-surface dark:bg-white/[0.04] border border-black/[0.06] dark:border-white/[0.04] rounded-lg text-gray-900 dark:text-text-primary text-sm focus:ring-2 focus:ring-brand-violet focus:border-transparent outline-none" + /> +
+
+ + {/* Secret */} +
+ +
+ { + setRpcForm({ ...rpcForm, secret: e.target.value || undefined }); + if (e.target.value) setHasStoredSecret(false); + }} + placeholder={hasStoredSecret + ? t('已保存密钥,留空则保留', 'Secret saved, leave blank to keep') + : t('可选,对应 aria2 的 --rpc-secret', 'Optional, aria2 --rpc-secret')} + className="w-full px-3 py-2 pr-10 bg-light-surface dark:bg-white/[0.04] border border-black/[0.06] dark:border-white/[0.04] rounded-lg text-gray-900 dark:text-text-primary text-sm focus:ring-2 focus:ring-brand-violet focus:border-transparent outline-none" + /> + +
+
- {/* Test Result */} - {testResult && ( -
- {testResult.success ? ( - - ) : ( - + {/* Hint */} +

+ {t( + '需要运行 aria2 并启用 RPC(aria2c --enable-rpc --rpc-listen-port=6800)', + 'Requires aria2 with RPC enabled (aria2c --enable-rpc --rpc-listen-port=6800)' )} - - {testResult.success - ? t('代理连接成功', 'Proxy connection successful') - : testResult.error || t('代理连接失败', 'Proxy connection failed')} - +

+ + {/* Actions */} +
+ + +
- )} -
- )} + + {/* Test Result */} + {rpcTestResult && ( +
+ {rpcTestResult.success ? ( + + ) : ( + + )} + + {rpcTestResult.success + ? `${t('连接成功', 'Connection successful')}${rpcTestResult.version ? ` (aria2 v${rpcTestResult.version})` : ''}` + : rpcTestResult.error || t('连接失败', 'Connection failed')} + +
+ )} +
+ )} +
); }; diff --git a/src/services/rpcDownloadService.ts b/src/services/rpcDownloadService.ts new file mode 100644 index 00000000..15e5c0ea --- /dev/null +++ b/src/services/rpcDownloadService.ts @@ -0,0 +1,150 @@ +import type { RpcDownloadConfig } from '../types'; +import { backend } from './backendAdapter'; + +interface RpcTestResult { + success: boolean; + error?: string; + version?: string; +} + +interface RpcDownloadResult { + success: boolean; + error?: string; + gid?: string; +} + +function getAuthHeaders(apiSecret?: string): Record { + const headers: Record = { 'Content-Type': 'application/json' }; + if (apiSecret) { + headers['Authorization'] = `Bearer ${apiSecret}`; + } + return headers; +} + +/** + * Resolve API base URL. + * - Backend mode: use backend.backendUrl (proxied through Express) + * - Client-only mode: call aria2 directly at http://host:port + */ +async function getBaseUrl(config?: RpcDownloadConfig): Promise { + // Try backend first + if (!backend.isAvailable) { + await backend.init(); + } + if (backend.backendUrl) { + return backend.backendUrl; + } + // Fallback: direct aria2 call (client-only mode) + if (config && config.host && config.port) { + return `http://${config.host}:${config.port}`; + } + throw new Error('Backend not available and no RPC config'); +} + +/** Call aria2 JSON-RPC directly (client-only mode) */ +async function callAria2Direct( + config: RpcDownloadConfig, + method: string, + params: unknown[], +): Promise> { + const rpcUrl = `http://${config.host}:${config.port}/jsonrpc`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 5000); + try { + const resp = await fetch(rpcUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: '1', method, params }), + signal: controller.signal, + }); + if (!resp.ok) { + throw new Error(`aria2 returned HTTP ${resp.status}`); + } + return await resp.json(); + } finally { + clearTimeout(timer); + } +} + +export async function testRpcDownload( + config: RpcDownloadConfig, + apiSecret?: string, +): Promise { + try { + // Client-only mode: call aria2 directly + if (!backend.isAvailable) { + const params = config.secret ? [`token:${config.secret}`] : []; + const data = await callAria2Direct(config, 'aria2.getVersion', params); + if (data.error) { + const err = data.error as { message?: string }; + return { success: false, error: err.message || 'RPC error' }; + } + const result = data.result as Record | undefined; + return { success: true, version: result?.version as string | undefined }; + } + + // Backend mode: proxy through Express + const base = await getBaseUrl(); + const resp = await fetch(`${base}/settings/rpc-download/test`, { + method: 'POST', + headers: getAuthHeaders(apiSecret), + body: JSON.stringify({ + host: config.host, + port: config.port, + ...(config.secret ? { secret: config.secret } : {}), + }), + }); + if (!resp.ok) { + return { success: false, error: `Server returned ${resp.status}` }; + } + return await resp.json(); + } catch (e) { + return { + success: false, + error: e instanceof Error ? e.message : 'Request failed', + }; + } +} + +export async function sendToRpcDownload( + url: string, + filename: string, + apiSecret?: string, +): Promise { + try { + // Client-only mode: call aria2 directly + if (!backend.isAvailable) { + const { rpcDownloadConfig } = await import('../store/useAppStore').then(m => m.useAppStore.getState()); + if (!rpcDownloadConfig.enabled || !rpcDownloadConfig.host || !rpcDownloadConfig.port) { + return { success: false, error: 'RPC download not configured' }; + } + const params: unknown[] = rpcDownloadConfig.secret + ? [`token:${rpcDownloadConfig.secret}`, [url]] + : [[url]]; + if (filename) params.push({ out: filename }); + const data = await callAria2Direct(rpcDownloadConfig, 'aria2.addUri', params); + if (data.error) { + const err = data.error as { message?: string }; + return { success: false, error: err.message || 'RPC error' }; + } + return { success: true, gid: data.result as string }; + } + + // Backend mode: proxy through Express + const base = await getBaseUrl(); + const resp = await fetch(`${base}/download/rpc`, { + method: 'POST', + headers: getAuthHeaders(apiSecret), + body: JSON.stringify({ url, filename }), + }); + if (!resp.ok) { + return { success: false, error: `Server returned ${resp.status}` }; + } + return await resp.json(); + } catch (e) { + return { + success: false, + error: e instanceof Error ? e.message : 'Request failed', + }; + } +} diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts index 5e64f2a6..37974350 100644 --- a/src/store/useAppStore.ts +++ b/src/store/useAppStore.ts @@ -8,6 +8,7 @@ import { AIConfig, WebDAVConfig, ProxyConfig, + RpcDownloadConfig, SearchFilters, GitHubUser, Category, @@ -172,6 +173,9 @@ interface AppActions { // Proxy actions setProxyConfig: (updates: Partial) => void; + // RPC Download actions + setRpcDownloadConfig: (updates: Partial) => void; + // Release Timeline View actions setReleaseViewMode: (mode: 'timeline' | 'repository') => void; setReleaseShowMode: (mode: 'all' | 'unread') => void; @@ -279,6 +283,7 @@ type PersistedAppState = Partial< | 'discoverySortBy' | 'discoverySortOrder' | 'proxyConfig' + | 'rpcDownloadConfig' | 'subscriptionRepos' | 'subscriptionLastRefresh' | 'subscriptionIsLoading' @@ -550,6 +555,19 @@ const normalizePersistedState = ( } return { enabled: false, type: 'http' as const, host: '', port: 7890 }; })(), + rpcDownloadConfig: (() => { + const r = (safePersisted as Record).rpcDownloadConfig; + if (r && typeof r === 'object') { + const obj = r as Record; + return { + enabled: typeof obj.enabled === 'boolean' ? obj.enabled : false, + host: typeof obj.host === 'string' ? obj.host : '', + port: typeof obj.port === 'number' && Number.isFinite(obj.port) ? obj.port : 6800, + // secret 不从持久化恢复,仅在内存中 + }; + } + return { enabled: false, host: '', port: 6800 }; + })(), }; }; @@ -738,6 +756,7 @@ export const useAppStore = create()( analysisProgress: { current: 0, total: 0 }, backendApiSecret: readSessionBackendSecret(), proxyConfig: { enabled: false, type: 'http', host: '', port: 7890 }, + rpcDownloadConfig: { enabled: false, host: '', port: 6800 }, isSidebarCollapsed: false, readmeModalOpen: false, releaseViewMode: 'timeline', @@ -1266,6 +1285,9 @@ export const useAppStore = create()( setProxyConfig: (updates) => set((state) => ({ proxyConfig: { ...state.proxyConfig, ...updates } })), + setRpcDownloadConfig: (updates) => set((state) => ({ + rpcDownloadConfig: { ...state.rpcDownloadConfig, ...updates } + })), // Release Timeline View actions setReleaseViewMode: (releaseViewMode) => set({ releaseViewMode }), @@ -1517,6 +1539,13 @@ export const useAppStore = create()( username: state.proxyConfig.username, // password 不持久化,仅保留在内存中 }, + // 持久化 RPC 下载配置,但排除密钥(安全考虑) + rpcDownloadConfig: { + enabled: state.rpcDownloadConfig.enabled, + host: state.rpcDownloadConfig.host, + port: state.rpcDownloadConfig.port, + // secret 不持久化,仅保留在内存中 + }, }), migrate: (persistedState) => { // 版本升级适配处理 @@ -1643,6 +1672,11 @@ export const useAppStore = create()( (state as Record).proxyConfig = { enabled: false, type: 'http', host: '', port: 7890 }; } + // 初始化 rpcDownloadConfig + if (state && !(state as Record).rpcDownloadConfig) { + (state as Record).rpcDownloadConfig = { enabled: false, host: '', port: 6800 }; + } + return state as PersistedAppState; }, merge: (persistedState, currentState) => { diff --git a/src/types/index.ts b/src/types/index.ts index 0658f714..94f9d6f7 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -169,6 +169,13 @@ export interface ProxyConfig { password?: string; } +export interface RpcDownloadConfig { + enabled: boolean; + host: string; + port: number; + secret?: string; +} + export interface SearchFilters { query: string; tags: string[];