fix(clipboard): auto-clear must not wipe content copied since our SetText

Copy a password (30s auto-clear armed), then copy something else from
another app before the timer fires: the tick emptied the clipboard anyway,
destroying the user's newer content. Guard with the Win32 clipboard sequence
number: SetText snapshots GetClipboardSequenceNumber, ClearIfOurs only
empties when it hasn't moved. Applied to the auto-clear timer AND the
clear-on-minimize path (same bug class); the explicit JS clipboard/clear
command stays unconditional (user-initiated).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-07-12 11:20:49 +01:00
parent 72dcc3dd82
commit 3822c21194
+21 -3
View File
@@ -41,6 +41,9 @@ type
TSecureClipboard = class TSecureClipboard = class
private private
FClearTimer: TTimer; FClearTimer: TTimer;
// Clipboard sequence number right after OUR SetText. If it moved, the
// user copied something else since — that content is theirs, not ours.
FSetSequence: DWORD;
procedure ClearTimerTick(Sender: TObject); procedure ClearTimerTick(Sender: TObject);
public public
constructor Create; constructor Create;
@@ -50,6 +53,10 @@ type
// AClearAfterMs = 0 disables auto-clear; default is 30 seconds. // AClearAfterMs = 0 disables auto-clear; default is 30 seconds.
procedure SetText(const AText: string; AClearAfterMs: Integer = 30000); procedure SetText(const AText: string; AClearAfterMs: Integer = 30000);
procedure Clear; procedure Clear;
// Clear only if the clipboard still holds what WE put there (sequence
// unchanged). Auto-clear timer + minimize-to-tray use this so they never
// wipe something the user copied from another app in the meantime.
procedure ClearIfOurs;
end; end;
// Distinguishes the "fill everything" hotkey (Ctrl+Shift+L) from the // Distinguishes the "fill everything" hotkey (Ctrl+Shift+L) from the
@@ -341,7 +348,7 @@ end;
procedure TSecureClipboard.ClearTimerTick(Sender: TObject); procedure TSecureClipboard.ClearTimerTick(Sender: TObject);
begin begin
FClearTimer.Enabled := False; FClearTimer.Enabled := False;
Clear; ClearIfOurs;
end; end;
procedure TSecureClipboard.SetText(const AText: string; AClearAfterMs: Integer); procedure TSecureClipboard.SetText(const AText: string; AClearAfterMs: Integer);
@@ -385,6 +392,10 @@ begin
CloseClipboard; CloseClipboard;
end; end;
// Snapshot the sequence AFTER our write — any later copy (ours or another
// app's) bumps it, which is exactly the "is it still ours?" signal.
FSetSequence := GetClipboardSequenceNumber;
if AClearAfterMs > 0 then if AClearAfterMs > 0 then
begin begin
FClearTimer.Interval := AClearAfterMs; FClearTimer.Interval := AClearAfterMs;
@@ -402,6 +413,12 @@ begin
end; end;
end; end;
procedure TSecureClipboard.ClearIfOurs;
begin
if GetClipboardSequenceNumber = FSetSequence then
Clear;
end;
function TSecureClipboard.ReadText: string; function TSecureClipboard.ReadText: string;
var var
H: THandle; H: THandle;
@@ -581,9 +598,10 @@ begin
// out of sight. SKIPPED when AClearClipboard=False — the quick-search // out of sight. SKIPPED when AClearClipboard=False — the quick-search
// copy-then-hide flow deliberately keeps the password on the clipboard // copy-then-hide flow deliberately keeps the password on the clipboard
// (the 30s auto-clear timer still guards it) so the user can paste it // (the 30s auto-clear timer still guards it) so the user can paste it
// into their target app after we minimise. // into their target app after we minimise. ClearIfOurs: never wipe
// content the user copied from another app since.
if AClearClipboard then if AClearClipboard then
FSecureClipboard.Clear; FSecureClipboard.ClearIfOurs;
LFormHwnd := MainFormHWND(FMainForm); LFormHwnd := MainFormHWND(FMainForm);
LAppHwnd := FindFMXAppWindow; LAppHwnd := FindFMXAppWindow;