Skip to content

Consolidate clipboard helpers into one Safari-safe copyToClipboard - #2033

Merged
benbalter merged 2 commits into
mainfrom
audit-clipboard
Sep 27, 2026
Merged

benbalter merged 2 commits into
mainfrom
audit-clipboard

Conversation

@benbalter

@benbalter benbalter commented Sep 27, 2026 •

Copy link
Copy Markdown
Owner

What

There were two clipboard helpers that disagreed:

  • src/scripts/linkedin-copy.ts: synchronous, execCommand('copy') only, with a comment saying Safari's writeText silently fails.
  • src/utils/copy-to-clipboard.ts: writeText first, then an execCommand fallback. Used by ShareButtons, quote-share, and the y shortcut.

Now there's one: src/utils/copy-to-clipboard.ts. linkedin-copy.ts keeps only its button wiring, and its unit tests moved into copy-to-clipboard.test.ts.

Why the Safari workaround existed

  • LinkedIn Resume copy functionality not working #1683 reported "In Safari on iPad OS. It looks like it works but the clipboard is blank." Copilot fixed it in Fix LinkedIn Resume copy on Safari/iPad OS #1684 (11b40ed) by switching to execCommand.
  • The code it replaced computed the text synchronously and called writeText as the first thing in the click handler. No await came before it, so lost transient user activation was not the cause.
  • The claim that Safari resolves writeText without writing came from Copilot and was never reproduced in that PR. LinkedIn Resume copy functionality not working #1683 has no follow-up confirming the fix on a device. I can't confirm the root cause either.
  • The important part: whatever the cause, a writeText-first helper can't detect that failure. The promise resolves, so the fallback never runs.

New order

  1. Textarea + execCommand('copy'), run synchronously before any await. It returns false when it fails instead of pretending to succeed. This is the path that fixed LinkedIn Resume copy functionality not working #1683.
  2. navigator.clipboard.writeText, only if step 1 fails. It's called in the same tick, so it still has user activation.

Other details:

  • It returns Promise<boolean> everywhere.
  • It takes an optional trigger element to refocus, since Safari doesn't focus buttons on click.
  • It restores the prior selection and focus.
  • A unit test calls it without awaiting and asserts the copy already happened. That catches anyone who later adds an await ahead of the copy.
  • I skipped the ClipboardItem promise path because no caller computes its text asynchronously.

Verification

e2e/clipboard.spec.ts clicks every copy surface: LinkedIn field, LinkedIn description, share-bar link, inline quote, and the y shortcut. It asserts the success UI, logs which API ran, and reads the clipboard back.

Result
Chromium 5/5 pass. execCommand did the copy, read back with readText.
WebKit 5/5 pass. execCommand did the copy, read back by pasting into a textarea (WebKit rejects readText even inside a gesture).

Standalone WebKit probes, not committed:

  • writeText resolves, but a following paste still returns the previous clipboard contents, in both headless and headed runs. That's consistent with LinkedIn Resume copy functionality not working #1683, but it could also be a Playwright WebKit artifact: async clipboard writes and synthetic paste may not share a pasteboard in the automation build, and WebKit gives no other way to read the clipboard. So this is not a reproduction.
  • writeText succeeds after a 50ms await and is rejected after 6s, so in Playwright WebKit the activation window ends somewhere between those two points. Short awaits are fine.

Also ran: vitest (13 pass), npm run check (0 errors), eslint on changed files, and knip (nothing new).

Caveats

  • Playwright WebKit is a desktop WebKit build, not iPadOS Safari. It shows the code path works, not that an iPad pasteboard does. Only a real iPad can settle LinkedIn Resume copy functionality not working #1683.
  • The site's CSP includes upgrade-insecure-requests. WebKit applies it to http://127.0.0.1, so no scripts load on a local preview. The new spec strips that directive when it fetches each page. The rest of the CROSS_BROWSER=1 WebKit suite (for example search-shortcuts) still fails locally for that reason. That was already broken before this PR and is left alone here.
  • Chromium-only clipboard tests in resume-linkedin and search-shortcuts now skip on non-Chromium, because WebKit throws on grantPermissions('clipboard-write').

🤖 Generated with Claude Code

benbalter and others added 2 commits September 27, 2026 10:15
The LinkedIn page had its own execCommand-only copy helper (added for
#1683, "iPadOS Safari: looks like it works but the clipboard is blank"),
while ShareButtons, quote-share, and the `y` shortcut used a
writeText-first helper. Merge them into src/utils/copy-to-clipboard.ts.

The helper now runs textarea + execCommand('copy') synchronously inside
the user gesture first, because it reports failure honestly, and only
then falls back to navigator.clipboard.writeText (also called in the
same tick). writeText-first can't detect #1683: the promise resolves,
so the fallback never runs. In Playwright WebKit, writeText resolves
but a subsequent paste still returns the old clipboard contents, which
matches the bug report. The fix commit's code already called writeText
synchronously, so lost user activation was not the cause.

Adds e2e/clipboard.spec.ts, which clicks every copy surface in Chromium
and WebKit and reads the clipboard back (readText in Chromium, paste in
WebKit, which rejects readText).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@benbalter
benbalter merged commit 72a80be into main Sep 27, 2026
17 checks passed
@benbalter
benbalter deleted the audit-clipboard branch September 27, 2026 14:17
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.

LinkedIn Resume copy functionality not working

1 participant