diff --git a/src/main/downloadFile.ts b/src/main/downloadFile.ts index 1fa9ab5c..02aa968d 100644 --- a/src/main/downloadFile.ts +++ b/src/main/downloadFile.ts @@ -1,11 +1,12 @@ import * as fs from 'fs-extra'; -import https from 'https'; -import http from 'http'; -import { URL } from 'url'; +import { net } from 'electron'; +import { Readable, Transform } from 'stream'; +import { pipeline } from 'stream/promises'; /** - * Promise based download file method - * Uses native Node.js http/https modules to preserve S3 pre-signed URLs exactly as-is - * See: https://ourcodeworld.com/articles/read/228/how-to-download-a-webfile-with-electron-save-it-and-show-download-progress + * Promise based download file method. + * Uses Electron net.fetch (Chromium TLS/proxy) so corporate SSL inspection + * and OS trust stores work — Node https hits UNABLE_TO_VERIFY_LEAF_SIGNATURE. + * Pass the original URL string so S3 pre-signed query strings stay exact. */ const downloadMap = new Map(); @@ -16,121 +17,73 @@ export const downloadClose = (token: string) => { if (downloadMap.has(token)) downloadMap.delete(token); }; -export const downloadFile = ( +export const downloadFile = async ( url: string, localPath: string, token?: string -) => { - return new Promise((resolve, reject) => { - let key: string | undefined = undefined; - let received_bytes = 0; - let total_bytes = 0; - let error: Error | null = null; +): Promise => { + new URL(url); // validate early - try { - // Parse URL to get components, but use the original URL string for the request - // to preserve the exact path and query string (important for S3 signatures) - const urlObj = new URL(url); - const isHttps = urlObj.protocol === 'https:'; - const client = isHttps ? https : http; - - // Use the original URL string to preserve exact path encoding - // This is critical for S3 pre-signed URLs where the signature depends on the exact URL - const options = { - hostname: urlObj.hostname, - port: urlObj.port || (isHttps ? 443 : 80), - path: urlObj.pathname + urlObj.search, // Preserve path and query exactly - method: 'GET', - headers: { - 'User-Agent': 'Audio-Project-Manager', - }, - }; - - const out = fs.createWriteStream(localPath); - const req = client.request(options, (res) => { - if (res.statusCode && res.statusCode >= 400) { - error = new Error( - `HTTP ${res.statusCode}: ${res.statusMessage}` - ) as any; - res.resume(); // Consume response to free up memory - out.destroy(); - if (key) { - const status = downloadMap.get(key); - downloadMap.set(key, status ? { ...status, error } : { error }); - } - reject(error); - return; - } - - total_bytes = parseInt(res.headers['content-length'] || '0', 10); - if (token) { - downloadMap.set(token, { - received: 0, - total: total_bytes, - error: error, - }); - key = token; - } - - res.on('data', (chunk: Buffer) => { - received_bytes += chunk.length; - if (key) { - const status = downloadMap.get(key); - downloadMap.set( - key, - status - ? { ...status, received: received_bytes } - : { received: received_bytes } - ); - } - }); - - res.on('end', () => { - out.end(); - }); - - res.pipe(out); - }); + const response = await net.fetch(url, { + method: 'GET', + headers: { 'User-Agent': 'Audio-Project-Manager' }, + }); - req.on('error', (err: Error) => { - error = err; - out.destroy(); - if (key) { - const status = downloadMap.get(key); - downloadMap.set(key, status ? { ...status, error } : { error }); - } - reject(error); - }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + if (!response.body) { + throw new Error('HTTP response has no body'); + } - out.on('finish', () => { - let err = error; - if (key) { - err = downloadMap.get(key)?.error; - } - if (err) { - fs.unlink(localPath).catch(() => { - // Ignore unlink errors - }); - reject(err); - } else { - resolve(void 0); - } - }); + const total_bytes = parseInt( + response.headers.get('content-length') || '0', + 10 + ); + if (token) { + downloadMap.set(token, { + received: 0, + total: total_bytes, + error: null, + }); + } - out.on('error', (err: Error) => { - error = err; - req.destroy(); - if (key) { - const status = downloadMap.get(key); - downloadMap.set(key, status ? { ...status, error } : { error }); - } - reject(error); - }); + let received_bytes = 0; + const counter = new Transform({ + transform(chunk, _enc, cb) { + received_bytes += chunk.length; + if (token) { + const status = downloadMap.get(token); + downloadMap.set( + token, + status + ? { ...status, received: received_bytes } + : { + received: received_bytes, + total: total_bytes, + error: null, + } + ); + } + cb(null, chunk); + }, + }); - req.end(); - } catch (err) { - const error = err as Error; - reject(error); + try { + const nodeReadable = Readable.fromWeb( + response.body as import('stream/web').ReadableStream + ); + await pipeline(nodeReadable, counter, fs.createWriteStream(localPath)); + } catch (err) { + if (token) { + const status = downloadMap.get(token); + downloadMap.set( + token, + status ? { ...status, error: err } : { error: err } + ); } - }); + // Unlink before rethrow so renderer exists() cannot race a leftover file. + await fs.unlink(localPath).catch(() => undefined); + throw err; + } }; diff --git a/src/main/ipcMethods.ts b/src/main/ipcMethods.ts index e5d882ae..e5a59433 100644 --- a/src/main/ipcMethods.ts +++ b/src/main/ipcMethods.ts @@ -411,7 +411,11 @@ export function ipcMethods(): void { await downloadFile(url, localFile); return; } catch (err) { - return JSON.stringify(err); + const e = err as Error & { code?: string }; + return JSON.stringify({ + message: e?.message ?? String(err), + ...(e?.code ? { code: e.code } : {}), + }); } }); diff --git a/src/renderer/src/burrito/useBurritoNavigation.ts b/src/renderer/src/burrito/useBurritoNavigation.ts index 1d0d094c..fb76416d 100644 --- a/src/renderer/src/burrito/useBurritoNavigation.ts +++ b/src/renderer/src/burrito/useBurritoNavigation.ts @@ -336,7 +336,8 @@ export const useBurritoNavigation = (teamId: string) => { await dataPath(content, PathType.MEDIA, local); const mediaName = local.localname; if (!(await ipc?.exists(mediaName))) { - await ipc?.downloadFile(content, mediaName); + const err = await ipc?.downloadFile(content, mediaName); + if (err || !(await ipc?.exists(mediaName))) return false; } await ipc?.copyFile(mediaName, destPath); return true; diff --git a/src/renderer/src/components/HelpMenu.tsx b/src/renderer/src/components/HelpMenu.tsx index c9e8f304..f256ef54 100644 --- a/src/renderer/src/components/HelpMenu.tsx +++ b/src/renderer/src/components/HelpMenu.tsx @@ -88,7 +88,14 @@ export function HelpMenu(props: IProps) { const localPath = loc ? path.join(folder, 'help', name) : await dataPath(name, PathType.ZIP); - if (!loc) await ipc?.downloadFile(url, localPath); + if (!loc) { + const err = await ipc?.downloadFile(url, localPath); + if (err || !(await ipc?.exists(localPath))) { + showMessage(`Failed to download ${name}`); + setAnchorEl(null); + return; + } + } launch(localPath, false); setAnchorEl(null); if (action) action('Download'); diff --git a/src/renderer/src/utils/tryDownload.ts b/src/renderer/src/utils/tryDownload.ts index eff577aa..ffa7e165 100644 --- a/src/renderer/src/utils/tryDownload.ts +++ b/src/renderer/src/utils/tryDownload.ts @@ -16,7 +16,11 @@ export const tryDownload = async (url: string): Promise => { try { ipc?.createFolder(path.dirname(local.localname)); console.log('downloading', local.localname, url); - await ipc?.downloadFile(url, local.localname); + const err = await ipc?.downloadFile(url, local.localname); + if (err) { + console.log('error', err); + return { ok: false, path: url }; + } if (await ipc?.exists(local.localname)) { return { ok: true, path: local.localname }; }