Skip to content

Align desktop download contract for missing model UI#1694

Open
benceruleanlu wants to merge 5 commits into
mainfrom
bl/missing-model-download-contract
Open

Align desktop download contract for missing model UI#1694
benceruleanlu wants to merge 5 commits into
mainfrom
bl/missing-model-download-contract

Conversation

@benceruleanlu
Copy link
Copy Markdown
Member

@benceruleanlu benceruleanlu commented Apr 17, 2026

Summary

  • add a desktop-issued downloadId to every DownloadState, using the final normalized save path as lifecycle identity
  • return { ok, download, error? } from startDownload() so the renderer can track the exact row immediately
  • key DownloadManager by downloadId, with FIFO URL correlation for Electron will-download and URL fallback for legacy pause/resume/cancel callers
  • keep deprecated state, receivedBytes, totalBytes, and isPaused fields for existing consumers

Context

Desktop missing-model downloads were starting successfully, but the frontend could not reliably reflect lifecycle state because URL was doing two jobs: source metadata and lifecycle identity. That breaks when multiple missing-model rows share a source URL or when the renderer needs to bind a started download to one specific target file. This makes desktop authoritative for download identity and state while preserving the old fields and URL fallback for bridge compatibility.

Notes

Summary by CodeRabbit

  • Improvements

    • More accurate, granular download progress and byte-count reporting.
    • Pause/resume/cancel commands now target canonical download IDs for consistent control.
    • Start now returns a structured result including the download ID and final save path.
    • Existing downloads are detected and reported immediately to avoid duplicates; interrupted/restarted downloads show consistent final statuses.
  • Tests

    • Coverage expanded to validate the updated download lifecycle, progress reporting, resume/restart and retrieval behaviors.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 17, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 4ae953cc-46b6-478a-b612-e9130305852b

📥 Commits

Reviewing files that changed from the base of the PR and between 95abf67 and 961b390.

📒 Files selected for processing (2)
  • src/models/DownloadManager.ts
  • tests/unit/models/DownloadManager.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • tests/unit/models/DownloadManager.test.ts
  • src/models/DownloadManager.ts

📝 Walkthrough

Walkthrough

Downloads were rekeyed by a new downloadId, download records gained progress/status/byte fields and an optional message, startDownload now returns a StartDownloadResult union, pause/resume/cancel now accept downloadId, IPC/preload typings were updated, and reporting/queries now use the new shapes and include all stored entries.

Changes

Download Manager lifecycle and API

Layer / File(s) Summary
Data shape / types
src/models/DownloadManager.ts
Download and DownloadState now include downloadId, progress, status, optional message, receivedBytes, totalBytes; legacy aliases (state, isPaused, receivedBytes, totalBytes) preserved on DownloadState. Added exported StartDownloadResult.
will-download handler / event updates
src/models/DownloadManager.ts
will-download matches pending URL→downloadId, attaches DownloadItem to the persisted Download, updates progress/status/bytes on updated events, finalizes COMPLETED/CANCELLED/ERROR on done, clears item, and reports via toDownloadState.
Core start logic
src/models/DownloadManager.ts
startDownload(url,directoryPath,filename) now returns StartDownloadResult; it derives/persists downloadId, enqueues PENDING entries, handles existing target files by creating COMPLETED entries, resumes PAUSED entries, and removes stale CANCELLED/ERROR entries before starting.
Control methods (pause/resume/cancel)
src/models/DownloadManager.ts
cancelDownload/pauseDownload/resumeDownload accept downloadIdOrUrl; if an Electron item exists they invoke item.cancel()/pause()/resume() (cancel relies on done for cleanup), otherwise they update/remove in-memory entries and may restart downloads when resumption isn’t possible.
State mapping & reporting
src/models/DownloadManager.ts
Added toDownloadState, calculateProgress, helpers for pending URL→downloadId matching; getAllDownloads() returns all stored entries via toDownloadState; reportProgress now accepts a DownloadState directly and emits IPC_CHANNELS.DOWNLOAD_PROGRESS.
IPC surface & preload wiring
src/infrastructure/ipcChannels.ts, src/preload.ts, src/main_types.ts
IPC typings updated: START_DOWNLOAD returns StartDownloadResult; PAUSE_DOWNLOAD/RESUME_DOWNLOAD/CANCEL_DOWNLOAD accept downloadId: string. preload methods now reflect StartDownloadResult and downloadId parameters. StartDownloadResult re-exported in main_types.
Tests / integration updates
tests/unit/models/DownloadManager.test.ts, tests/integration/post-install/downloadManager.spec.ts
Unit tests refactored to new helpers and assertions for canonical downloadId-based snapshots, emitted DOWNLOAD_PROGRESS, resume/restart flows, and getAllDownloads() snapshots. Integration test updated to assert StartDownloadResult.ok and returned downloadId/savePath.

Sequence Diagram(s)

sequenceDiagram
    participant Renderer
    participant IPC
    participant Main as DownloadManager (Main)
    participant Electron as BrowserWindow / DownloadItem
    participant FS as FileSystem

    Renderer->>IPC: START_DOWNLOAD {url, path, filename}
    IPC->>Main: startDownload(...)
    Main->>FS: validate/create target directory, check existing file
    alt file exists
        Main-->>IPC: {ok: true, download: COMPLETED DownloadState}
        IPC-->>Renderer: StartDownloadResult
    else new or resume
        Main->>Electron: webContents.downloadURL(url)
        Main->>Main: enqueue pending URL→downloadId (PENDING)
        Main-->>IPC: {ok: true, download: pending DownloadState}
        IPC-->>Renderer: StartDownloadResult (PENDING)
        Electron->>Main: will-download (provides DownloadItem)
        Main->>Main: bind item to persisted Download
        loop during download
            Electron->>Main: item.updated (progress/paused)
            Main-->>IPC: DOWNLOAD_PROGRESS (DownloadState)
            IPC-->>Renderer: DOWNLOAD_PROGRESS
        end
        Electron->>Main: item.done (completed/cancelled/error)
        Main->>FS: rename temp→final on completed / cleanup on cancel/error
        Main-->>IPC: DOWNLOAD_PROGRESS (final DownloadState)
        IPC-->>Renderer: DOWNLOAD_PROGRESS
    end
Loading

"I dug a tiny tunnel through bytes and files,
I planted a badge that says 'downloadId' in smiles,
I hopped when progress ticked and paused my paws,
Resumed the race, and cleaned up the claws,
🐇✨ — a rabbit's small cheer for robust download miles."

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'Align desktop download contract for missing model UI' is somewhat vague and does not clearly convey the main changes, which involve adding downloadId, restructuring DownloadState, and changing return types for download operations. Consider a more specific title such as 'Add downloadId to DownloadState and restructure download contract' or 'Introduce downloadId-based download lifecycle tracking for reliable UI binding' to better reflect the core changes.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch bl/missing-model-download-contract

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 7/8 reviews remaining, refill in 7 minutes and 30 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

@benceruleanlu benceruleanlu changed the title [codex] Align desktop download contract for missing model UI Align desktop download contract for missing model UI May 3, 2026
@benceruleanlu benceruleanlu marked this pull request as ready for review May 3, 2026 10:48
@benceruleanlu benceruleanlu requested review from a team as code owners May 3, 2026 10:48
@dosubot dosubot Bot added the size:L This PR changes 100-499 lines, ignoring generated files. label May 3, 2026
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 610c00ae81

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/models/DownloadManager.ts Outdated
Comment on lines +204 to +205
} else {
return true;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Allow restarting completed downloads after file removal

startDownload now keeps completed entries in downloads, but this branch returns early for any existing status other than paused/error/cancelled. After a model is deleted in the same session (e.g., via deleteModel, which does not clear downloads), calling startDownload for the same URL hits this return true path and never reaches session.defaultSession.downloadURL(url), so the download cannot be restarted until app restart.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/models/DownloadManager.ts (1)

98-110: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't emit COMPLETED after the final move fails.

If renameSync throws, this branch still reports status: COMPLETED with progress: 1, so the renderer can hydrate a model that never actually landed at savePath.

Proposed fix
         if (state === 'completed') {
           try {
             fs.renameSync(download.tempPath, download.savePath);
             log.info(`Successfully renamed ${download.tempPath} to ${download.savePath}`);
+            download.item = null;
+            download.progress = 1;
+            download.status = DownloadStatus.COMPLETED;
+            download.message = undefined;
           } catch (error) {
             log.error('Failed to rename downloaded file. Deleting temp file.', error);
             fs.unlinkSync(download.tempPath);
+            download.item = null;
+            download.progress = this.calculateProgress(receivedBytes, totalBytes);
+            download.status = DownloadStatus.ERROR;
+            download.message = 'Failed to move downloaded file into place';
           }
-          download.item = null;
-          download.progress = 1;
-          download.status = DownloadStatus.COMPLETED;
-          download.message = undefined;
           this.reportProgress(this.toDownloadState(download));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/models/DownloadManager.ts` around lines 98 - 110, The rename failure
branch currently still marks the download as completed and calls reportProgress;
modify the DownloadManager code so the post-success updates (setting
download.item = null, download.progress = 1, download.status =
DownloadStatus.COMPLETED, download.message = undefined and calling
this.reportProgress(this.toDownloadState(download))) occur only inside the try
after fs.renameSync succeeds; in the catch block do not set COMPLETED or call
reportProgress as completed—instead set download.status to a failure state
(e.g., DownloadStatus.FAILED), set download.message to the caught error message,
ensure the temp file is unlinked, and call
reportProgress(this.toDownloadState(download)) with the failure state so the
renderer is not told the file landed at savePath (refer to symbols:
DownloadManager, download, reportProgress, toDownloadState, DownloadStatus).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/models/DownloadManager.ts`:
- Around line 170-188: The existingCompletedDownload reuse path only updates
terminal fields and can leak stale metadata; update all location and byte fields
before storing/reporting: set existingCompletedDownload.directoryPath =
normalizedDirectoryPath, existingCompletedDownload.savePath = localSavePath,
existingCompletedDownload.tempPath = this.getTempPath(filename,
normalizedDirectoryPath), existingCompletedDownload.filename = filename, and
refresh receivedBytes and totalBytes (reset or set to current values as
appropriate) and item = null, then this.downloads.set(url,
existingCompletedDownload) and
reportProgress(this.toDownloadState(existingCompletedDownload)); apply these
changes around the existingCompletedDownload handling in DownloadManager
(references: downloads, existingCompletedDownload, getTempPath, toDownloadState,
reportProgress, DownloadStatus.COMPLETED).

---

Outside diff comments:
In `@src/models/DownloadManager.ts`:
- Around line 98-110: The rename failure branch currently still marks the
download as completed and calls reportProgress; modify the DownloadManager code
so the post-success updates (setting download.item = null, download.progress =
1, download.status = DownloadStatus.COMPLETED, download.message = undefined and
calling this.reportProgress(this.toDownloadState(download))) occur only inside
the try after fs.renameSync succeeds; in the catch block do not set COMPLETED or
call reportProgress as completed—instead set download.status to a failure state
(e.g., DownloadStatus.FAILED), set download.message to the caught error message,
ensure the temp file is unlinked, and call
reportProgress(this.toDownloadState(download)) with the failure state so the
renderer is not told the file landed at savePath (refer to symbols:
DownloadManager, download, reportProgress, toDownloadState, DownloadStatus).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5470701d-950e-4182-8ecc-74b38b584fe2

📥 Commits

Reviewing files that changed from the base of the PR and between 250d690 and 610c00a.

📒 Files selected for processing (3)
  • src/models/DownloadManager.ts
  • src/preload.ts
  • tests/unit/models/DownloadManager.test.ts

Comment thread src/models/DownloadManager.ts Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/models/DownloadManager.ts (1)

107-118: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Rename failure is reported as COMPLETED.

If temp→final rename fails, the code still sets status = COMPLETED and progress = 1. That reports a successful terminal state even though persistence failed.

Suggested fix
         if (state === 'completed') {
           try {
             fs.renameSync(download.tempPath, download.savePath);
             log.info(`Successfully renamed ${download.tempPath} to ${download.savePath}`);
           } catch (error) {
             log.error('Failed to rename downloaded file. Deleting temp file.', error);
-            fs.unlinkSync(download.tempPath);
+            try {
+              fs.unlinkSync(download.tempPath);
+            } catch (unlinkError) {
+              log.error('Failed to delete temp file after rename failure', unlinkError);
+            }
+            download.item = null;
+            download.progress = this.calculateProgress(receivedBytes, totalBytes);
+            download.status = DownloadStatus.ERROR;
+            download.message = 'Failed to finalize downloaded file';
+            this.reportProgress(this.toDownloadState(download));
+            return;
           }
           download.item = null;
           download.progress = 1;
           download.status = DownloadStatus.COMPLETED;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/models/DownloadManager.ts` around lines 107 - 118, The rename failure
handling currently still marks the download as completed; update the catch block
around fs.renameSync(download.tempPath, download.savePath) so that on error you
set download.status to a non-completed terminal state (e.g.,
DownloadStatus.FAILED), set download.progress appropriately (do not set to 1),
populate download.message with the error message, ensure the temp file is
removed (fs.unlinkSync) only after handling the error, and call
this.reportProgress(this.toDownloadState(download)) to report the failure; keep
the success path unchanged and only set download.item = null and download.status
= DownloadStatus.COMPLETED when renameSync succeeds.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/models/DownloadManager.ts`:
- Around line 219-222: The paused-restart path in startDownload returns the
stale existingDownload snapshot after calling resumeDownload; change
startDownload to await the outcome of resumeDownload (or retrieve the
new/updated download record after resume) and return this fresh state via
toDownloadState so the caller gets the current pending/download entry (do the
same fix for the analogous block that handles paused items around the later
branch). Ensure you use the resumeDownload result or re-fetch the download by
downloadId rather than returning existingDownload directly, and preserve error
handling if resume fails.

In `@tests/integration/post-install/downloadManager.spec.ts`:
- Around line 79-81: The test fails cross-platform because downloadId is
canonicalized (lowercased on Windows); update the assertion to normalize both
sides the same way before comparing: transform expectedFilePath the same way
startResult.download.downloadId is canonicalized (e.g., path.normalize and
lowercasing on Windows) and then assert equality against
startResult.download.downloadId (or compare both lowercased). Reference
startResult.download.downloadId and expectedFilePath when making the change so
the test compares canonicalized values instead of raw strings.

---

Outside diff comments:
In `@src/models/DownloadManager.ts`:
- Around line 107-118: The rename failure handling currently still marks the
download as completed; update the catch block around
fs.renameSync(download.tempPath, download.savePath) so that on error you set
download.status to a non-completed terminal state (e.g., DownloadStatus.FAILED),
set download.progress appropriately (do not set to 1), populate download.message
with the error message, ensure the temp file is removed (fs.unlinkSync) only
after handling the error, and call
this.reportProgress(this.toDownloadState(download)) to report the failure; keep
the success path unchanged and only set download.item = null and download.status
= DownloadStatus.COMPLETED when renameSync succeeds.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 0c5b89d1-50ab-4e73-bbab-e3aa5bdcb543

📥 Commits

Reviewing files that changed from the base of the PR and between 610c00a and dfae1a5.

📒 Files selected for processing (6)
  • src/infrastructure/ipcChannels.ts
  • src/main_types.ts
  • src/models/DownloadManager.ts
  • src/preload.ts
  • tests/integration/post-install/downloadManager.spec.ts
  • tests/unit/models/DownloadManager.test.ts
✅ Files skipped from review due to trivial changes (1)
  • src/main_types.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/preload.ts
  • tests/unit/models/DownloadManager.test.ts

Comment thread src/models/DownloadManager.ts
Comment on lines +79 to +81
expect(startResult.ok).toBe(true);
expect(startResult.download.downloadId).toBe(expectedFilePath);
expect(startResult.download.savePath).toBe(expectedFilePath);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Normalize expected downloadId before asserting.

downloadId is canonicalized (lowercased on Windows), so a direct equality with expectedFilePath can fail cross-platform even when behavior is correct.

Suggested fix
+      const expectedDownloadId = process.platform === 'win32' ? expectedFilePath.toLowerCase() : expectedFilePath;
       expect(startResult.ok).toBe(true);
-      expect(startResult.download.downloadId).toBe(expectedFilePath);
+      expect(startResult.download.downloadId).toBe(expectedDownloadId);
       expect(startResult.download.savePath).toBe(expectedFilePath);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
expect(startResult.ok).toBe(true);
expect(startResult.download.downloadId).toBe(expectedFilePath);
expect(startResult.download.savePath).toBe(expectedFilePath);
const expectedDownloadId = process.platform === 'win32' ? expectedFilePath.toLowerCase() : expectedFilePath;
expect(startResult.ok).toBe(true);
expect(startResult.download.downloadId).toBe(expectedDownloadId);
expect(startResult.download.savePath).toBe(expectedFilePath);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/integration/post-install/downloadManager.spec.ts` around lines 79 - 81,
The test fails cross-platform because downloadId is canonicalized (lowercased on
Windows); update the assertion to normalize both sides the same way before
comparing: transform expectedFilePath the same way
startResult.download.downloadId is canonicalized (e.g., path.normalize and
lowercasing on Windows) and then assert equality against
startResult.download.downloadId (or compare both lowercased). Reference
startResult.download.downloadId and expectedFilePath when making the change so
the test compares canonicalized values instead of raw strings.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/models/DownloadManager.ts (1)

106-118: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Rename failure is reported as COMPLETED

If renameSync fails, the code deletes temp data but still sets status = COMPLETED and progress = 1. That reports success even when finalization failed.

Suggested fix
         if (state === 'completed') {
           try {
             fs.renameSync(download.tempPath, download.savePath);
             log.info(`Successfully renamed ${download.tempPath} to ${download.savePath}`);
           } catch (error) {
             log.error('Failed to rename downloaded file. Deleting temp file.', error);
-            fs.unlinkSync(download.tempPath);
+            this.deleteTempFile(download.tempPath);
+            download.item = null;
+            download.progress = this.calculateProgress(receivedBytes, totalBytes);
+            download.status = DownloadStatus.ERROR;
+            download.message = 'Failed to finalize downloaded file';
+            this.reportProgress(this.toDownloadState(download));
+            return;
           }
           download.item = null;
           download.progress = 1;
           download.status = DownloadStatus.COMPLETED;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/models/DownloadManager.ts` around lines 106 - 118, The catch block in
DownloadManager (where fs.renameSync(download.tempPath, download.savePath) is
attempted) currently deletes the temp file but still sets download.progress = 1
and download.status = DownloadStatus.COMPLETED and reports success; change the
catch to mark the download as failed instead: set download.status =
DownloadStatus.FAILED, set download.progress to a value < 1 (e.g., 0 or leave
current progress), populate download.message with the caught error message, do
not set download.item to null/clear successful fields, ensure
fs.unlinkSync(download.tempPath) still runs safely, and call
this.reportProgress(this.toDownloadState(download)) from inside the catch so the
failure is reported correctly (and remove/avoid the post-try success assignments
when an exception occurred).
♻️ Duplicate comments (2)
src/models/DownloadManager.ts (2)

192-210: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Reused completed entries should refresh full metadata before reporting

When an existing object is reused, only terminal fields are reset. Metadata like url, paths, filename, and byte counters can remain stale in emitted state.

Suggested fix
       const existingCompletedDownload = this.downloads.get(downloadId) ?? {
         downloadId,
         url,
         directoryPath: normalizedDirectoryPath,
         savePath: localSavePath,
         tempPath: this.getTempPath(filename, normalizedDirectoryPath),
         filename,
         item: null,
         progress: 1,
         status: DownloadStatus.COMPLETED,
         message: undefined,
         receivedBytes: 0,
         totalBytes: 0,
       };
+      existingCompletedDownload.url = url;
+      existingCompletedDownload.directoryPath = normalizedDirectoryPath;
+      existingCompletedDownload.savePath = localSavePath;
+      existingCompletedDownload.tempPath = this.getTempPath(filename, normalizedDirectoryPath);
+      existingCompletedDownload.filename = filename;
       existingCompletedDownload.progress = 1;
       existingCompletedDownload.status = DownloadStatus.COMPLETED;
       existingCompletedDownload.message = undefined;
       existingCompletedDownload.item = null;
+      existingCompletedDownload.receivedBytes = 0;
+      existingCompletedDownload.totalBytes = 0;
       this.downloads.set(downloadId, existingCompletedDownload);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/models/DownloadManager.ts` around lines 192 - 210, The reuse of
existingCompletedDownload reuses an old object but only resets terminal fields;
update the block that constructs/updates existingCompletedDownload (the object
created when reading this.downloads.get(downloadId)) to refresh all metadata
fields — set url, directoryPath (normalizedDirectoryPath), savePath
(localSavePath), tempPath (use this.getTempPath(filename,
normalizedDirectoryPath)), filename, receivedBytes and totalBytes — before
forcing progress=1, status=DownloadStatus.COMPLETED, message=undefined and
item=null, then write it back with this.downloads.set(downloadId,
existingCompletedDownload).

219-221: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Paused re-start can still return a stale row snapshot

This path calls resumeDownload and immediately returns existingDownload. If resume cannot continue and resumeDownload restarts internally, the returned snapshot can point to the pre-restart object.

Suggested fix
-      if (existingDownload.status === DownloadStatus.PAUSED) {
-        this.resumeDownload(downloadId);
-        return { ok: true, download: this.toDownloadState(existingDownload) };
+      if (existingDownload.status === DownloadStatus.PAUSED) {
+        if (existingDownload.item?.canResume()) {
+          this.resumeDownload(downloadId);
+          return { ok: true, download: this.toDownloadState(existingDownload) };
+        }
+        this.downloads.delete(downloadId);
+        return this.startDownload(url, normalizedDirectoryPath, filename);
       } else if (
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/models/DownloadManager.ts` around lines 219 - 221, When handling a PAUSED
download in the DownloadManager, calling this.resumeDownload(downloadId) then
immediately returning the previously captured existingDownload can return a
stale snapshot if resumeDownload mutates or restarts the download; update the
code to await the result/state after the resume operation and build the response
from the current download object instead of the old existingDownload (e.g., use
the value returned by resumeDownload or re-fetch the download by id) so the
returned this.toDownloadState(...) reflects the post-resume state.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/models/DownloadManager.ts`:
- Around line 363-373: takePendingDownload currently falls back to any download
with item === null which can match cancelled/completed/error rows; update the
fallback so it only returns truly pending entries. In takePendingDownload, keep
the initial pendingDownloadIdsByUrl lookup, but change the fallback
[...this.downloads.values()].find(...) to also verify the download is in a
pending state (e.g. download.status === 'pending' or download.isPending ===
true) or that its id exists in pendingDownloadIdsByUrl for the URL, instead of
just checking download.item === null; ensure you reference the existing symbols
download.item, this.pendingDownloadIdsByUrl and this.downloads and avoid
returning downloads whose status is cancelled/completed/error.

---

Outside diff comments:
In `@src/models/DownloadManager.ts`:
- Around line 106-118: The catch block in DownloadManager (where
fs.renameSync(download.tempPath, download.savePath) is attempted) currently
deletes the temp file but still sets download.progress = 1 and download.status =
DownloadStatus.COMPLETED and reports success; change the catch to mark the
download as failed instead: set download.status = DownloadStatus.FAILED, set
download.progress to a value < 1 (e.g., 0 or leave current progress), populate
download.message with the caught error message, do not set download.item to
null/clear successful fields, ensure fs.unlinkSync(download.tempPath) still runs
safely, and call this.reportProgress(this.toDownloadState(download)) from inside
the catch so the failure is reported correctly (and remove/avoid the post-try
success assignments when an exception occurred).

---

Duplicate comments:
In `@src/models/DownloadManager.ts`:
- Around line 192-210: The reuse of existingCompletedDownload reuses an old
object but only resets terminal fields; update the block that constructs/updates
existingCompletedDownload (the object created when reading
this.downloads.get(downloadId)) to refresh all metadata fields — set url,
directoryPath (normalizedDirectoryPath), savePath (localSavePath), tempPath (use
this.getTempPath(filename, normalizedDirectoryPath)), filename, receivedBytes
and totalBytes — before forcing progress=1, status=DownloadStatus.COMPLETED,
message=undefined and item=null, then write it back with
this.downloads.set(downloadId, existingCompletedDownload).
- Around line 219-221: When handling a PAUSED download in the DownloadManager,
calling this.resumeDownload(downloadId) then immediately returning the
previously captured existingDownload can return a stale snapshot if
resumeDownload mutates or restarts the download; update the code to await the
result/state after the resume operation and build the response from the current
download object instead of the old existingDownload (e.g., use the value
returned by resumeDownload or re-fetch the download by id) so the returned
this.toDownloadState(...) reflects the post-resume state.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 55113226-c529-4139-ac5a-37c10eefd0d6

📥 Commits

Reviewing files that changed from the base of the PR and between dfae1a5 and 95abf67.

📒 Files selected for processing (1)
  • src/models/DownloadManager.ts

Comment thread src/models/DownloadManager.ts
@Kosinkadink
Copy link
Copy Markdown
Member

I had amp code do a quick review, found 4 things it thinks should be changed. Seems pretty applicable. Everything below here is not written by me and is amp taking the wheel.


What it does

Replaces URL-as-identity with a desktop-issued downloadId (= resolved final save path) for the entire download lifecycle. startDownload() now returns { ok, download, error? } so the renderer can bind a row immediately. Internal Maps and IPC params (pause/resume/cancel) all move to downloadId, with URL fallback retained for legacy callers and a FIFO pendingDownloadIdsByUrl queue to correlate Electron's will-download event back to the right row when multiple rows share a URL. Deprecated state / receivedBytes / totalBytes / isPaused fields stay on DownloadState.

Verdict

Solid direction, but worth tightening before merge. The path-identity refactor is good, but the URL fallback + pending-queue + Windows path casing have correctness cliffs now that multiple rows can share a URL.

Must-fix before merge

  1. Ambiguous URL fallback in findDownload (~L420 of DownloadManager.ts) — returns the first URL match, which may be a COMPLETED/CANCELLED row instead of the active one. cancelDownload(url) can flip a completed row to CANCELLED and miss the live one. Fix: only use URL fallback when there is a single non-terminal candidate; otherwise log and no-op. Also guard cancel/resume against terminal rows.

  2. createDownloadId is not Windows-canonical (~L386) — uses path.resolve only. The same physical file gets different IDs for different casing, even though getPathForComparison already lower-cases on Win32. Use getPathForComparison(path.resolve(savePath)).

  3. existsSync(localSavePath) short-circuit runs before the existing-row check (~L196 vs L229) — if a live row exists for that downloadId and the file exists on disk, the code rewrites the live row to COMPLETED and nulls item, orphaning the active DownloadItem. Reorder: check existingDownload first; only treat the on-disk file as authoritative when no live row.

  4. Pending-queue race on cancel/restart before will-download (~L63, L270, L396) — if the user cancels a PENDING row and re-calls startDownload for the same URL, a late will-download from the first request can bind to the new row. Fix: remove stale IDs from pendingDownloadIdsByUrl when deleting/replacing rows; in takePendingDownload, only fall back to URL scan when exactly one pending candidate exists.

Should-fix

  1. deleteModel (~L327) doesn't normalize the path, doesn't cancel a live item, doesn't clean the pending queue — can orphan an active download or let a late write land after a delete.
  2. Redirected/canonicalized URL mismatch — enqueue uses raw caller URL, dequeue uses item.getURLChain()[0]. Canonicalize both sides (e.g. new URL(url).toString()).
  3. getAllDownloads() semantics changed — now includes terminal rows with item === null (previously filtered). Document the new contract; verify no existing caller assumed "active only."

Nits

  • isPaused in toDownloadState mixes status === PAUSED with item?.isPaused() — can disagree with status. Acceptable since the field is deprecated, but pick one source.
  • Test gaps worth adding: cancel-then-restart of same-URL PENDING row; ambiguous URL fallback (one completed + one active for same URL); Windows casing equality of downloadId; deleteModel against active/pending row.

Things done right

  • Returning StartDownloadResult immediately is the right shape for the renderer.
  • Deprecated fields are preserved consistently across all return paths in toDownloadState and the synchronous error branches.
  • calculateProgress correctly guards totalBytes <= 0 (Electron returns 0 for unknown size early on).
  • The 'cancelled' done branch (~L116) now properly distinguishes cancel from generic interruption — nice catch versus the previous "everything-not-completed = ERROR".
  • deleteTempFile swallows ENOENT correctly via existsSync guard.

The core refactor is worth landing — fixing items 1–4 closes the real correctness gaps without enlarging the design.

@benceruleanlu
Copy link
Copy Markdown
Member Author

  • URL fallback now only works when there is exactly one non-terminal matching download.
  • Pending correlation now uses a per-start token so cancel/restart cannot bind a late will-download to the wrong row.
  • deleteModel() now cancels/removes active or pending desktop download state before deleting files.

For the remaining items, I’m intentionally not changing them in this PR:

  • Windows downloadId casing: keeping downloadId as the resolved save path is intentional here. The Windows lowercasing helper is for containment checks, not for the renderer-facing identity. Changing this would alter the external contract and needs a separate decision.
  • Existing file before existing row: I agree this is an edge case, but it is about disk/file-state reconciliation rather than the same-URL row ownership bug this PR is fixing. I’d rather handle that separately if we decide the file system should not be authoritative in that path.
  • URL canonicalization / redirects: plausible hardening, but I don’t have evidence that Electron returns a different original URL string for the current missing-model flow. The new pending token closes the proven race; URL normalization can be a follow-up if we see real mismatches.
  • getAllDownloads() including terminal rows: this is intentional for the new state contract. The renderer needs durable snapshots, not just active Electron DownloadItems.
  • Deprecated isPaused source: leaving as-is because it is compatibility-only and deprecated. status is the canonical field now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants