Skip to content

Fix browser download trigger parity - #6258

Merged
austinywang merged 7 commits into
mainfrom
issue-6255-browser-download-chrome-parity
Jun 16, 2026
Merged

austinywang merged 7 commits into
mainfrom
issue-6255-browser-download-chrome-parity

Conversation

@austinywang

@austinywang austinywang commented Jun 16, 2026 •

Copy link
Copy Markdown
Contributor

Closes #6255

Also fixes the PDF download button portion of #4266 by routing <a download> / blob-backed downloads through cmux's native save flow. #4266 should remain open for the PDF print button.

Testing / proof so far:

  • python3 scripts/swift_file_length_budget.py
  • scripts/check-pbxproj.sh
  • scripts/lint-pbxproj-test-wiring.sh

Not run yet: local tagged app build and manual CSV/PDF download verification. Per workspace build-serialization instructions, that waits until CI is green and the user explicitly tells me to launch the dev build.


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Summary by cubic

Aligns our in-app browser’s download behavior with Chrome. Adds native handling for <a download> links, classifies more responses as downloads, and requires a user gesture for scripted downloads.

  • Bug Fixes
    • Route scripted downloads to native save: intercept <a download>; allow blob:/data: only (blobs converted to data URLs); require user activation, per‑tab tokens, 100 MB payload and 140 MB data‑URL caps, 500 ms post rate limit.
    • Honor shouldPerformDownload in both main and popup navigation delegates by early‑returning .download.
    • Treat Content-Disposition: attachment and common archive types (text/csv, zip/gzip, octet-stream) as downloads; fall back when MIME can’t render.
    • Centralize download policy and filename resolution in BrowserDownloadFilenameResolver with reasoned logs; send only domain/path/secure‑matching cookies on download requests, with tests for cookie scoping.

Closes #6255. Fixes the PDF download button portion of #4266 (print button still tracked there).

Written for commit a053488. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features
    • Enhanced download decisions using both MIME type and Content-Disposition, including attachment handling and MIME normalization.
    • Added scripted interception for user-initiated download links, including blob/data-url capture, native routing, token validation, and safer filename resolution.
    • Improved download request cookie selection with domain/path matching and secure/expired cookie filtering.
  • Bug Fixes
    • Made download classification consistent across navigation and popup flows by centralizing the decision logic.
  • Tests
    • Added tests for forced-vs-inline MIME types, Content-Disposition: attachment, and cookie matching/filtering behavior.

@vercel

vercel Bot commented Jun 16, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Canceled Canceled Jun 16, 2026 7:59pm
cmux-staging Building Building Preview, Comment Jun 16, 2026 7:59pm

@coderabbitai

coderabbitai Bot commented Jun 16, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 3912c65a-c995-4780-8f4b-531898135d71

📥 Commits

Reviewing files that changed from the base of the PR and between 14b16e7 and a053488.

📒 Files selected for processing (3)
  • Sources/Panels/BrowserPopupWindowController.swift
  • Sources/Panels/CmuxWebView+ScriptedDownloads.swift
  • cmuxTests/BrowserDownloadFilenameResolverTests.swift

📝 Walkthrough

Walkthrough

Centralizes download-forcing logic in BrowserDownloadFilenameResolver with MIME-type allowlist and Content-Disposition parsing, then wires it into both BrowserPanel and BrowserPopupWindowController navigation delegates. Adds CmuxWebView+ScriptedDownloads.swift with a JS bootstrap that intercepts blob/anchor-download clicks and routes them through a native WKScriptMessageHandler to downloadURLViaSession.

Changes

Download Policy Centralization and Scripted Download Interception

Layer / File(s) Summary
Download policy logic and tests
Sources/Panels/BrowserDownloadFilenameResolver.swift, cmuxTests/BrowserDownloadFilenameResolverTests.swift
Adds shouldForceDownload and navigationResponseDownloadReason to BrowserDownloadFilenameResolver backed by a forceDownloadMIMETypes allowlist, MIME normalization, and case-insensitive Content-Disposition: attachment detection. Three new tests cover force-download, inline, and attachment-disposition cases. Two additional tests validate cookie filtering by domain/path and secure/expiration status.
Navigation delegate integration
Sources/Panels/BrowserPanel.swift, Sources/Panels/BrowserPopupWindowController.swift
Both BrowserNavigationDelegate and PopupNavigationDelegate now short-circuit to .download when navigationAction.shouldPerformDownload is true, and delegate navigationResponse download classification to BrowserDownloadFilenameResolver().navigationResponseDownloadReason, removing the prior inline Content-Disposition string checks.
Scripted download JS bootstrap and message handler
Sources/Panels/CmuxWebView+ScriptedDownloads.swift
New file embeds a document-start JS script overriding URL.createObjectURL/revokeObjectURL, intercepting anchor-download clicks, reading blobs as data URLs, and posting messages to WebKit. installScriptedDownloadInterception() installs the script once per WKUserContentController. handleScriptedDownloadMessage parses payloads and calls startScriptedDownload, which calls downloadURLViaSession. Cookie filtering helpers validate request headers. ScriptedDownloadMessageHandler dispatches WKScriptMessage callbacks to MainActor.
CmuxWebView integration and project registration
Sources/Panels/CmuxWebView.swift, cmux.xcodeproj/project.pbxproj
Both CmuxWebView initializers call installScriptedDownloadInterception(). makeContextDownloadTraceID, debugContextDownload, and downloadURLViaSession lose private so the extension can use them. The data: URL filename path delegates to BrowserDownloadFilenameResolver().suggestedFilename. Cookie header generation now includes the request URL. Project file registers the new source in the build phase.

Sequence Diagram

sequenceDiagram
    participant Page as Web Page JS
    participant WKHandler as ScriptedDownloadMessageHandler
    participant CmuxWebView
    participant Resolver as BrowserDownloadFilenameResolver
    participant Session as downloadURLViaSession

    Note over Page,CmuxWebView: JS bootstrap installed at document-start
    Page->>WKHandler: postMessage(kind, url/dataURL, suggestedFilename)
    WKHandler->>CmuxWebView: handleScriptedDownloadMessage(_:) on MainActor
    CmuxWebView->>CmuxWebView: startScriptedDownload(_:suggestedFilename:)
    CmuxWebView->>Session: downloadURLViaSession(url, traceID, .scriptedDownload)

    Note over CmuxWebView,Resolver: navigationResponse path
    CmuxWebView->>Resolver: navigationResponseDownloadReason(mimeType, canShowMIMEType, contentDisposition)
    Resolver-->>CmuxWebView: "content-disposition" or "forceDownloadMIME" or "cannotShowMIME" or nil
    CmuxWebView->>Session: decisionHandler(.download) if reason != nil
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related issues

Possibly related PRs

  • manaflow-ai/cmux#5938: Both PRs modify BrowserDownloadFilenameResolver and rewire it into the WebKit download flow; this PR adds content-disposition and MIME-based forcing decisions, while the earlier PR added HTTP-status gating and image-byte filename resolution.

Suggested reviewers

  • lawrencecchen
  • Ari4ka

🐇 A rabbit hops and clicks a link,
The blob is caught before you blink!
MIME types sorted, headers read,
attachment found — it's time to download!
The script runs first at document-start,
Download magic — a bunny's art. 🎉


Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (2 errors, 1 warning)

Check name Status Explanation Resolution
Cmux Swift Logging ❌ Error PR adds 10 unguarded NSLog statements in production app/runtime code (BrowserPanel.swift), violating swift-logging.md rules; two also log URLs exposing navigation data. Guard all NSLog statements with #if DEBUG or replace with Logger (os.log) using Apple's unified logging system.
Cmux User-Facing Error Privacy ❌ Error NSLog statements added at lines 9221, 9236, 9244 in BrowserPanel.swift are unguarded by #if DEBUG and expose URLs and internal download decisions to system logs accessible to users via Console.app,... Guard the NSLog statements with #if DEBUG or use cmuxDebugLog instead to ensure URLs and internal download routing decisions are not exposed in production system logs accessible to end users.
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.
✅ Passed checks (18 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: fixing download trigger parity to align browser download behavior with Chrome.
Description check ✅ Passed The description is mostly complete with a clear summary of changes, testing steps performed, and an acknowledgment of pending manual testing. However, it deviates significantly from the repository's description template.
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.
Cmux Swift Actor Isolation ✅ Passed BrowserDownloadFilenameResolver correctly marked as nonisolated struct, ScriptedDownloadMessageHandler uses MainActor.assumeIsolated, and CmuxWebView methods on @MainActor class. No actor isolation...
Cmux Swift Blocking Runtime ✅ Passed PR introduces no blocking/timing-based synchronization patterns. New files (BrowserDownloadFilenameResolver, CmuxWebView+ScriptedDownloads) and modifications to BrowserPanel, BrowserPopupWindowCont...
Cmux Expensive Synchronous Load ✅ Passed PR adds lightweight MIME/Content-Disposition decision logic via BrowserDownloadFilenameResolver with only string operations (normalization, comparison, set lookup) in existing WebKit delegate metho...
Cmux Cache Substitution Correctness ✅ Passed PR does not swap fresh reads for cached values in persistence/history/undo/snapshot paths. All download policy decisions use fresh authoritative reads: cookies via getAllCookies, MIME types via sta...
Cmux No Hacky Sleeps ✅ Passed PR contains only Swift and Xcode project file changes. The check applies only to TypeScript, JavaScript, shell, and build/runtime scripts; Swift is explicitly out of scope per rule file.
Cmux Algorithmic Complexity ✅ Passed PR introduces single-pass O(1) and O(n) algorithms in appropriate contexts: BrowserDownloadFilenameResolver uses fixed-size set lookup; cookiesForDownloadRequest performs single-pass filter over HT...
Cmux Swift Concurrency ✅ Passed PR introduces no new legacy async patterns. New code uses synchronous methods, MainActor.assumeIsolated at WebKit boundaries (allowed), and no new DispatchQueue, Combine, completion handlers, or fi...
Cmux Swift @Concurrent ✅ Passed PR properly implements @concurrent rules: No nonisolated async functions, no invalid @concurrent usage, network/file I/O properly dispatched via DispatchQueue.
Cmux Swift File And Package Boundaries ✅ Passed PR complies with Swift file boundaries: CmuxWebView+ScriptedDownloads.swift (382 lines, new file) has clear single responsibility for scripted download interception and is under 400-line threshold;...
Cmux Full Internationalization ✅ Passed PR introduces one localized user-facing string 'browser.download.defaultFilename' using String(localized:defaultValue:) with complete translations in all 20 supported locales. All other changes are...
Cmux Swiftui State Layout ✅ Passed PR contains no SwiftUI code. All modified files use AppKit/WebKit/Foundation only. No SwiftUI state patterns (@Published, @StateObject, @Observable, etc.) are introduced.
Cmux Architecture Rethink ✅ Passed PR introduces centralized download policy (BrowserDownloadFilenameResolver) with clear ownership, idempotent installation guards, proper MainActor isolation, and no problematic timing/dispatch patt...
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR introduces no new NSWindow/NSPanel/NSWindowController or SwiftUI Window/WindowGroup code. BrowserPopupWindowController (NSPanel) pre-existed with "cmux.browser-popup" identifier already register...
Cmux Source Artifacts ✅ Passed All files are hand-written source code, config, or required build artifacts. No local tool output, logs, caches, build output, or artifact directories are added to source control.
✨ 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 issue-6255-browser-download-chrome-parity

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

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

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)
Sources/Panels/CmuxWebView.swift (1)

1608-1615: ⚠️ Potential issue | 🟠 Major

Add @MainActor annotation to downloadURLViaSession.

This method is callable from other same-module files (e.g., CmuxWebView+ScriptedDownloads.swift) and accesses WebKit's httpCookieStore and AppKit's NSSavePanel. While the current scripted-download path uses MainActor.assumeIsolated, the method itself lacks an explicit @MainActor contract, allowing future callers to invoke it from background threads. Mark it @MainActor to enforce main-thread-only access, consistent with how other cookie-store access in the codebase (BrowserPanel, TerminalController) wraps these operations.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Panels/CmuxWebView.swift` around lines 1608 - 1615, The
downloadURLViaSession method in CmuxWebView.swift accesses WebKit's
httpCookieStore and AppKit's NSSavePanel, which are main-thread-only APIs, but
lacks an explicit `@MainActor` contract. Add the `@MainActor` annotation to the
downloadURLViaSession method signature to enforce main-thread-only access. This
will prevent future callers from accidentally invoking it from background
threads and will make the main-thread requirement explicit in the method's
contract, consistent with how other similar cookie-store access is handled
elsewhere in the codebase such as in BrowserPanel and TerminalController.

Source: Learnings

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Sources/Panels/CmuxWebView`+ScriptedDownloads.swift:
- Around line 48-58: The readBlobForDownload function uses readAsDataURL to
convert the entire blob to a base64-encoded string, which causes memory issues
with large files. Implement a size cap check before calling reader.readAsDataURL
on the blob parameter; if the blob size exceeds a reasonable threshold, handle
it with explicit error handling (e.g., reject the download or show an error
message to the user). Alternatively, replace the base64 approach with chunked or
streamed transfer of the blob data to a native temporary file to avoid
materializing the entire file in WebContent memory at once.
- Around line 194-232: The handleScriptedDownloadMessage method is a
page-accessible entry point that currently lacks security validation before
passing URLs to the credentialed downloader. Add security gates to this function
to: verify an unforgeable injected token is present in the message body,
validate that user activation and correct origin conditions are met, explicitly
reject file: URLs from scripted messages, and ensure that when passing the
validated URL to startScriptedDownload, HTTP credentials are either scoped
appropriately to the target URL or the request is routed through WebKit's
built-in download machinery instead of the credentialed session. These
validations must occur in handleScriptedDownloadMessage before the guard
statement that validates the URL itself.

---

Outside diff comments:
In `@Sources/Panels/CmuxWebView.swift`:
- Around line 1608-1615: The downloadURLViaSession method in CmuxWebView.swift
accesses WebKit's httpCookieStore and AppKit's NSSavePanel, which are
main-thread-only APIs, but lacks an explicit `@MainActor` contract. Add the
`@MainActor` annotation to the downloadURLViaSession method signature to enforce
main-thread-only access. This will prevent future callers from accidentally
invoking it from background threads and will make the main-thread requirement
explicit in the method's contract, consistent with how other similar
cookie-store access is handled elsewhere in the codebase such as in BrowserPanel
and TerminalController.
🪄 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: ASSERTIVE

Plan: Pro

Run ID: c5854124-2bb6-47d9-a3d1-202bc71a20b0

📥 Commits

Reviewing files that changed from the base of the PR and between a78c241 and aecf4cb.

📒 Files selected for processing (7)
  • Sources/Panels/BrowserDownloadFilenameResolver.swift
  • Sources/Panels/BrowserPanel.swift
  • Sources/Panels/BrowserPopupWindowController.swift
  • Sources/Panels/CmuxWebView+ScriptedDownloads.swift
  • Sources/Panels/CmuxWebView.swift
  • cmux.xcodeproj/project.pbxproj
  • cmuxTests/BrowserDownloadFilenameResolverTests.swift

Comment thread Sources/Panels/CmuxWebView+ScriptedDownloads.swift
Comment thread Sources/Panels/CmuxWebView+ScriptedDownloads.swift
@greptile-apps

greptile-apps Bot commented Jun 16, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR aligns the in-app WKWebView's download behavior with Chrome by (1) centralizing download-policy decisions in BrowserDownloadFilenameResolver, (2) honoring shouldPerformDownload in both the main and popup navigation delegates, (3) broadening force-download classification to include Content-Disposition: attachment and common archive/CSV MIME types, and (4) adding a new JS bootstrap script that intercepts <a download> clicks on blob: and data: URLs and routes them through the native save flow.

  • BrowserDownloadFilenameResolver gains shouldForceDownload and navigationResponseDownloadReason, eliminating duplicated inline checks from both navigation delegates and unifying download policy in one place.
  • CmuxWebView+ScriptedDownloads.swift (new, 382 lines) installs a WKUserScript injected at document start that overrides URL.createObjectURL to track blob objects, intercepts trusted-gesture <a download> clicks on blob: / data: URLs, and bridges them to downloadURLViaSession via a token-validated message handler; cookie filtering is also scoped to domain/path/secure/expiry matching.
  • CmuxWebView.swift switches the cookie attachment for download requests from all-cookies to the new filtered set and delegates filename sanitization for data-URL downloads to BrowserDownloadFilenameResolver.suggestedFilename.

Confidence Score: 5/5

Safe to merge; all changes are correctly scoped to the download-handling path with no regressions to navigation, auth, or persistence.

The download-policy centralization in BrowserDownloadFilenameResolver is clean and well-tested. The shouldPerformDownload wiring in both delegates is straightforward. The scripted-download bootstrap is carefully token-gated, user-gesture-checked, and rate-limited. The two open issues — a rate-gate consumed before a size rejection, and a premature preventDefault in the fetch fallback — are confined to edge cases (worker-created blob URLs; oversized blob immediately followed by a second click within 500 ms) that don't affect the common download path.

CmuxWebView+ScriptedDownloads.swift warrants a quick second look at the fetch-fallback branch and the ordering of reserveDownloadPost relative to size validation.

Important Files Changed

Filename Overview
Sources/Panels/BrowserDownloadFilenameResolver.swift Adds shouldForceDownload and navigationResponseDownloadReason, centralizing download policy with correct MIME normalization and case-insensitive Content-Disposition parsing.
Sources/Panels/BrowserPanel.swift Adds shouldPerformDownload early-return to the action delegate and replaces duplicated inline download logic with the centralized navigationResponseDownloadReason call.
Sources/Panels/BrowserPopupWindowController.swift Mirrors BrowserPanel changes for the popup delegate — adds shouldPerformDownload handling and delegates response classification to BrowserDownloadFilenameResolver.
Sources/Panels/CmuxWebView+ScriptedDownloads.swift New 382-line extension; fetch fallback in postBlobURLDownload returns true before the async fetch resolves, consuming event.preventDefault prematurely. Token validation and blobDownloadInFlight guard are correctly implemented.
Sources/Panels/CmuxWebView.swift Installs scripted-download interception in both init paths, scopes cookie headers to filtered set, and delegates data-URL filename sanitization to BrowserDownloadFilenameResolver.
cmuxTests/BrowserDownloadFilenameResolverTests.swift Adds well-targeted tests for force-download MIME policy, Content-Disposition attachment, and cookie domain/path/expiry/secure filtering.
cmux.xcodeproj/project.pbxproj Correctly wires CmuxWebView+ScriptedDownloads.swift into the app target Sources phase with proper file and build references.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant User
    participant Page as Page JS
    participant Script as Injected Bootstrap (atDocumentStart)
    participant Bridge as WKScriptMessageHandler
    participant Swift as CmuxWebView (MainActor)
    participant Session as URLSession

    User->>Page: click anchor with download attribute
    Script->>Script: anchorForEvent → interceptAnchorDownload
    Script->>Script: hasUserActivation? ✓
    Script->>Script: reserveDownloadPost? ✓
    alt blob in objectURLs map
        Script->>Script: readBlobForDownload → FileReader.readAsDataURL
        Script-->>Bridge: "postMessage kind=dataURL token dataURL suggestedFilename"
    else blob not tracked
        Script->>Page: fetch(blobURL) async
        Script-->>User: preventDefault() immediately
        Page-->>Script: blob resolved
        Script->>Script: readBlobForDownload → FileReader.readAsDataURL
        Script-->>Bridge: "postMessage kind=dataURL token dataURL"
    end
    Bridge->>Swift: handleScriptedDownloadMessage (MainActor.assumeIsolated)
    Swift->>Swift: token validation (WKUserContentController assoc object)
    Swift->>Swift: startScriptedDownload → isScriptedDownloadSupportedURL(data) ✓
    Swift->>Swift: cookiesForDownloadRequest (domain/path/secure/expiry filter)
    Swift->>Session: downloadTask with dataURL request and cookies
    Session-->>Swift: file saved to disk
    Swift-->>User: NSSavePanel / native save flow

    Note over User,Script: Regular https a-download handled via shouldPerformDownload in WKNavigationDelegate
    Note over Swift,Session: data-scheme anchor clicks take url kind path with no FileReader
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant User
    participant Page as Page JS
    participant Script as Injected Bootstrap (atDocumentStart)
    participant Bridge as WKScriptMessageHandler
    participant Swift as CmuxWebView (MainActor)
    participant Session as URLSession

    User->>Page: click anchor with download attribute
    Script->>Script: anchorForEvent → interceptAnchorDownload
    Script->>Script: hasUserActivation? ✓
    Script->>Script: reserveDownloadPost? ✓
    alt blob in objectURLs map
        Script->>Script: readBlobForDownload → FileReader.readAsDataURL
        Script-->>Bridge: "postMessage kind=dataURL token dataURL suggestedFilename"
    else blob not tracked
        Script->>Page: fetch(blobURL) async
        Script-->>User: preventDefault() immediately
        Page-->>Script: blob resolved
        Script->>Script: readBlobForDownload → FileReader.readAsDataURL
        Script-->>Bridge: "postMessage kind=dataURL token dataURL"
    end
    Bridge->>Swift: handleScriptedDownloadMessage (MainActor.assumeIsolated)
    Swift->>Swift: token validation (WKUserContentController assoc object)
    Swift->>Swift: startScriptedDownload → isScriptedDownloadSupportedURL(data) ✓
    Swift->>Swift: cookiesForDownloadRequest (domain/path/secure/expiry filter)
    Swift->>Session: downloadTask with dataURL request and cookies
    Session-->>Swift: file saved to disk
    Swift-->>User: NSSavePanel / native save flow

    Note over User,Script: Regular https a-download handled via shouldPerformDownload in WKNavigationDelegate
    Note over Swift,Session: data-scheme anchor clicks take url kind path with no FileReader
Loading

Reviews (3): Last reviewed commit: "test: cover browser download cookie scop..." | Re-trigger Greptile

Comment on lines +61 to +84
const readBlobForDownload = (blob, suggestedFilename) => {
try {
if (!blob) return;
if (blobDownloadInFlight) return;
if (typeof blob.size === "number" && blob.size > maxPayloadBytes) return;
blobDownloadInFlight = true;
const filename = String(suggestedFilename || blob.name || "");
const reader = new FileReader();
const finish = () => {
blobDownloadInFlight = false;
};
reader.onload = () => {
if (typeof reader.result === "string" && reader.result.length > 0) {
postDataURLDownload(reader.result, filename);
}
finish();
};
reader.onerror = finish;
reader.onabort = finish;
reader.readAsDataURL(blob);
} catch (_) {
blobDownloadInFlight = false;
}
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Silent concurrent blob-download drop

blobDownloadInFlight is a per-page serial gate: while a FileReader.readAsDataURL call is in progress (potentially seconds for large files), any second blob-download trigger returns silently with no user feedback, no retry, and no error. The user clicks "Download" a second time and nothing happens. The flag is reset only when the first read resolves/errors, so for a 100 MB blob (≈133 MB after base64 encoding), any overlapping download is permanently lost.

Consider queuing the second request or showing a brief in-app error when the gate is occupied, rather than silently discarding it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Adjusted in 14b16e7: an occupied blob read now returns false to avoid consuming a second trigger through the native bridge.

— Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 14b16e7: a concurrent blob read now returns false from the interceptor instead of consuming the click, so the bridge no longer silently swallows the user's second action while one read is in flight.

— Claude Code

Comment on lines +165 to +173
private static let forceDownloadMIMETypes: Set<String> = [
"application/gzip",
"application/json",
"application/octet-stream",
"application/x-gzip",
"application/x-zip-compressed",
"application/zip",
"text/csv",
]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 application/json force-download breaks inline JSON viewing

Adding application/json to forceDownloadMIMETypes means every navigation to a JSON endpoint — REST API debug views, admin consoles, or any direct URL that returns JSON — now triggers a save dialog instead of rendering inline. WKWebView can show application/json in its raw text renderer, and many pages deliberately serve JSON for in-browser inspection. This is a broader behavior change than the CSV/PDF fix described in the PR and will affect developer and power-user workflows. Consider restricting the force-download rule to MIME types WebKit cannot usefully render (application/octet-stream, application/zip, application/gzip, application/x-gzip, application/x-zip-compressed, text/csv) and deferring application/json until there is a confirmed need.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Adjusted in 14b16e7: application/json was removed from the force-download MIME list and is covered as an inline-renderable type in the policy test.

— Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 14b16e7: application/json was removed from the force-download MIME set and the unit test now asserts JSON remains inline.

— Claude Code

Comment on lines +194 to +227
func installScriptedDownloadInterception() {
let userContentController = configuration.userContentController
if objc_getAssociatedObject(
userContentController,
&Self.scriptedDownloadHandlerInstalledKey
) != nil {
return
}

let token = UUID().uuidString
objc_setAssociatedObject(
self,
&Self.scriptedDownloadTokenKey,
token,
.OBJC_ASSOCIATION_COPY_NONATOMIC
)
userContentController.addUserScript(
WKUserScript(
source: Self.scriptedDownloadInterceptionBootstrapScriptSource(token: token),
injectionTime: .atDocumentStart,
forMainFrameOnly: true
)
)
userContentController.add(
Self.sharedScriptedDownloadMessageHandler,
name: Self.scriptedDownloadMessageHandlerName
)
objc_setAssociatedObject(
userContentController,
&Self.scriptedDownloadHandlerInstalledKey,
NSNumber(value: true),
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Shared WKUserContentController causes token-never-set for second web view

The "installed" guard key is on userContentController, but the token is stored on self (the individual CmuxWebView). If two CmuxWebView instances share a WKWebViewConfiguration (and therefore the same userContentController), the second web view hits the early-return and never sets its scriptedDownloadTokenKey. When a scripted download message arrives for that second web view, objc_getAssociatedObject(self, &Self.scriptedDownloadTokenKey) returns nil, the guard let expectedToken fails, and all scripted downloads are silently rejected. The guard and the token should be keyed consistently — either both on self or both on userContentController — and the token should be stored whenever a new CmuxWebView joins the same content controller.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 14b16e7: the scripted-download token is now associated with the shared WKUserContentController, matching the installed-script guard.

— Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 14b16e7: the install guard and token are now both stored/read on the shared WKUserContentController, so later web views sharing that controller validate against the same token instead of missing a per-view value.

— Claude Code

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

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

⚠️ Outside diff range comments (2)
Sources/Panels/CmuxWebView+ScriptedDownloads.swift (2)

318-331: 🧹 Nitpick | 🔵 Trivial | 💤 Low value

Document or assert main-thread assumption for assumeIsolated.

MainActor.assumeIsolated relies on WebKit's contract that WKScriptMessageHandler callbacks are always dispatched on the main thread. A comment or debug assertion would make this assumption explicit and catch regressions if WebKit behavior ever changes.

📝 Optional improvement
 private final class ScriptedDownloadMessageHandler: NSObject, WKScriptMessageHandler {
     func userContentController(
         _ userContentController: WKUserContentController,
         didReceive message: WKScriptMessage
     ) {
         guard let webView = message.webView as? CmuxWebView,
               let body = message.body as? [String: Any] else {
             return
         }
+        // WKScriptMessageHandler callbacks are always dispatched on the main thread per WebKit contract.
+        assert(Thread.isMainThread, "WKScriptMessageHandler callback expected on main thread")
         MainActor.assumeIsolated {
             webView.handleScriptedDownloadMessage(body)
         }
     }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Panels/CmuxWebView`+ScriptedDownloads.swift around lines 318 - 331,
The `userContentController(_:didReceive:)` method in
`ScriptedDownloadMessageHandler` uses `MainActor.assumeIsolated` without
documenting the assumption that this WebKit callback always runs on the main
thread. Add a comment above or within the method explaining that WebKit
guarantees this callback is dispatched on the main thread, and optionally add a
debug assertion (such as asserting that the current thread is the main thread)
to catch any regressions if WebKit behavior changes in the future.

150-168: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject file: URLs from scripted download messages.

The prior review flagged that file: URLs should be rejected from scripted messages since legitimate web pages cannot link to local files. Line 161 still allows file: scheme through interceptAnchorDownload, and the Swift-side startScriptedDownload doesn't filter it either. This exposes local file reads to page-initiated scripts if the token were ever compromised.

🔒 Proposed fix to reject file: URLs in JS
             if (scheme === "blob") {
               return postBlobURLDownload(href, suggestedFilename);
             }
-            if (scheme === "data" || scheme === "http" || scheme === "https" || scheme === "file") {
+            if (scheme === "data" || scheme === "http" || scheme === "https") {
               if (scheme === "data" && href.length > maxDataURLCharacters) return false;
               postURLDownload(href, suggestedFilename);
               return true;
             }

Alternatively, add a guard in Swift-side startScriptedDownload:

private func startScriptedDownload(_ url: URL, suggestedFilename: String?) {
    let scheme = url.scheme?.lowercased() ?? ""
    guard scheme != "file" else {
        debugContextDownload("browser.scriptdl.start rejected=file_scheme")
        return
    }
    // ...
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Panels/CmuxWebView`+ScriptedDownloads.swift around lines 150 - 168,
Remove the `file:` scheme from the allowed URL schemes in the
interceptAnchorDownload function at line 161 where the scheme check currently
includes "file" alongside "data", "http", "https", and "http" - this prevents
locally stored files from being accessed via scripted downloads. Additionally,
add a guard statement in the Swift-side startScriptedDownload method to
explicitly reject file:// scheme URLs before processing, as shown in the
proposed fix example, to provide defense-in-depth protection against
page-initiated script access to local files.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@Sources/Panels/CmuxWebView`+ScriptedDownloads.swift:
- Around line 318-331: The `userContentController(_:didReceive:)` method in
`ScriptedDownloadMessageHandler` uses `MainActor.assumeIsolated` without
documenting the assumption that this WebKit callback always runs on the main
thread. Add a comment above or within the method explaining that WebKit
guarantees this callback is dispatched on the main thread, and optionally add a
debug assertion (such as asserting that the current thread is the main thread)
to catch any regressions if WebKit behavior changes in the future.
- Around line 150-168: Remove the `file:` scheme from the allowed URL schemes in
the interceptAnchorDownload function at line 161 where the scheme check
currently includes "file" alongside "data", "http", "https", and "http" - this
prevents locally stored files from being accessed via scripted downloads.
Additionally, add a guard statement in the Swift-side startScriptedDownload
method to explicitly reject file:// scheme URLs before processing, as shown in
the proposed fix example, to provide defense-in-depth protection against
page-initiated script access to local files.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 108bc4dc-4fda-4003-bbd3-b947ebf85428

📥 Commits

Reviewing files that changed from the base of the PR and between aecf4cb and 11158a7.

📒 Files selected for processing (2)
  • Sources/Panels/CmuxWebView+ScriptedDownloads.swift
  • Sources/Panels/CmuxWebView.swift

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

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

⚠️ Outside diff range comments (1)
Sources/Panels/CmuxWebView+ScriptedDownloads.swift (1)

387-389: 🧹 Nitpick | 🔵 Trivial | 💤 Low value

MainActor.assumeIsolated relies on WebKit's undocumented threading guarantee.

WKScriptMessageHandler callbacks are expected on the main thread per WebKit convention, but this isn't formally guaranteed in the API contract. If WebKit ever delivers messages on a background thread, this will crash.

Consider using Task { @mainactor in ... } for defensive dispatch, or add a comment documenting the WebKit threading assumption for future maintainers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Panels/CmuxWebView`+ScriptedDownloads.swift around lines 387 - 389,
The code uses MainActor.assumeIsolated to call
webView.handleScriptedDownloadMessage(body), which assumes WebKit delivers
callbacks on the main thread without documented guarantee. Replace the
assumeIsolated block with a defensive Task { `@MainActor` in ... } wrapper to
safely dispatch the webView.handleScriptedDownloadMessage call to the main
actor, ensuring it will work correctly even if WebKit ever delivers the message
on a background thread. Alternatively, if keeping assumeIsolated, add a clear
comment documenting the WebKit threading assumption for future maintainers.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@Sources/Panels/CmuxWebView`+ScriptedDownloads.swift:
- Around line 387-389: The code uses MainActor.assumeIsolated to call
webView.handleScriptedDownloadMessage(body), which assumes WebKit delivers
callbacks on the main thread without documented guarantee. Replace the
assumeIsolated block with a defensive Task { `@MainActor` in ... } wrapper to
safely dispatch the webView.handleScriptedDownloadMessage call to the main
actor, ensuring it will work correctly even if WebKit ever delivers the message
on a background thread. Alternatively, if keeping assumeIsolated, add a clear
comment documenting the WebKit threading assumption for future maintainers.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: e5db2998-98ec-4898-93ab-2b850b97e876

📥 Commits

Reviewing files that changed from the base of the PR and between 11158a7 and 14b16e7.

📒 Files selected for processing (3)
  • Sources/Panels/BrowserDownloadFilenameResolver.swift
  • Sources/Panels/CmuxWebView+ScriptedDownloads.swift
  • cmuxTests/BrowserDownloadFilenameResolverTests.swift
💤 Files with no reviewable changes (1)
  • Sources/Panels/BrowserDownloadFilenameResolver.swift

@austinywang
austinywang merged commit ae52fdd into main Jun 16, 2026
26 checks passed
austinywang added a commit that referenced this pull request Jul 6, 2026
…ill be re-implemented on the current download architecture

The pre-#6258 implementation (save-panel based BrowserDownloadDelegate
extensions) no longer matches the download system on main, so this merge
takes main's tree wholesale rather than rescuing stale hunks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview – cmux — a0534882 Deployed Jun 16, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Browser: match Chrome download behavior — CSV/exports render inline instead of downloading; blob/JS downloads (PDF preview Download button) do nothing

1 participant