Skip to content

Bind paste transactions to the selected item, target and clipboard ownership - #7

Draft
MiMoHo wants to merge 8 commits into
haad:masterfrom
MiMoHo:menu-click-paste-race
Draft

Bind paste transactions to the selected item, target and clipboard ownership#7
MiMoHo wants to merge 8 commits into
haad:masterfrom
MiMoHo:menu-click-paste-race

Conversation

@MiMoHo

@MiMoHo MiMoHo commented Jul 5, 2026

Copy link
Copy Markdown

Summary

Bind delayed paste to one owned, cancellable invocation rather than a mutable row index, a loose callback or an elapsed-time debounce.

  • Capture the selected clipping identity with the displayed search snapshot. Resolve that exact object in the live store; a deleted/reloaded row must not redirect Paste to a different clipping.
  • Bind the original target PID and transaction generation. Cancel stale hide/post callbacks, reject missing/self/terminated targets and abort if the user changed focus before target reactivation. Bound the activation-settle wait.
  • Copy the chosen text once and capture the previous clipboard representations. Reject observed clipboard-generation/content interference before activation or posting. Roll back a cancelled or rejected post only while the transaction still owns the clipboard generation; a completed post leaves the selected text available to the target.
  • Use invocation-owned completion rather than relying on the older time-only approach. A distinct new invocation remains independent; Escape cancels pending work, including events without characters.
  • Resolve the Paste key on the Command-modified layout at actual targeted posting, using the same FlycutPasteKey.h as Resolve Paste using command-modified keyboard layouts #19. An unresolved key or event allocation failure posts nothing. Denied access in this targeted path requests native macOS consent and cancels without an app-owned prerequisite dialog.

Related PRs and composition

Related: #9 and #16 cover older duplicate/escape strategies; this revision uses the owned transaction instead. #19 owns the standalone keyboard-layout hook and #15 owns the complete startup/manual permission flow. Those baseline helpers are not silently rewritten here. Do not treat independently green PRs as a verified combined tree. The frozen predecessor was measured to conflict with published #15 in AppController.h/.m and #19 in AppController.m; this lifecycle repair does not resolve those overlaps or claim a combined-tree check. Reconcile both sides explicitly before integrating these PRs.

Verification

  • 45 tests pass on the repaired candidate, with no skipped tests, including nine failure/retry and newer-generation protection cases. The original independent failure oracles also pass unchanged. Native fixtures execute real production methods behind explicit clipboard, application, consent, layout and event test boundaries; they are not a real editor-paste claim.
  • Regressions include stale search rows, changed target identity, activation that never settles, late duplicate completion, focus changes, clipboard interference, conditional rollback, denied access and unresolved layout keys.
  • All 20 target Objective-C translation units pass non-injected syntax checking and the actual production sources link successfully. Compiler warning messages match the exact baseline. The linked executable was not launched.
  • Full commands, logs, source hashes and intended RED/GREEN receipts are retained locally. An initially too-short duplicate probe and a fixture setup failure are recorded as such, not counted as valid regression proof.

Limits

Draft: this is source/native-fixture verification, not a newly installed editor-paste or multi-account/TCC result. No app bundle/resource build, installation, signing, real clipboard use or global input was performed for this revision.

NSPasteboard has no atomic cross-process compare-and-swap, and CGEventPostToPid does not acknowledge clipboard consumption. The guards reject observed interference; they do not guarantee atomic delivery against every concurrent external copy. Promised clipboard representations can block and snapshots consume memory proportional to their contents. Failed rollback is logged, not reported as a successful restoration.

AI assistance

Implemented and independently reviewed with Hermes Agent using gpt-6-astra. The session does not expose its reasoning-effort setting; no value is inferred.

MiMoHo and others added 7 commits July 5, 2026 02:29
…licked one

The pollPB: timer runs in NSRunLoopCommonModes, so a clipboard change can be
noticed while the status menu is open (deliberate, for Universal Clipboard).
That triggers updateMenu, which removes every clipping item and inserts new
NSMenuItem objects.  If the rebuild lands between the user's click and the
action dispatch, the clicked item is no longer in the menu, [sender menu] is
nil, and [[sender menu] indexOfItem:sender] messages nil and yields 0 --
pasting stack position 0, the most recently copied clipping, instead of the
entry the user clicked.  With the 1-second poll interval this commonly
happens when the user copies something and opens the menu right away.

- processMenuClippingSelection: bail out when the sender is orphaned or its
  index can't be resolved, instead of pasting the wrong clipping.
- pasteIndexAndUpdate: now returns whether a clipping was placed on the
  pasteboard, so no Cmd-V is faked when nothing was pasted (previously that
  re-pasted whatever was already on the pasteboard).  Also bounds-check the
  search mapping, which could throw NSRangeException.
- searchWindowItemSelected: use clickedRow for double-clicks so a selection
  change between click and action can't redirect the paste to row 0, and
  bounds-check the search mapping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pollPB: runs in NSRunLoopCommonModes so it keeps firing during menu
tracking. When it notices a clipboard change mid-selection it inserts a
new clipping at store index 0, shifting every clipping's index by one.
The pending selection is then resolved against the shifted store, so the
user pastes the wrong entry - typically the freshly-arrived newest one -
instead of the one they clicked. The same shift breaks the search
window, whose result list is a snapshot while getPasteFromIndex: uses
live indices.

Guard pollPB: to skip capture while isMenuOpen or isSearchWindowDisplayed,
without touching pbCount so the change is still detected and captured the
moment the surface closes (menuDidClose: fires a catch-up poll). Track
menu open state via menuWillOpen:/menuDidClose:.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up to the first commit, which stopped the wrong paste but could only
bail out: when the rebuild had already detached the clicked item there was
nothing left to resolve, so the click did nothing.

Each clipping row now carries what it stands for instead of relying on where
it sits: -updateMenuContaining: attaches [store position, display string] as
the item's representedObject, and -storePositionForMenuItem: resolves the
click from that, using the position only as a hint that is re-validated
against the current store.  -[FlycutClipping displayString] returns the same
string object on every call and -previousDisplayStrings: passes those objects
through unchanged, so pointer equality identifies exactly one clipping; a
content comparison is only used as a fallback for a store reloaded from disk.
A menu rebuild between the click and the delivery of the action is therefore
harmless rather than merely detected.

While the store position is known at build time, the search mapping is
resolved there as well, so -pasteStorePositionAndUpdate: (renamed from
-pasteIndexAndUpdate:) no longer reads the menu's search box at paste time.
That box is cleared asynchronously by -updateMenu, so a click arriving after
the clearing used to resolve a filtered row number against the unfiltered
store - another way to paste the newest clipping.  The bezel path benefits
too: -moveItemAtStackPositionToTopOfStack passes a store position, which the
old method would have run through the menu's search mapping.

Menu items removed by a rebuild are held for one generation.  jcMenu is their
only owner, so a sender AppKit delivers after the rebuild would otherwise be
a freed object - which the first commit's nil check would already have had to
touch.

When a click still cannot be resolved, nothing is pasted, the failure is
logged without any clipping contents and NSBeep gives feedback, so "nothing
happened" can be told apart from a wrong paste in a bug report.

Co-Authored-By: Claude <noreply@anthropic.com>
…tring

The previous commit claimed that -[FlycutClipping displayString] hands out a
pointer-unique string per clipping.  That is wrong for short strings: a
display string is the first line truncated to displayLen, and short strings
are tagged pointers, so two different clippings whose first lines happen to
match compare pointer-equal.  A test program built against FlycutClipping
shows "test\nZeile A" and "test\nZeile B" both yielding 0x802e93b4acce3b26.

The consequence was the very bug this PR is about, only rarer: with the hint
position invalidated by a store change, resolution could land on the wrong
twin, and the hint check itself could accept a position that a same-looking
clipping had moved into.

So carry the FlycutClipping object instead.  Object pointers are exact, the
comparison is cheaper than a string compare, and a contents comparison
remains as a fallback for a store reloaded from disk, where the objects are
new but two clippings with equal contents are interchangeable for pasting
anyway.  This needs one read-only accessor on FlycutOperator,
-clippingAtPosition:, forwarding to the store; the clipping is fetched before
the NSMenuItem is allocated so a store change mid-build cannot leak an item.

Verified with a test program covering: unchanged store, shifted store, a
same-looking twin sitting at the hint position, a store reloaded from disk,
and a deleted clipping (which must resolve to -1 and paste nothing).  All six
resolve to the clipping the user clicked.

Co-Authored-By: Claude <noreply@anthropic.com>
The freeze added in the second commit stops -pollPB: while isMenuOpen or
isSearchWindowDisplayed is set.  Both flags can be left standing, and because
capture then never resumes, Flycut silently stops recording clippings with
nothing in the UI or the log to say why.

Option-click on the status icon is the documented way to pause capture while
copying a password (help.md, readme.md).  It cancels the menu from inside
-menuWillOpen:, and AppKit does not send -menuDidClose: for a menu cancelled
there - verified with a standalone AppKit program: menuWillOpen fires,
menuDidClose never does.  So isMenuOpen stayed set forever.  The first
option-click was harmless because -pollPB: also requires the store to be
enabled, but the second one re-enabled the store while capture stayed frozen.
Cleared in that branch, where the menu is not opening anyway.

-windowDidResignKey: routed every window to -hideApp, which does not close the
search window properly, so isSearchWindowDisplayed survived clicking away from
it.  It now closes the search window through -hideSearchWindow.  Before the
freeze this only leaked a bit of state; with it, capture died.

Both flags are additionally cleared in -hideApp as a backstop: hiding the app
tears down the menu and the search window anyway, and neither flag is meant to
outlive its surface.

Co-Authored-By: Claude <noreply@anthropic.com>
While the bezel is open, -pollPB: keeps capturing, and -addClipping: reset the
stack position to 0 on every insert.  A clipping arriving mid-selection
therefore moved the selection onto it, and the user pasted the newest entry
instead of the one they had picked - the same defect this PR fixes for the
status menu, on the third selection surface.

The position now follows the clipping it points at: -addClipping: anchors on
that clipping before changing the store and looks up where it ended up
afterwards.  -indexOfClipping: matches on contents, which is what is wanted
here - two clippings with equal contents are interchangeable for pasting, and
it also covers removeDuplicates, where the existing copy is moved to the top
rather than a new one being inserted.  The anchor is retained because the
store can drop clippings while inserting.

Position 0 is deliberately not anchored.  It is where every selection starts,
so it expresses no choice, and the bezel always shows whatever sits at the
top; following the old top clipping from there would move the selection away
from what the user is looking at.  Staying put keeps display and paste in
agreement.

Because the bezel opening at the newest clipping used to be a side effect of
that reset, -hitMainHotKey: now says so explicitly.  Not in -showBezel:, which
the favourites toggle also calls after -toggleToFromFavoritesStore has swapped
in that store's own remembered position.

Finally, the store redraws the bezel from inside its own insert, by way of
-endUpdates, before the position has settled - so the bezel would show the
neighbour of the clipping a paste would deliver.  The clipping-added callback
now redraws once the position is settled.

No new state is introduced, so there is no new way for a flag to be left
standing and stop capture, which is what the previous commit had to repair
twice.

Verified with a test program against FlycutStore/FlycutClipping: a clipping
arriving mid-selection, two in a row, an untouched bezel at position 0 (also
with a single stored clipping, and after navigating back up to 0), the anchor
pushed out by the rememberNum limit, and removeDuplicates both with a
duplicate above the selection and of the selection itself.

Co-Authored-By: Claude <noreply@anthropic.com>
With pasteMovesToTop enabled, -getPasteFromIndex: moved the clipping and only
then set the stack position to 0.  The store redraws the bezel from inside that
move (-delegateEndUpdates -> -[AppController endUpdates], which always has
needBezelUpdate set via -noteChangeAtIndex:), so the redraw ran with the
pre-move position - which after the move points at the neighbour of the
clipping being pasted.  Reported from a visual test: pressing Return on the
third entry flashed the fourth one for an instant.  The paste itself was
correct.

Assigning the position first fixes it, because once the clipping has been
moved to the top, position 0 is that clipping.

Reproduced and verified with a test program that stands in for the store
delegate and records what the bezel would draw at -endUpdates: before, the
redraw shows the neighbour; after, it shows the clipping that is pasted.

Co-Authored-By: Claude <noreply@anthropic.com>
Updates haad#7.

AI assistance: Hermes Agent, gpt-6-astra.
Reasoning effort: not exposed by this session.
@MiMoHo MiMoHo changed the title Fix menu click sometimes pasting the newest clipping instead of the clicked one Bind paste transactions to the selected item, target and clipboard ownership Sep 9, 2026
@MiMoHo
MiMoHo marked this pull request as draft September 9, 2026 21:53
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.

1 participant