diff --git a/components/studio/StudioMediaBrowser.tsx b/components/studio/StudioMediaBrowser.tsx index fc70740..4a5f0d2 100644 --- a/components/studio/StudioMediaBrowser.tsx +++ b/components/studio/StudioMediaBrowser.tsx @@ -1,9 +1,10 @@ import { Download, Plus, Search, Sparkles, Trash2 } from 'lucide-react'; import { useMemo, useState, type DragEvent } from 'react'; -import { useWorkflowMediaUrl } from '../workflow/media'; +import { loadWorkflowMediaBlob, useWorkflowMediaUrl } from '../workflow/media'; import { FolderTree, type FolderTreeProps } from './FolderTree'; import type { AssetFolder } from '../../types'; import { displayError } from '../../services/displayError'; +import { downloadBlob, openRemoteMediaUrl } from '../../services/desktopNetwork'; export const STUDIO_MEDIA_DRAG_TYPE = 'application/x-flovart-studio-media'; @@ -54,18 +55,41 @@ function StudioMediaPreview({ item }: { item: StudioMediaItem }) { } function StudioMediaDownload({ item, isChinese }: { item: StudioMediaItem; isChinese: boolean }) { - const media = useWorkflowMediaUrl(undefined, item.href); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const onDownload = async () => { + if (busy) return; + setBusy(true); + setError(''); + try { + const blob = await loadWorkflowMediaBlob(undefined, item.href); + downloadBlob(blob, item.name); + } catch (downloadError) { + const message = displayError(downloadError, isChinese ? '下载失败。' : 'Download failed.'); + setError(message); + if (/^https:\/\//i.test(item.href)) { + try { + await openRemoteMediaUrl(item.href); + return; + } catch (openError) { + setError(displayError(openError, message)); + } + } + } finally { + setBusy(false); + } + }; return ( - { if (!media.url) event.preventDefault(); }} + title={error || (isChinese ? '下载' : 'Download')} + onClick={() => { void onDownload(); }} > + ); } diff --git a/components/workflow/media.ts b/components/workflow/media.ts index 8077d3f..308983c 100644 --- a/components/workflow/media.ts +++ b/components/workflow/media.ts @@ -7,6 +7,7 @@ import { getAssetById } from '../../utils/assetStorage'; import { loadRuntimeArtifactBlob } from '../../services/runtimeArtifacts'; import { loadBrowserImportArtifactBlob, parseBrowserImportHref } from '../../services/browserImportArtifacts'; import { LocalFolderError, parseLocalFolderHref, readLocalFolderFile } from '../../services/localFolderSource'; +import { fetchRemoteMediaBlob, shouldUseDesktopNativeFetch } from '../../services/desktopNetwork'; import { workflowMediaStorage } from './storage'; import type { WorkflowArtifactRef, WorkflowNode, WorkflowNodeMetadata, WorkflowNodeType, WorkflowProject } from './types'; @@ -332,9 +333,7 @@ function decodeDataUrl(dataUrl: string): Blob { export async function workflowDataUrlToBlob(dataUrl: string): Promise { const trimmed = dataUrl.trim(); if (/^data:/i.test(trimmed)) return decodeDataUrl(trimmed); - const response = await fetch(dataUrl); - if (!response.ok) throw new Error('无法读取图片结果'); - return response.blob(); + return fetchRemoteMediaBlob(dataUrl); } export function isFetchableMediaHref(href: string) { @@ -369,9 +368,7 @@ export async function loadFallbackMediaBlob(href: string): Promise { if (!isFetchableMediaHref(href)) { throw new Error('无法读取本地媒体引用,请重新导入素材'); } - const response = await fetch(href); - if (!response.ok) throw new Error('无法读取图片文件'); - return response.blob(); + return fetchRemoteMediaBlob(href); } export async function loadWorkflowMediaBlob(storageKey?: string, href?: string, artifactRef?: WorkflowArtifactRef): Promise { @@ -419,7 +416,8 @@ function inspectImageElement(url: string): Promise { export function useWorkflowMediaUrl(storageKey?: string, fallbackHref?: string, artifactRef?: WorkflowArtifactRef) { const artifactKey = artifactRef?.taskId || ''; const mediaKey = storageKey || artifactKey || fallbackHref || ''; - const immediateUrl = storageKey ? null : (fallbackHref && !isFetchableMediaHref(fallbackHref) ? null : fallbackHref || null); + const nativeRemote = shouldUseDesktopNativeFetch(fallbackHref); + const immediateUrl = storageKey || nativeRemote ? null : (fallbackHref && !isFetchableMediaHref(fallbackHref) ? null : fallbackHref || null); const [state, setState] = useState<{ key: string; url: string | null; error: string | null }>({ key: mediaKey, url: immediateUrl, @@ -430,7 +428,7 @@ export function useWorkflowMediaUrl(storageKey?: string, fallbackHref?: string, let active = true; let objectUrl: string | null = null; setState({ key: mediaKey, url: immediateUrl, error: null }); - if (!storageKey && !artifactRef && (!fallbackHref || isFetchableMediaHref(fallbackHref))) return () => undefined; + if (!storageKey && !artifactRef && (!fallbackHref || (isFetchableMediaHref(fallbackHref) && !nativeRemote))) return () => undefined; void loadWorkflowMediaBlob(storageKey, fallbackHref, artifactRef).then(blob => { if (!active) return; if (!blob) { @@ -453,7 +451,7 @@ export function useWorkflowMediaUrl(storageKey?: string, fallbackHref?: string, active = false; if (objectUrl) URL.revokeObjectURL(objectUrl); }; - }, [artifactKey, artifactRef?.mimeType, fallbackHref, mediaKey, storageKey]); + }, [artifactKey, artifactRef?.mimeType, fallbackHref, mediaKey, nativeRemote, storageKey]); return state.key === mediaKey ? state : { url: immediateUrl, error: null }; } diff --git a/package-lock.json b/package-lock.json index 306645d..780fb5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,7 @@ "react-konva": "^19.2.4", "react-router": "^7.18.2", "tippy.js": "^6.3.7", - "undici": "^6.28.0", + "undici": "^6.28.1", "uuid": "^14.0.1", "yaml": "^2.9.0", "zod": "^4.0.17", @@ -5756,9 +5756,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", - "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "funding": [ { "type": "github", @@ -8698,9 +8698,9 @@ } }, "node_modules/undici": { - "version": "6.28.0", - "resolved": "https://mirrors.huaweicloud.com/repository/npm/undici/-/undici-6.28.0.tgz", - "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", + "version": "6.28.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.1.tgz", + "integrity": "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==", "license": "MIT", "engines": { "node": ">=18.17" diff --git a/package.json b/package.json index 76de3ba..3ded9aa 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,7 @@ "react-konva": "^19.2.4", "react-router": "^7.18.2", "tippy.js": "^6.3.7", - "undici": "^6.28.0", + "undici": "^6.28.1", "uuid": "^14.0.1", "yaml": "^2.9.0", "zod": "^4.0.17", diff --git a/services/desktopNetwork.ts b/services/desktopNetwork.ts new file mode 100644 index 0000000..c9351c5 --- /dev/null +++ b/services/desktopNetwork.ts @@ -0,0 +1,77 @@ +import { invoke, isTauri } from '@tauri-apps/api/core'; + +const REMOTE_MEDIA_MIME_HEADER_BYTES = 4; + +function decodeNativeMediaPayload(payload: ArrayBuffer | Uint8Array, fallbackMimeType?: string): Blob { + const bytes = payload instanceof Uint8Array ? payload : new Uint8Array(payload); + if (bytes.byteLength < REMOTE_MEDIA_MIME_HEADER_BYTES) { + throw new Error('桌面媒体响应无效'); + } + + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const mimeLength = view.getUint32(0, true); + const bodyOffset = REMOTE_MEDIA_MIME_HEADER_BYTES + mimeLength; + if (bodyOffset > bytes.byteLength) { + throw new Error('桌面媒体响应无效'); + } + + const mimeBytes = bytes.subarray(REMOTE_MEDIA_MIME_HEADER_BYTES, bodyOffset); + const mimeType = new TextDecoder().decode(mimeBytes).trim() + || fallbackMimeType + || 'application/octet-stream'; + return new Blob([bytes.subarray(bodyOffset)], { type: mimeType }); +} + +export function shouldUseDesktopNativeFetch(url: string | undefined | null): boolean { + return Boolean(url && /^https:\/\//i.test(url) && typeof window !== 'undefined' && isTauri()); +} + +export async function fetchRemoteMediaBlob( + url: string, + options: { signal?: AbortSignal; fallbackMimeType?: string } = {}, +): Promise { + if (options.signal?.aborted) { + throw options.signal.reason || new DOMException('Media fetch aborted', 'AbortError'); + } + + if (shouldUseDesktopNativeFetch(url)) { + const pending = invoke('desktop_fetch_remote_media', { url }) + .then(payload => decodeNativeMediaPayload(payload, options.fallbackMimeType)); + + if (!options.signal) return pending; + + return await Promise.race([ + pending, + new Promise((_, reject) => { + options.signal?.addEventListener('abort', () => { + reject(options.signal?.reason || new DOMException('Media fetch aborted', 'AbortError')); + }, { once: true }); + }), + ]); + } + + const response = await fetch(url, { signal: options.signal }); + if (!response.ok) throw new Error(`无法下载远程媒体 (HTTP ${response.status})`); + return response.blob(); +} + +export async function openRemoteMediaUrl(url: string): Promise { + if (typeof window !== 'undefined' && isTauri() && /^https:\/\//i.test(url)) { + await invoke('desktop_open_remote_url', { url }); + return; + } + + const opened = window.open(url, '_blank', 'noopener,noreferrer'); + if (!opened) throw new Error('无法打开远程媒体链接'); +} + +export function downloadBlob(blob: Blob, filename: string): void { + const objectUrl = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = objectUrl; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + setTimeout(() => URL.revokeObjectURL(objectUrl), 0); +} diff --git a/services/workflowGeneration.ts b/services/workflowGeneration.ts index 03216ff..be855d1 100644 --- a/services/workflowGeneration.ts +++ b/services/workflowGeneration.ts @@ -18,6 +18,7 @@ import { validateWorkflowOperationOutputs } from '../components/workflow/operati import type { ProviderMaterializedReference } from './providerGenerationAdapter'; import { resolveProviderGenerationExtension } from './userScriptProviderAdapter'; import { displayError } from './displayError'; +import { fetchRemoteMediaBlob } from './desktopNetwork'; export interface WorkflowHistoryPayload { name?: string; @@ -148,14 +149,12 @@ async function mediaResult( try { const blob = /^data:/i.test(result.mediaUrl) ? await workflowDataUrlToBlob(result.mediaUrl) - : await (runtime.fetchMedia || (href => fetch(href, { - // 产物下载同时受外层取消(停止生成/新 run 抢占)与 120s 超时约束,避免无限挂起。 + : await (runtime.fetchMedia || (href => fetchRemoteMediaBlob(href, { + // 桌面端通过 Rust IPC 下载远程产物,绕过 WebView CORS;Web 端仍使用浏览器 fetch。 signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(120_000)]) : AbortSignal.timeout(120_000), - }).then(response => { - if (!response.ok) throw new Error('无法下载生成结果'); - return response.blob(); + fallbackMimeType: result.mimeType, })))(result.mediaUrl); const extension = mode === 'video' ? 'mp4' : 'png'; const file = typeof File === 'undefined' ? Object.assign(blob, { name: `workflow-result.${extension}`, lastModified: Date.now() }) as File : new File([blob], `workflow-result.${extension}`, { type: result.mimeType || blob.type, lastModified: Date.now() }); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d1dbb5c..ab00320 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -14,6 +14,7 @@ pub mod deeplink; pub mod errors; pub mod keyring; pub mod managed_agent; +pub mod remote_media; pub mod runtime; pub mod state; @@ -149,6 +150,8 @@ pub fn run() { runtime::browser_import_mark_consumed, runtime::browser_import_artifact_read, managed_agent::managed_agent_connection, + remote_media::desktop_fetch_remote_media, + remote_media::desktop_open_remote_url, ]) .build(tauri::generate_context!()) .expect("error while building Iris"); diff --git a/src-tauri/src/remote_media.rs b/src-tauri/src/remote_media.rs new file mode 100644 index 0000000..61d0cda --- /dev/null +++ b/src-tauri/src/remote_media.rs @@ -0,0 +1,225 @@ +use std::{ + net::{IpAddr, ToSocketAddrs}, + time::Duration, +}; + +use reqwest::{header::CONTENT_TYPE, redirect::Policy, Client, StatusCode}; +use tauri::{ipc::Response, AppHandle}; +use tauri_plugin_opener::OpenerExt; +use url::{Host, Url}; + +const MAX_REMOTE_MEDIA_BYTES: u64 = 256 * 1024 * 1024; +const MAX_REDIRECTS: usize = 5; +const MAX_MIME_BYTES: usize = 512; + +fn is_private_or_special_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(ip) => { + let octets = ip.octets(); + let shared = octets[0] == 100 && (64..=127).contains(&octets[1]); + let reserved = octets[0] >= 240; + ip.is_private() + || ip.is_loopback() + || ip.is_link_local() + || ip.is_unspecified() + || ip.is_multicast() + || shared + || reserved + } + IpAddr::V6(ip) => { + let segments = ip.segments(); + let unique_local = (segments[0] & 0xfe00) == 0xfc00; + let link_local = (segments[0] & 0xffc0) == 0xfe80; + ip.is_loopback() || ip.is_unspecified() || ip.is_multicast() || unique_local || link_local + } + } +} + +fn validate_remote_url(url: &Url) -> Result<(), String> { + if url.scheme() != "https" { + return Err("Remote media downloads require HTTPS".to_owned()); + } + if !url.username().is_empty() || url.password().is_some() { + return Err("Remote media URLs must not contain credentials".to_owned()); + } + if url.port().is_some_and(|port| port != 443) { + return Err("Remote media URLs must use the default HTTPS port".to_owned()); + } + + let host = url + .host() + .ok_or_else(|| "Remote media URL is missing a host".to_owned())?; + + match host { + Host::Ipv4(ip) => { + if is_private_or_special_ip(IpAddr::V4(ip)) { + return Err("Remote media URL resolves to a private or special address".to_owned()); + } + } + Host::Ipv6(ip) => { + if is_private_or_special_ip(IpAddr::V6(ip)) { + return Err("Remote media URL resolves to a private or special address".to_owned()); + } + } + Host::Domain(domain) => { + let normalized = domain.trim_end_matches('.').to_ascii_lowercase(); + if normalized == "localhost" + || normalized.ends_with(".localhost") + || normalized.ends_with(".local") + || normalized.ends_with(".internal") + { + return Err("Remote media URL uses a local-only hostname".to_owned()); + } + + let port = url.port_or_known_default().unwrap_or(443); + let addresses = (domain, port) + .to_socket_addrs() + .map_err(|error| format!("Could not resolve remote media host: {error}"))?; + let mut resolved = false; + for address in addresses { + resolved = true; + if is_private_or_special_ip(address.ip()) { + return Err("Remote media URL resolves to a private or special address".to_owned()); + } + } + if !resolved { + return Err("Remote media host did not resolve to an address".to_owned()); + } + } + } + + Ok(()) +} + +fn frame_media_payload(mime_type: &str, bytes: &[u8]) -> Result, String> { + let mime = if mime_type.trim().is_empty() { + "application/octet-stream" + } else { + mime_type.trim() + }; + let mime_bytes = mime.as_bytes(); + if mime_bytes.len() > MAX_MIME_BYTES { + return Err("Remote media Content-Type is too large".to_owned()); + } + let mime_len = u32::try_from(mime_bytes.len()) + .map_err(|_| "Remote media Content-Type is too large".to_owned())?; + let mut payload = Vec::with_capacity(4 + mime_bytes.len() + bytes.len()); + payload.extend_from_slice(&mime_len.to_le_bytes()); + payload.extend_from_slice(mime_bytes); + payload.extend_from_slice(bytes); + Ok(payload) +} + +#[tauri::command] +pub async fn desktop_fetch_remote_media(url: String) -> Result { + let client = Client::builder() + .redirect(Policy::none()) + .connect_timeout(Duration::from_secs(15)) + .timeout(Duration::from_secs(120)) + .user_agent(concat!("Iris/", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(|error| format!("Could not initialize desktop media client: {error}"))?; + + let mut current = Url::parse(&url) + .map_err(|error| format!("Invalid remote media URL: {error}"))?; + + for redirect_count in 0..=MAX_REDIRECTS { + validate_remote_url(¤t)?; + + let response = client + .get(current.clone()) + .send() + .await + .map_err(|error| format!("Remote media request failed: {error}"))?; + + if response.status().is_redirection() { + if redirect_count == MAX_REDIRECTS { + return Err("Remote media redirected too many times".to_owned()); + } + let location = response + .headers() + .get(reqwest::header::LOCATION) + .and_then(|value| value.to_str().ok()) + .ok_or_else(|| "Remote media redirect is missing Location".to_owned())?; + current = current + .join(location) + .map_err(|error| format!("Invalid remote media redirect: {error}"))?; + continue; + } + + if response.status() != StatusCode::OK && !response.status().is_success() { + return Err(format!( + "Remote media request failed with HTTP {}", + response.status().as_u16() + )); + } + + if response + .content_length() + .is_some_and(|length| length > MAX_REMOTE_MEDIA_BYTES) + { + return Err(format!( + "Remote media exceeds the {} MiB desktop download limit", + MAX_REMOTE_MEDIA_BYTES / 1024 / 1024 + )); + } + + let mime_type = response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.split(';').next()) + .unwrap_or("application/octet-stream") + .trim() + .to_owned(); + + let bytes = response + .bytes() + .await + .map_err(|error| format!("Could not read remote media response: {error}"))?; + + if bytes.len() as u64 > MAX_REMOTE_MEDIA_BYTES { + return Err(format!( + "Remote media exceeds the {} MiB desktop download limit", + MAX_REMOTE_MEDIA_BYTES / 1024 / 1024 + )); + } + + return frame_media_payload(&mime_type, &bytes) + .map(Response::new); + } + + Err("Remote media redirected too many times".to_owned()) +} + +#[tauri::command] +pub fn desktop_open_remote_url(app: AppHandle, url: String) -> Result<(), String> { + let parsed = Url::parse(&url) + .map_err(|error| format!("Invalid remote media URL: {error}"))?; + validate_remote_url(&parsed)?; + app.opener() + .open_url(parsed.as_str(), None::<&str>) + .map_err(|error| format!("Could not open remote media URL: {error}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_non_https_and_private_targets() { + assert!(validate_remote_url(&Url::parse("http://example.com/video.mp4").unwrap()).is_err()); + assert!(validate_remote_url(&Url::parse("https://127.0.0.1/video.mp4").unwrap()).is_err()); + assert!(validate_remote_url(&Url::parse("https://10.0.0.1/video.mp4").unwrap()).is_err()); + assert!(validate_remote_url(&Url::parse("https://[::1]/video.mp4").unwrap()).is_err()); + assert!(validate_remote_url(&Url::parse("https://localhost/video.mp4").unwrap()).is_err()); + } + + #[test] + fn frames_mime_type_and_bytes_for_raw_ipc() { + let framed = frame_media_payload("video/mp4", &[1, 2, 3, 4]).unwrap(); + let mime_len = u32::from_le_bytes(framed[..4].try_into().unwrap()) as usize; + assert_eq!(&framed[4..4 + mime_len], b"video/mp4"); + assert_eq!(&framed[4 + mime_len..], &[1, 2, 3, 4]); + } +} diff --git a/tests/desktopNetwork.test.ts b/tests/desktopNetwork.test.ts new file mode 100644 index 0000000..57de1e5 --- /dev/null +++ b/tests/desktopNetwork.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(), + isTauri: vi.fn(() => true), +})); + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: mocks.invoke, + isTauri: mocks.isTauri, +})); + +import { + fetchRemoteMediaBlob, + openRemoteMediaUrl, + shouldUseDesktopNativeFetch, +} from '../services/desktopNetwork'; + +function framedPayload(mimeType: string, body: number[]) { + const mime = new TextEncoder().encode(mimeType); + const payload = new Uint8Array(4 + mime.length + body.length); + new DataView(payload.buffer).setUint32(0, mime.length, true); + payload.set(mime, 4); + payload.set(body, 4 + mime.length); + return payload.buffer; +} + +describe('desktopNetwork', () => { + beforeEach(() => { + mocks.invoke.mockReset(); + mocks.isTauri.mockReset(); + mocks.isTauri.mockReturnValue(true); + }); + + it('uses raw Tauri IPC for HTTPS media in the desktop app', async () => { + mocks.invoke.mockResolvedValueOnce(framedPayload('video/mp4', [1, 2, 3])); + + const blob = await fetchRemoteMediaBlob('https://cdn.example.com/result.mp4'); + + expect(mocks.invoke).toHaveBeenCalledWith('desktop_fetch_remote_media', { + url: 'https://cdn.example.com/result.mp4', + }); + expect(blob.type).toBe('video/mp4'); + const body = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(new Uint8Array(reader.result as ArrayBuffer)); + reader.onerror = () => reject(reader.error); + reader.readAsArrayBuffer(blob); + }); + expect([...body]).toEqual([1, 2, 3]); + }); + + it('does not route blob/data URLs through the native remote downloader', () => { + expect(shouldUseDesktopNativeFetch('blob:https://example.com/id')).toBe(false); + expect(shouldUseDesktopNativeFetch('data:image/png;base64,AA==')).toBe(false); + expect(shouldUseDesktopNativeFetch('https://example.com/file.png')).toBe(true); + }); + + it('opens HTTPS fallback URLs through the desktop opener command', async () => { + mocks.invoke.mockResolvedValueOnce(undefined); + + await openRemoteMediaUrl('https://cdn.example.com/result.mp4'); + + expect(mocks.invoke).toHaveBeenCalledWith('desktop_open_remote_url', { + url: 'https://cdn.example.com/result.mp4', + }); + }); + + it('rejects malformed native media frames', async () => { + mocks.invoke.mockResolvedValueOnce(new Uint8Array([1, 2]).buffer); + + await expect(fetchRemoteMediaBlob('https://cdn.example.com/result.mp4')) + .rejects.toThrow('桌面媒体响应无效'); + }); +});