diff --git a/CLAUDE.md b/CLAUDE.md
index bdc9374..4fc0b78 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -768,6 +768,16 @@ Sidebar Tools expose 2 items MFA :
secret base32 aléatoire 20 bytes (`randomBase32Secret()`). Rien
n'est sauvegardé.
+**Scanner le QR à l'écran** (bouton QR du champ 2FA de l'éditeur, 2026-09-18) :
+`cmd://qr/screen` → la fenêtre passe au tray, `PM.ScreenQr` capture **tout le
+bureau virtuel** en mémoire (GDI, octets bruts, jamais sur disque) et le décode
+par ZXing.Delphi — le même que l'APK, `android-client\ThirdParty\ZXing` dans le
+chemin de recherche du `.dproj`. Pas de sélection de zone : le décodeur trouve
+le QR où qu'il soit. **Seul un `otpauth://` repart vers la page**
+(`Bridge.onScreenQr`), le reste de l'écran ne quitte pas l'exe. Format `BGR32`
+et non `BGRA32` : l'octet alpha d'une capture GDI vaut 0, lu comme transparent
+→ image blanche.
+
Code TOTP retourne `{ code, period, secondsLeft }` — attention au nom,
**pas `remaining`** (utiliser `t.secondsLeft`).
diff --git a/delphi-backend/PMServer.dpr b/delphi-backend/PMServer.dpr
index a02d495..d07b3b9 100644
--- a/delphi-backend/PMServer.dpr
+++ b/delphi-backend/PMServer.dpr
@@ -24,6 +24,7 @@ uses
PM.Licence in 'Source\PM.Licence.pas',
PM.LicenceGate in 'Source\PM.LicenceGate.pas',
DelphiZXingQRCode in 'Source\DelphiZXingQRCode.pas',
+ PM.ScreenQr in 'Source\PM.ScreenQr.pas',
PM.AutoStart in 'Source\PM.AutoStart.pas',
PM.Favicon in 'Source\PM.Favicon.pas',
PM.ProcessLockdown in 'Source\PM.ProcessLockdown.pas',
diff --git a/delphi-backend/PMServer.dproj b/delphi-backend/PMServer.dproj
index 1a37745..b6c22ff 100644
--- a/delphi-backend/PMServer.dproj
+++ b/delphi-backend/PMServer.dproj
@@ -91,6 +91,7 @@
true
+ ..\android-client\ThirdParty\ZXing;..\android-client\ThirdParty\ZXing\1D Barcodes;..\android-client\ThirdParty\ZXing\2D Barcodes;..\android-client\ThirdParty\ZXing\2D Barcodes\Decoder;..\android-client\ThirdParty\ZXing\2D Barcodes\Detector;..\android-client\ThirdParty\ZXing\Common;..\android-client\ThirdParty\ZXing\Common\Detector;..\android-client\ThirdParty\ZXing\Common\ReedSolomon;..\android-client\ThirdParty\ZXing\Filtering;$(DCC_UnitSearchPath)
false
false
false
@@ -250,6 +251,7 @@ $(PreBuildEvent)]]>
+
diff --git a/delphi-backend/Source/PM.ScreenQr.pas b/delphi-backend/Source/PM.ScreenQr.pas
new file mode 100644
index 0000000..42476e8
--- /dev/null
+++ b/delphi-backend/Source/PM.ScreenQr.pas
@@ -0,0 +1,115 @@
+unit PM.ScreenQr;
+
+// Lit un QR affiche a l'ecran - le secret 2FA qu'un site montre a l'activation.
+// Capture de TOUS les ecrans (bureau virtuel) en memoire, jamais sur le disque,
+// puis decodage par ZXing.Delphi (Apache 2.0, deja vendu pour l'APK dans
+// android-client\ThirdParty\ZXing). Aucune selection de zone : le decodeur
+// trouve le QR n'importe ou dans l'image.
+//
+// GDI pur et octets bruts, pas de TBitmap FMX : tout se fait dans un thread
+// d'arriere-plan, et le decodage d'un bureau entier prend un moment.
+
+interface
+
+/// Texte du premier QR trouve a l'ecran, '' si aucun.
+function ScanScreenForQr: string;
+
+implementation
+
+uses
+ Winapi.Windows, System.SysUtils, System.Generics.Collections,
+ ZXing.LuminanceSource, ZXing.RGBLuminanceSource, ZXing.HybridBinarizer,
+ ZXing.BinaryBitmap, ZXing.MultiFormatReader, ZXing.BarcodeFormat,
+ ZXing.DecodeHintType, ZXing.ReadResult;
+
+const
+ CAPTURE_BLT = $40000000; // absente de Winapi.Windows ; inclut les fenetres superposees
+
+function CaptureVirtualScreen(out AWidth, AHeight: Integer): TArray;
+var
+ LX, LY: Integer;
+ LScreenDC, LMemDC: HDC;
+ LBmp: HBITMAP;
+ LOld: HGDIOBJ;
+ LInfo: TBitmapInfo;
+begin
+ Result := nil;
+ LX := GetSystemMetrics(SM_XVIRTUALSCREEN);
+ LY := GetSystemMetrics(SM_YVIRTUALSCREEN);
+ AWidth := GetSystemMetrics(SM_CXVIRTUALSCREEN);
+ AHeight := GetSystemMetrics(SM_CYVIRTUALSCREEN);
+ if (AWidth <= 0) or (AHeight <= 0) then Exit;
+
+ LScreenDC := GetDC(0);
+ LMemDC := CreateCompatibleDC(LScreenDC);
+ LBmp := CreateCompatibleBitmap(LScreenDC, AWidth, AHeight);
+ try
+ LOld := SelectObject(LMemDC, LBmp);
+ BitBlt(LMemDC, 0, 0, AWidth, AHeight, LScreenDC, LX, LY, SRCCOPY or CAPTURE_BLT);
+ SelectObject(LMemDC, LOld);
+
+ FillChar(LInfo, SizeOf(LInfo), 0);
+ LInfo.bmiHeader.biSize := SizeOf(TBitmapInfoHeader);
+ LInfo.bmiHeader.biWidth := AWidth;
+ LInfo.bmiHeader.biHeight := -AHeight; // negatif = de haut en bas
+ LInfo.bmiHeader.biPlanes := 1;
+ LInfo.bmiHeader.biBitCount := 32;
+ LInfo.bmiHeader.biCompression := BI_RGB;
+ SetLength(Result, AWidth * AHeight * 4);
+ if GetDIBits(LMemDC, LBmp, 0, AHeight, @Result[0], LInfo, DIB_RGB_COLORS) = 0 then
+ Result := nil;
+ finally
+ DeleteObject(LBmp);
+ DeleteDC(LMemDC);
+ ReleaseDC(0, LScreenDC);
+ end;
+end;
+
+function ScanScreenForQr: string;
+var
+ LPixels: TArray;
+ LW, LH: Integer;
+ LSource: TLuminanceSource;
+ LBinarizer: THybridBinarizer;
+ LImage: TBinaryBitmap;
+ LReader: TMultiFormatReader;
+ LHints: TDictionary;
+ LFormats: TList;
+ LRes: TReadResult;
+begin
+ Result := '';
+ LPixels := CaptureVirtualScreen(LW, LH);
+ if LPixels = nil then Exit;
+
+ LFormats := TList.Create;
+ LFormats.Add(TBarcodeFormat.QR_CODE);
+ LHints := TDictionary.Create;
+ LHints.Add(TDecodeHintType.POSSIBLE_FORMATS, LFormats);
+ LHints.Add(TDecodeHintType.TRY_HARDER, nil);
+ LReader := TMultiFormatReader.Create;
+ LReader.Hints := LHints;
+ // BGR32 et non BGRA32 : le 4e octet d'une capture GDI vaut 0, et la
+ // conversion BGRA le lirait comme une transparence totale - image blanche.
+ LSource := TRGBLuminanceSource.Create(LPixels, LW, LH, TBitmapFormat.BGR32);
+ LBinarizer := THybridBinarizer.Create(LSource);
+ LImage := TBinaryBitmap.Create(LBinarizer);
+ LRes := nil;
+ try
+ try
+ LRes := LReader.decode(LImage, True);
+ if LRes <> nil then Result := Trim(LRes.Text);
+ except
+ Result := '';
+ end;
+ finally
+ LRes.Free;
+ LImage.Free;
+ LBinarizer.Free;
+ LSource.Free;
+ LReader.Free;
+ LFormats.Free;
+ LHints.Free;
+ end;
+end;
+
+end.
diff --git a/delphi-backend/UMainForm.pas b/delphi-backend/UMainForm.pas
index c7a1d2a..e38d809 100644
--- a/delphi-backend/UMainForm.pas
+++ b/delphi-backend/UMainForm.pas
@@ -35,7 +35,7 @@ uses
FMX.TMSFNCEdgeWebBrowser,
{$ENDIF}
PM.HTTPServer, PM.Bridge, PM.QuickUnlock, PM.PinUnlock, PM.UserPrefs, PM.Licence, PM.AutoStart,
- PM.Favicon, DelphiZXingQRCode,
+ PM.Favicon, DelphiZXingQRCode, PM.ScreenQr,
FMX.Platform.Win, FMX.Menus; // WindowHandleToPlatform → HWND for visibility check
const
// Bump on each release. Surfaced to JS via cmd://app/version, displayed
@@ -1393,6 +1393,40 @@ begin
// envoie tout texte a une recherche web (Google, Chrome) - le jeton serait
// parti chez Google a un appui pres. Un lien a schema propre n'a qu'une
// application pour l'ouvrir : la notre.
+ // QR 2FA affiche par un site : la fenetre s'efface le temps de la capture
+ // (elle recouvre souvent le QR), puis revient. Seul un lien otpauth:// part
+ // vers la page - le reste de l'ecran ne quitte pas la memoire de l'exe.
+ else if ACmd = 'qr/screen' then
+ begin
+ LogLine('qr/screen: scanning');
+ FBridge.MinimizeToTray(False, False);
+ TThread.CreateAnonymousThread(
+ procedure
+ var
+ LText: string;
+ begin
+ Sleep(400); // laisser Windows retirer la fenetre de l'ecran
+ LText := ScanScreenForQr;
+ if Pos('otpauth://', LowerCase(LText)) <> 1 then LText := '';
+ TThread.Queue(nil,
+ procedure
+ var
+ LJs: TJSONString;
+ begin
+ RestoreWindowFromTray;
+ if LText = '' then LogLine('qr/screen: no otpauth QR found')
+ else LogLine('qr/screen: otpauth QR found');
+ LJs := TJSONString.Create(LText);
+ try
+ WebBrowser.ExecuteJavaScript(
+ 'if(window.Bridge&&Bridge.onScreenQr)Bridge.onScreenQr(' + LJs.ToJSON + ')');
+ finally
+ LJs.Free;
+ end;
+ end);
+ end).Start;
+ end
+
else if ACmd = 'sync/qr' then
begin
var LQrUrl := PM.UserPrefs.GetPref('syncUrl');
diff --git a/index.html b/index.html
index 3ea50da..2dfc81d 100644
--- a/index.html
+++ b/index.html
@@ -47,6 +47,7 @@
+
diff --git a/js/app.js b/js/app.js
index eaa85c0..ba6d323 100644
--- a/js/app.js
+++ b/js/app.js
@@ -4833,6 +4833,24 @@ function soTotpField(plainSecret) {
});
row.appendChild(input);
row.appendChild(toggle);
+ // Lit le QR qu'affiche le site a l'activation de la 2FA : l'exe s'efface,
+ // capture l'ecran et ne renvoie qu'un lien otpauth:// (qr/screen).
+ if (Bridge.active) {
+ const scan = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Scan the 2FA QR code shown on screen' });
+ scan.appendChild(icon('i-qr'));
+ scan.addEventListener('click', () => {
+ Bridge.onScreenQr = (text) => {
+ const secret = parseOtpAuthUri(text || '');
+ if (!secret) { toast('No 2FA QR code found on screen', 'warning'); return; }
+ input.value = secret;
+ updateTotpDisplay();
+ soDirtyCheck();
+ toast('2FA secret read from the QR code');
+ };
+ window.location.href = 'cmd://qr/screen';
+ });
+ row.appendChild(scan);
+ }
row.appendChild(clear);
wrap.appendChild(row);