Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 67 additions & 114 deletions src/main/downloadFile.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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<void> => {
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;
}
};
6 changes: 5 additions & 1 deletion src/main/ipcMethods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}),
});
}
});

Expand Down
3 changes: 2 additions & 1 deletion src/renderer/src/burrito/useBurritoNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 8 additions & 1 deletion src/renderer/src/components/HelpMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
6 changes: 5 additions & 1 deletion src/renderer/src/utils/tryDownload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ export const tryDownload = async (url: string): Promise<TryDownloadResult> => {
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 };
}
Expand Down
Loading