experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(download): sanitize filenames + surface failures via notification

Two concrete fixes for "downloads often do not work":

1. Sanitize the filename before path.join — replace path separators,
control chars, and other filesystem-hostile characters with
underscore; substitute timestamped fallback for empty / "." / ".."
names; cap to 200 chars while preserving the extension. Defends
against silent setSavePath errors on edge-case Content-Disposition
names.

2. On download `done` with state !== 'completed', show a "Download
Failed" notification with the cancelled / interrupted reason.
Previously the failure was console.log-only and looked indistinguishable
from "didn't start" to the user — feeding the "often do not work"
perception even when downloads were cleanly cancelled.

Adds unit test coverage for sanitizeDownloadFilename (mirrored helper
in session-partition.test.ts, matching the pattern already in use for
resolveUniqueDownloadPath).

+91 -1
+57
backend/electron/session-partition.test.ts
··· 111 111 assert.strictEqual(result, path.join(tmpDir, 'archive.tar-1.gz')); 112 112 }); 113 113 }); 114 + 115 + // ─── sanitizeDownloadFilename (mirrored from session-partition.ts) ── 116 + // Must stay in sync with the implementation in session-partition.ts. 117 + 118 + function sanitizeDownloadFilename(raw: string): string { 119 + // eslint-disable-next-line no-control-regex 120 + let cleaned = (raw || '').replace(/[\x00-\x1f/\\:*?"<>|]/g, '_').trim(); 121 + if (cleaned === '' || cleaned === '.' || cleaned === '..') { 122 + cleaned = `download-${Date.now()}`; 123 + } 124 + if (cleaned.length > 200) { 125 + const ext = path.extname(cleaned); 126 + cleaned = cleaned.slice(0, 200 - ext.length) + ext; 127 + } 128 + return cleaned; 129 + } 130 + 131 + describe('sanitizeDownloadFilename', () => { 132 + it('preserves a normal filename', () => { 133 + assert.strictEqual(sanitizeDownloadFilename('photo.jpg'), 'photo.jpg'); 134 + }); 135 + 136 + it('preserves multi-dot extensions', () => { 137 + assert.strictEqual(sanitizeDownloadFilename('archive.tar.gz'), 'archive.tar.gz'); 138 + }); 139 + 140 + it('replaces filesystem-hostile chars with underscore', () => { 141 + assert.strictEqual( 142 + sanitizeDownloadFilename('a/b\\c:d*e?f"g<h>i|j.txt'), 143 + 'a_b_c_d_e_f_g_h_i_j.txt', 144 + ); 145 + }); 146 + 147 + it('replaces control characters with underscore', () => { 148 + assert.strictEqual(sanitizeDownloadFilename('hello\x00world\x1f.bin'), 'hello_world_.bin'); 149 + }); 150 + 151 + it('replaces empty input with timestamped fallback', () => { 152 + assert.match(sanitizeDownloadFilename(''), /^download-\d+$/); 153 + }); 154 + 155 + it('replaces "." and ".." with timestamped fallback', () => { 156 + assert.match(sanitizeDownloadFilename('.'), /^download-\d+$/); 157 + assert.match(sanitizeDownloadFilename('..'), /^download-\d+$/); 158 + }); 159 + 160 + it('truncates absurdly long names while preserving the extension', () => { 161 + const long = 'a'.repeat(500) + '.zip'; 162 + const out = sanitizeDownloadFilename(long); 163 + assert.ok(out.length <= 200, `expected <=200 got ${out.length}`); 164 + assert.ok(out.endsWith('.zip'), `expected .zip extension got ${out}`); 165 + }); 166 + 167 + it('trims surrounding whitespace', () => { 168 + assert.strictEqual(sanitizeDownloadFilename(' hello.txt '), 'hello.txt'); 169 + }); 170 + });
+34 -1
backend/electron/session-partition.ts
··· 280 280 _registerDownloadHandlerImpl(ses); 281 281 } 282 282 283 + /** 284 + * Strip path separators and other filesystem-hostile characters from a 285 + * download filename. Chromium already drops `/` and `\` from suggested 286 + * names, but we belt-and-suspender against null bytes, control chars, 287 + * and absurdly long names that would error on `setSavePath`. 288 + */ 289 + export function sanitizeDownloadFilename(raw: string): string { 290 + // eslint-disable-next-line no-control-regex 291 + let cleaned = (raw || '').replace(/[\x00-\x1f/\\:*?"<>|]/g, '_').trim(); 292 + if (cleaned === '' || cleaned === '.' || cleaned === '..') { 293 + cleaned = `download-${Date.now()}`; 294 + } 295 + // 200 chars leaves headroom for collision suffixes + path components. 296 + if (cleaned.length > 200) { 297 + const ext = path.extname(cleaned); 298 + cleaned = cleaned.slice(0, 200 - ext.length) + ext; 299 + } 300 + return cleaned; 301 + } 302 + 283 303 function _registerDownloadHandlerImpl(ses: Session): void { 284 304 ses.on('will-download', (_event, item, webContents) => { 285 - const filename = item.getFilename(); 305 + const rawFilename = item.getFilename(); 306 + const filename = sanitizeDownloadFilename(rawFilename); 286 307 const desiredPath = path.join(app.getPath('downloads'), filename); 287 308 const savePath = resolveUniqueDownloadPath(desiredPath); 288 309 item.setSavePath(savePath); ··· 367 388 }); 368 389 notification.show(); 369 390 } else { 391 + // Surface failure so the user knows the download didn't silently 392 + // succeed. State is 'cancelled' or 'interrupted' here. 370 393 console.log('[download] Failed/cancelled:', filename, state); 394 + try { 395 + const reason = state === 'cancelled' ? 'cancelled' : 'interrupted'; 396 + new Notification({ 397 + title: 'Download Failed', 398 + body: `${filename} (${reason})`, 399 + silent: false, 400 + }).show(); 401 + } catch (err) { 402 + console.error('[download] Failed to surface failure notification:', err); 403 + } 371 404 } 372 405 }); 373 406 });