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('桌面媒体响应无效');
+ });
+});