Compare commits

...

30 Commits

Author SHA1 Message Date
r-zakarya 247771bfec docs: formulaire d'edition, scan QR 2FA, synchro et reseau, Copy / Fill
CLAUDE.md : interrupteur Copy / Fill de la recherche rapide et pourquoi le
tray copie seulement ; QR lu mais pas de 2FA ; nom par defaut data.json,
messages de commit neutres, 8 s / 3 essais sur le reseau.
README Android : edition de l'entree entiere en un commit, oeil, icone depuis
la galerie, scan 2FA ; verification a l'ouverture, depot vide = creation ; les
pieges 'view' qui defocalisait les champs et 'toast' qui etait un <div>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:42:12 +01:00
r-zakarya 8d3e20b702 docs(fillbar): info-bulle et detour NOTOPMOST, les deux pieges corriges
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:40:38 +01:00
r-zakarya 1309868f27 fix(fillbar): plus de detour par NOTOPMOST pour remonter la barrette
Le passage NOTOPMOST puis TOPMOST la faisait descendre sous les fenetres
ordinaires un instant - un flash a chaque battement ou elle se croyait
recouverte, donc souvent quand elle est posee sur la barre des taches. Et il
ne gagnait rien contre la barre des taches active, mesure faite. TOPMOST seul
suffit contre une fenetre TOPMOST ordinaire.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:38:55 +01:00
r-zakarya e786d8fe14 fix(fillbar): l'info-bulle ne fait plus clignoter la barrette en boucle
La remontee automatique voyait l'info-bulle d'un bouton - posee par-dessus la
barrette - comme un recouvrement : NOTOPMOST puis TOPMOST, la barrette
disparaissait un instant, l'info-bulle se fermait puis se rouvrait sous la
souris immobile. Les fenetres de notre propre processus ne comptent plus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:25:25 +01:00
r-zakarya 28349c10c8 fix(quick-search): Esc ne sort plus du cadre, et la note dit qu'elle se copie
Le champ de recherche gardait la largeur de son texte d'aide (min-width par
defaut) et poussait l'interrupteur et Esc hors du panneau. Le texte d'aide
parlait aussi de copier alors que Fill est souvent actif : il dit "Search..."
tout court. Et le bouton d'une note precise, en mode Fill, qu'une note n'est
jamais tapee.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:12:06 +01:00
r-zakarya 7c26716831 feat(quick-search): interrupteur Copy / Fill quand une fenetre cible existe
Ouverte par Ctrl+Shift+Q ou la barrette, la recherche remplissait toujours ;
copier restait impossible alors que le remplissage echoue vers une application
lancee en administrateur, ou quand le gestionnaire de Chrome s'en mele. Sans
cible (tray, palette), l'interrupteur n'apparait pas : remplir n'aurait rien a
viser. Le choix est retenu en pref DPAPI, les verbes des boutons et l'aide
suivent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:07:57 +01:00
r-zakarya 5e4fcfad0f fix(qr): dire "ce QR n'est pas un code 2FA" au lieu de "aucun QR"
Un QR de texte, ou un QR dynamique qui encode un lien de suivi, etait lu puis
ecarte en silence : l'exe repondait "aucun QR 2FA trouve", l'APK continuait de
viser sans rien dire. Le texte d'un autre QR ne part toujours pas a la page ;
seul le fait qu'un QR a ete lu est signale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:29:12 +01:00
r-zakarya 3acf78b1ca feat(apk): afficher/masquer les secrets, scanner le QR 2FA, et toast() reparé
- Mot de passe et secret 2FA naissent masques dans la creation et l'edition,
  avec un oeil pour les reveler.
- Un bouton QR dans le champ 2FA ouvre la camera du scan des reglages ; le lien
  otpauth:// lu part a la page, qui n'en garde que le secret. Le journal ne note
  que le fait.
- toast() designait l'element <div id="toast"> (chaque id est une variable
  globale) : sept appels de la page levaient "toast is not a function" et leur
  message ne s'affichait jamais. Une vraie fonction globale les repare tous.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:20:31 +01:00
r-zakarya 22c418f933 fix(sync): le poste aussi abandonne vite une adresse morte, et reessaie
140.82.121.5 est une adresse de GitHub injoignable depuis ce reseau - verifie
par l'application ET par un navigateur, sur deux appareils. Un navigateur passe
a la suivante aussitot ; le poste attendait dix secondes puis abandonnait. Huit
secondes pour la poignee de main et trois essais, mais seulement quand la
requete n'a pas abouti : un 404 ou un 409 sont des reponses, pas des echecs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 18:50:46 +01:00
r-zakarya 64195fc641 fix(apk): attente de connexion courte et trois essais
Mesure sur ce telephone : api.github.com rend plusieurs adresses, 140.82.121.6
repond en 0,22 s et 140.82.121.5 n'est pas joignable. Un navigateur passe a la
suivante aussitot ; l'application attendait son delai entier sur la mauvaise.
Huit secondes pour la poignee de main, trente pour la reponse, trois essais.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 18:42:22 +01:00
r-zakarya 074e6f92e7 feat(sync): messages de commit neutres des deux cotes
L'historique du depot affichait "sync from <utilisateur du coffre>" a chaque
envoi du poste, et le telephone y ecrivait ce qu'il venait de faire ("new entry
from phone", "icon fetched from phone", "entries deleted from phone"). Le
contenu est chiffre, ces lignes ne l'etaient pas. Il ne reste que l'appareil
d'origine - le detail vit dans le journal local, ou il sert au diagnostic sans
quitter l'appareil.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 14:26:21 +01:00
r-zakarya 75a1169dc9 feat(sync): nom par defaut neutre (data.json), et plus de clavier pendant une synchro
Un fichier nomme "vault-sync.json" annonce un coffre a qui parcourt la liste
des depots ; le contenu est chiffre, le nom ne l'etait pas. Les configurations
existantes gardent leur nom - c'est l'URL complete qui est enregistree - et le
QR du poste ne raccourcit plus que le nom par defaut courant, sinon le
telephone rallongerait vers un autre fichier.

Synchroniser coffre ferme ouvre d'abord le coffre : ce chemin depliait le
clavier de recherche au milieu d'une synchro.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 14:24:11 +01:00
r-zakarya a89e227272 fix(apk): verdict honnete de la verification, et plus de connexion reutilisee
"unchanged (HTTP 0)" confondait "rien n'a change" et "je n'ai pas pu demander".
Et quand le Wi-Fi se reconnecte avec une autre IP, la pile Java garde des
sockets sur l'interface morte : mesure faite, curl atteint les deux adresses de
GitHub depuis le shell du telephone pendant que l'application expire sur
l'ancienne.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 13:12:02 +01:00
r-zakarya fc7e89a5d5 feat(apk): l'ouverture ne tire plus l'instantane, elle demande seulement s'il a bouge
Ouvrir depuis le cache puis retelecharger tout faisait changer la liste sous
les yeux au gre du reseau, et montrait une panne en plein ecran pour un simple
lancement. Une requete conditionnelle suffit : si le depot a bouge, le temoin
de synchro s'allume et l'utilisateur tire quand il veut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-26 12:29:17 +01:00
r-zakarya 24614d71ba fix(apk): depot vide = creation, et deux essais reseau au lieu d'un long
404 a la lecture pendant une publication etait traite comme un echec : sur un
depot neuf, rien ne partait jamais. Sans sha, l'API des contenus cree le
fichier. Et le lien Wi-Fi de ce telephone perd des paquets (ping 71-236 ms
alors que le port 443 s'ouvre depuis le shell) : deux essais de 20 s valent
mieux qu'une attente unique de 40 s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 17:29:37 +01:00
r-zakarya 329588dc1c ui(apk): l'icone accompagne le titre a la lecture aussi
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 17:09:55 +01:00
r-zakarya 9ae7b58767 fix(apk): le formulaire garde la vue 'detail', sinon chaque champ perdait le focus
Un gardien defocalise tout ce qui prend le focus hors de l'ecran courant, en
comparant 'view' a l'identifiant de l'ecran. Le formulaire vit dans l'ecran
'detail' ; poser view='edit' le faisait donc blurer a chaque focusin - clavier
ouvert, pas de curseur, rien ne rentrait. Un drapeau 'editing' dit desormais
qu'on modifie, et 'view' continue de nommer l'ecran.

Mesure au protocole DevTools sur le telephone : le champ etait bien atteignable
et focalise, ce qui a elimine le DOM et le CSS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 17:03:27 +01:00
r-zakarya 2a461cad26 fix(apk): depot vide + changements en attente = premiere publication, et delais reseau plus longs
Le telephone verifiait le mot de passe de synchro en telechargeant
l'instantane et s'arretait sur 404 : rien ne partait jamais vers un depot
neuf. Mesure : un 404 de GitHub a pris 16,3 s, au-dela du delai de 15 s du
client et sous le garde-fou de 25 s - d'ou les 'HTTP 0' intermittents.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 16:31:16 +01:00
r-zakarya e0f8bf441f fix(apk): l'icone se pose a cote du titre de l'en-tete, le nom n'est plus ecrit deux fois
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 15:39:38 +01:00
r-zakarya 49738d0b74 fix(apk): retour depuis le formulaire = revenir a l'entree, pas a la liste
Le formulaire se declare comme une vue ('state?view=edit') : la fleche de
l'en-tete et la touche retour du telephone le referment d'abord.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 15:27:14 +01:00
r-zakarya 29f737b831 ui(apk): icone et nom en tete du formulaire, arrondis unifies, retour au formulaire apres une action d'icone
L'icone se commet comme un champ : Delphi rouvrait l'entree et la vue de
lecture reprenait la place au milieu d'une modification. Le formulaire est
maintenant rouvert avec ce qui y etait deja tape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 15:18:41 +01:00
r-zakarya 0f90b816fc fix(apk): le crayon par ligne disparait, l'edition passe par le formulaire
L'editeur d'une ligne n'a plus de bouton : sa machinerie part avec lui. Seule
la liste des dossiers en est gardee (folderSelect), pour que le champ Folder du
formulaire reste un choix et non une faute de frappe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 15:05:58 +01:00
r-zakarya 0c12997ad4 feat(apk): edition de l'entree entiere en un seul enregistrement, et icone choisie dans la galerie
Un crayon ouvre un formulaire pre-rempli : tous les champs modifiables a la
fois, un seul Save, donc UN commit au lieu d'un par champ ('setfields',
plusieurs PatchEntryField dans le meme CommitChange).

L'icone quitte le menu de l'entree pour ce formulaire : From site (recherche),
Image... (galerie, reduite a 128 px, provenance custom) et Remove.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 14:57:58 +01:00
r-zakarya 5f1a98df7d ui(apk): le formulaire de nouvelle entree devient une seule carte
Sept champs faisaient sept cartes, chacune contenant un champ avec son propre
fond et ses coins - rectangle dans rectangle. Une carte, des lignes dedans, le
champ reduit a son trait de saisie ; le bouton Save sort de sa carte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 14:39:31 +01:00
r-zakarya 6a98f113ee fix(apk): pas de menu Duplicate/Delete sur l'ecran d'une nouvelle entree
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 14:33:59 +01:00
r-zakarya 29d569c7ba docs(fillbar): la barre des taches active passe devant, mesures a l'appui
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 15:56:58 +01:00
r-zakarya 1ae1d482f0 fix(fillbar): remonte la barrette quand elle est recouverte, au lieu de la deplacer
Elle a le droit de se poser sur la barre des taches. Redemander HWND_TOPMOST
quand on l'est deja ne change rien : le shell restait devant au demarrage de
session et au menu Demarrer. Le passage NOTOPMOST -> TOPMOST force la remontee,
fait seulement quand le centre de la barrette appartient a une autre fenetre.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 15:38:05 +01:00
r-zakarya c1c7d9cd59 fix(fillbar): la position retenue est ramenee dans la zone de travail
Posee sur la barre des taches (TOPMOST elle aussi), la barrette passait
derriere elle au demarrage de Windows et a l'ouverture du menu Demarrer.
Mesure avant/apres sur ce poste : y=1037 (barre = 1032..1080) -> y=992.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 15:33:03 +01:00
r-zakarya 652815ea98 feat(quick-search): boutons par entree - utilisateur, mot de passe ou note, code 2FA (s'ils existent)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 23:15:49 +01:00
r-zakarya cfa2a798e5 docs(build): build-both.cmd sauvegarde les exe precedents dans PMServer-previous.zip
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 22:58:39 +01:00
14 changed files with 1016 additions and 198 deletions
+7
View File
@@ -34,6 +34,13 @@
<!-- Scanner du QR des reglages de synchro, demandee au premier appui. --> <!-- Scanner du QR des reglages de synchro, demandee au premier appui. -->
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" /> <uses-feature android:name="android.hardware.camera" android:required="false" />
<!-- Choisir une icone dans la galerie. READ_MEDIA_IMAGES est le nom
d'Android 13 et plus, READ_EXTERNAL_STORAGE celui d'avant ; la
seconde est donc bornee aux anciennes versions pour ne pas demander
un acces large la ou le selecteur recent n'exige rien. -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-feature android:glEsVersion="0x00020000" android:required="true"/> <uses-feature android:glEsVersion="0x00020000" android:required="true"/>
<queries> <queries>
<%queries-child-elements%> <%queries-child-elements%>
+62 -2
View File
@@ -262,6 +262,26 @@ Détails :
`TrackPopupMenu`, qui est modal) : le menu déroulant est lui aussi dans la `TrackPopupMenu`, qui est modal) : le menu déroulant est lui aussi dans la
bande `TOPMOST`, et se faisait donc recouvrir par la barrette qui venait de bande `TOPMOST`, et se faisait donc recouvrir par la barrette qui venait de
l'ouvrir. l'ouvrir.
- **Posée SUR la barre des tâches, elle disparaît quand celle-ci est active**
(clic sur Démarrer, clic sur la barre) et revient au premier clic sur une
autre fenêtre. Mesuré le 2026-09-22 : la barrette reste au bon endroit,
au-dessus de `Shell_TrayWnd` dans l'ordre des fenêtres, et n'est pas masquée
par DWM (`DWMWA_CLOAKED` = 0) — elle n'est simplement plus dessinée. La barre
des tâches active occupe une **bande réservée au shell**, au-dessus de tout
`TOPMOST` ordinaire. Deux contournements essayés, tous deux sans effet :
`NOTOPMOST` puis `TOPMOST` (les appels rendent True), et le même avec
`AttachThreadInput` sur le fil de la barre des tâches. Seul `uiAccess=true`
(exe signé, dans `C:\Program Files\`) y accède. **Assumé** : l'utilisateur a
choisi de garder sa position plutôt que d'être remonté automatiquement
(2026-09-22). Le battement ne redemande donc `TOPMOST` que lorsque le centre
de la barrette appartient à une autre fenêtre — utile contre les autres
fenêtres `TOPMOST`, impuissant contre le shell. **Deux pièges corrigés le
2026-09-28** : les fenêtres de **notre propre processus** ne comptent pas
comme un recouvrement (l'info-bulle d'un bouton se pose par-dessus la
barrette, et la remontée qu'elle déclenchait la refermait — boucle infinie
info-bulle / barrette invisible) ; et la remontée est un simple `TOPMOST`,
**sans détour par `NOTOPMOST`**, qui faisait passer la barrette sous les
fenêtres ordinaires un instant — un flash à chaque battement.
- **Traîner depuis un bouton** : les boutons sont subclassés (`GWLP_WNDPROC`, - **Traîner depuis un bouton** : les boutons sont subclassés (`GWLP_WNDPROC`,
une seule proc d'origine — même classe). Sur `WM_LBUTTONDOWN`, **`DragDetect`** une seule proc d'origine — même classe). Sur `WM_LBUTTONDOWN`, **`DragDetect`**
tranche entre clic et glissement en respectant le seuil réglé par tranche entre clic et glissement en respectant le seuil réglé par
@@ -493,7 +513,10 @@ faire semblant couterait plus que de l'admettre.
**Le reste du travail Android est dans **Le reste du travail Android est dans
[android-client/README.md](android-client/README.md)**, tenu a jour le [android-client/README.md](android-client/README.md)**, tenu a jour le
2026-09-17 : les trois facons d'ouvrir le coffre (verrouillage d'ecran, code 2026-09-28 — dont le formulaire d'edition de l'entree entiere, le scan du QR
2FA, la verification a l'ouverture au lieu du retelechargement, et deux pieges
de la page (`view` qui defocalisait les champs, `toast` qui etait un `<div>`) ;
et avant cela, le 2026-09-17 : les trois facons d'ouvrir le coffre (verrouillage d'ecran, code
d'application, mot de passe), la copie de secours `vault-recover.bin` qui d'application, mot de passe), la copie de secours `vault-recover.bin` qui
survit au schema retire, le scanner de QR (`ThirdParty/ZXing`), le define survit au schema retire, le scanner de QR (`ThirdParty/ZXing`), le define
`AllowCreateLocal` — creer un coffre sur le telephone est reserve au debogage — `AllowCreateLocal` — creer un coffre sur le telephone est reserve au debogage —
@@ -774,7 +797,10 @@ bureau virtuel** en mémoire (GDI, octets bruts, jamais sur disque) et le décod
par ZXing.Delphi — le même que l'APK, `android-client\ThirdParty\ZXing` dans le 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 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** 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` (`Bridge.onScreenQr(texte, trouvé)`), le reste de l'écran ne quitte pas l'exe.
Le second argument dit qu'**un QR a été lu** même s'il n'est pas un code 2FA :
« This QR code is not a 2FA code » plutôt que « aucun QR » (cas vu : un QR
**dynamique** de générateur en ligne encode un lien de suivi, pas le texte). Format `BGR32`
et non `BGRA32` : l'octet alpha d'une capture GDI vaut 0, lu comme transparent et non `BGRA32` : l'octet alpha d'une capture GDI vaut 0, lu comme transparent
→ image blanche. → image blanche.
@@ -1143,6 +1169,25 @@ QR que l'application Android scanne elle-meme.
- Telephone : `https://github.com/O/R` <-> URL d'API, comme pour Gitea. Le QR - Telephone : `https://github.com/O/R` <-> URL d'API, comme pour Gitea. Le QR
du PC porte aussi la forme courte `github.com/O/R`. du PC porte aussi la forme courte `github.com/O/R`.
### Ce que le depot laisse voir, et le reseau (2026-09-26)
- **Nom par defaut `data.json`** (`SYNC_DEFAULT_FILE`, JS et page du
telephone ; `SyncDefaultFile` dans le QR). `vault-sync.json` annoncait un
coffre a qui parcourt la liste des depots. Les configurations existantes
gardent leur nom : c'est l'URL complete qui est enregistree. **Le QR ne
raccourcit que le nom par defaut courant** — un autre nom voyage en URL
complete, sinon le telephone rallongerait vers `data.json` et les deux
appareils ne liraient pas le meme fichier.
- **Messages de commit neutres** : `update from desktop` / `update from phone`.
Le poste ecrivait `sync from <utilisateur du coffre>`, le telephone decrivait
ce qu'il faisait. Le detail reste dans le journal local.
- **8 s pour se connecter, 30 s pour repondre, trois essais sur status 0**
(poste et telephone). Mesure : `api.github.com` rend plusieurs adresses ;
`140.82.121.5` n'est joignable depuis ce reseau ni par l'application ni par un
navigateur, `140.82.121.6` repond en 0,22 s. Un navigateur passe a la
suivante aussitot ; nous attendions le delai entier. Un 404 ou un 409 sont
des reponses et ne relancent rien.
### Idee gardee : « Reinitialiser l'historique » du depot de synchro ### Idee gardee : « Reinitialiser l'historique » du depot de synchro
**Rien n'est fait, et le code actuel reste tel quel** (decide le 2026-09-17). **Rien n'est fait, et le code actuel reste tel quel** (decide le 2026-09-17).
@@ -1464,6 +1509,15 @@ après chaque `DELETE /attachments/{id}`. Un petit vault sain ne paie rien.
→ retombe sur pwd). → retombe sur pwd).
Fix clipboard : `MinimizeToTray(AClearClipboard=False)` via `keepclip=1` Fix clipboard : `MinimizeToTray(AClearClipboard=False)` via `keepclip=1`
pour ne pas effacer le pwd juste copié quand on re-minimise. pour ne pas effacer le pwd juste copié quand on re-minimise.
**Boutons par ligne** (2026-09-18) : utilisateur, mot de passe ou note, 2FA —
seulement ceux qui existent. Une **note est toujours copiée**, même en Fill :
tapée, chacun de ses retours à la ligne partirait comme un Entrée.
**Interrupteur Copy / Fill** (2026-09-28, `qsFills()`) : visible seulement
quand une fenêtre cible existe (Ctrl+Shift+Q, bouton Search de la barrette).
Depuis le tray, pas de cible — cliquer le tray donne le premier plan à la
barre des tâches — donc copie seule ; suivre « la dernière fenêtre active »
a été écarté : les frappes partiraient au mauvais endroit si l'utilisateur a
changé d'application entre-temps. Choix retenu en pref DPAPI `qsCopy`.
- **Custom fields combobox éditable** : les champs avec `options` (card - **Custom fields combobox éditable** : les champs avec `options` (card
brand, expiry…) rendent un `<input>` + menu custom `.so-combo` (flèche = brand, expiry…) rendent un `<input>` + menu custom `.so-combo` (flèche =
toutes les options, input = frappe libre). PAS un `<datalist>` natif toutes les options, input = frappe libre). PAS un `<datalist>` natif
@@ -1589,6 +1643,12 @@ le vide ET vide `FPendingURL` → écran noir permanent sur cold-start lent.
``` ```
Surcharge MSBuild, pas dans le `.dproj` : un chemin propre a une machine n'a Surcharge MSBuild, pas dans le `.dproj` : un chemin propre a une machine n'a
pas sa place dans un fichier versionne. **F9 dans l'IDE sort toujours sur Z:.** pas sa place dans un fichier versionne. **F9 dans l'IDE sort toujours sur Z:.**
**Build de livraison = `E:\password manager\build-both.cmd`** (hors depot, memes
raisons) : il appelle d'abord `backup-previous-exe.ps1`, qui range les exe
actuels (`Win32/PMServer.exe`, `Win64/PMServer.exe`, rien d'autre - ni
`vault.db` ni `config.txt`) dans `PMServer-previous.zip`, **supprime et recree
a chaque build** : la version d'avant reste toujours recuperable. Echec de la
sauvegarde = pas de build. Assets d'abord si le frontend a change.
**La sortie Android suit le meme chemin** : `Z:\password-manager\Android64` est **La sortie Android suit le meme chemin** : `Z:\password-manager\Android64` est
une **jonction** vers `E:\password manager\Android64`, posee le 2026-09-17 quand une **jonction** vers `E:\password manager\Android64`, posee le 2026-09-17 quand
`Z:` est tombe a 40 Ko libres et que `aapt` echouait sans dire pourquoi. `Z:` est tombe a 40 Ko libres et que `aapt` echouait sans dire pourquoi.
+60 -21
View File
@@ -61,6 +61,8 @@ type
FBaseUrl: string; FBaseUrl: string;
FToken: string; FToken: string;
FTimeoutMs: Integer; FTimeoutMs: Integer;
/// Attente de la POIGNEE DE MAIN, distincte de celle de la reponse.
FConnectMs: Integer;
FPrivacyChecked: Boolean; FPrivacyChecked: Boolean;
public public
constructor Create(const ABaseUrl, AToken: string); constructor Create(const ABaseUrl, AToken: string);
@@ -136,7 +138,7 @@ begin
FPrivacyChecked := True; FPrivacyChecked := True;
LHttp := THTTPClient.Create; LHttp := THTTPClient.Create;
try try
LHttp.ConnectionTimeout := FTimeoutMs; LHttp.ConnectionTimeout := FConnectMs;
LHttp.ResponseTimeout := FTimeoutMs; LHttp.ResponseTimeout := FTimeoutMs;
LHttp.HandleRedirects := False; LHttp.HandleRedirects := False;
LHttp.UserAgent := 'PMServer'; LHttp.UserAgent := 'PMServer';
@@ -193,7 +195,7 @@ begin
LRepo := LParts[1]; LRepo := LParts[1];
LHttp := THTTPClient.Create; LHttp := THTTPClient.Create;
try try
LHttp.ConnectionTimeout := FTimeoutMs; LHttp.ConnectionTimeout := FConnectMs;
LHttp.ResponseTimeout := FTimeoutMs; LHttp.ResponseTimeout := FTimeoutMs;
LHttp.HandleRedirects := False; LHttp.HandleRedirects := False;
LHttp.UserAgent := 'PMServer'; LHttp.UserAgent := 'PMServer';
@@ -328,22 +330,33 @@ begin
try try
LHttp := THTTPClient.Create; LHttp := THTTPClient.Create;
try try
LHttp.ConnectionTimeout := FTimeoutMs; LHttp.ConnectionTimeout := FConnectMs;
LHttp.ResponseTimeout := FTimeoutMs; LHttp.ResponseTimeout := FTimeoutMs;
LHttp.HandleRedirects := False; LHttp.HandleRedirects := False;
LHttp.UserAgent := 'PMServer'; // GitHub refuse toute requete sans LHttp.UserAgent := 'PMServer'; // GitHub refuse toute requete sans
LHttp.CustomHeaders['Connection'] := 'close'; // voir Fetch
LHttp.CustomHeaders['Authorization'] := 'token ' + FToken; LHttp.CustomHeaders['Authorization'] := 'token ' + FToken;
LHttp.CustomHeaders['Content-Type'] := 'application/json'; LHttp.CustomHeaders['Content-Type'] := 'application/json';
try // Deux essais, meme raison qu'a la lecture : un SYN perdu ne dit rien
LResp := LHttp.Put(FBaseUrl, LReq, LResync); // du serveur. Le corps est renvoye depuis le debut a chaque essai.
except LResp := nil;
on E: Exception do for var LTry := 1 to 3 do
begin begin
Result.Status := ssNetworkError; try
Result.Error := E.ClassName + ': ' + E.Message; LReq.Position := 0;
Exit; LResync.Clear;
LResp := LHttp.Put(FBaseUrl, LReq, LResync);
Break;
except
on E: Exception do
begin
Result.Status := ssNetworkError;
Result.Error := E.ClassName + ': ' + E.Message;
if LTry = 3 then Exit;
end;
end; end;
end; end;
if LResp = nil then Exit;
Result.HttpCode := LResp.StatusCode; Result.HttpCode := LResp.StatusCode;
case LResp.StatusCode of case LResp.StatusCode of
200, 201: 200, 201:
@@ -395,7 +408,21 @@ begin
inherited Create; inherited Create;
FBaseUrl := ABaseUrl.Trim; FBaseUrl := ABaseUrl.Trim;
FToken := AToken.Trim; FToken := AToken.Trim;
FTimeoutMs := 15000; // 15 s ne suffisaient pas a GitHub depuis ce telephone : un simple 404 a ete
// mesure a 16,3 s. Mais allonger seul ne suffit pas - mesure aussi : depuis
// le shell du meme telephone, le ping passe et le port 443 s'ouvre tout de
// suite, alors que la requete de l'application expire. Le lien perd des
// paquets (ping 71 a 236 ms), et c'est le SYN qui se perd. D'ou DEUX essais
// courts plutot qu'une seule longue attente, et un total qui reste sous le
// garde-fou de 50 s de l'ecran.
FTimeoutMs := 30000;
// COURT POUR SE CONNECTER, LONG POUR REPONDRE. Mesure sur ce telephone :
// api.github.com rend plusieurs adresses, 140.82.121.6 repond en 0,22 s et
// 140.82.121.5 n'est pas joignable du tout. Un navigateur passe a la
// suivante aussitot ; l'application attendait son delai entier sur la
// mauvaise. Huit secondes suffisent a une adresse qui repond, et trois
// essais laissent le temps de tomber sur une bonne.
FConnectMs := 8000;
end; end;
function TSyncClient.Fetch(const AKnownETag: string): TSyncResult; function TSyncClient.Fetch(const AKnownETag: string): TSyncResult;
@@ -421,9 +448,14 @@ begin
LHttp := THTTPClient.Create; LHttp := THTTPClient.Create;
try try
LHttp.ConnectionTimeout := FTimeoutMs; LHttp.ConnectionTimeout := FConnectMs;
LHttp.ResponseTimeout := FTimeoutMs; LHttp.ResponseTimeout := FTimeoutMs;
LHttp.UserAgent := 'PMServer'; // GitHub refuse toute requete sans LHttp.UserAgent := 'PMServer'; // GitHub refuse toute requete sans
// PAS DE CONNEXION REUTILISEE. Le telephone change de reseau (Wi-Fi qui
// se reconnecte, IP qui change) et la pile Java garde des sockets ouverts
// sur l'interface morte : la requete suivante attend son delai entier
// pour rien. Une poignee de main de plus coute bien moins que cela.
LHttp.CustomHeaders['Connection'] := 'close';
// Pas de redirection suivie : une redirection vers un autre hote nous // Pas de redirection suivie : une redirection vers un autre hote nous
// ferait envoyer le jeton ailleurs. // ferait envoyer le jeton ailleurs.
LHttp.HandleRedirects := False; LHttp.HandleRedirects := False;
@@ -443,16 +475,23 @@ begin
LStream := TStringStream.Create('', TEncoding.UTF8); LStream := TStringStream.Create('', TEncoding.UTF8);
try try
try LResp := nil;
LResp := LHttp.Get(FBaseUrl, LStream); for var LTry := 1 to 3 do
except begin
on E: Exception do try
begin LResp := LHttp.Get(FBaseUrl, LStream);
Result.Status := ssNetworkError; Break;
Result.Error := E.ClassName + ': ' + E.Message; except
Exit; on E: Exception do
begin
Result.Status := ssNetworkError;
Result.Error := E.ClassName + ': ' + E.Message;
if LTry = 3 then Exit;
LStream.Clear;
end;
end; end;
end; end;
if LResp = nil then Exit;
Result.HttpCode := LResp.StatusCode; Result.HttpCode := LResp.StatusCode;
Result.ETag := LResp.HeaderValue['ETag']; Result.ETag := LResp.HeaderValue['ETag'];
+3
View File
@@ -257,6 +257,9 @@ begin
LRoot := TJSONObject.Create; LRoot := TJSONObject.Create;
try try
LRoot.AddPair('title', AEntry.Display); LRoot.AddPair('title', AEntry.Display);
// L'icone du moment, pour que le formulaire montre ce qu'il va changer.
LRoot.AddPair('icon', AEntry.IconB64);
LRoot.AddPair('initial', Initial(AEntry.Display));
LRoot.AddPair('totp', TJSONBool.Create(AEntry.Totp <> '')); LRoot.AddPair('totp', TJSONBool.Create(AEntry.Totp <> ''));
LArr := TJSONArray.Create; LArr := TJSONArray.Create;
LRoot.AddPair('rows', LArr); LRoot.AddPair('rows', LArr);
+75
View File
@@ -341,6 +341,81 @@ reste.
(code, empreinte) **dans le service** avant de proposer quoi que ce soit : le (code, empreinte) **dans le service** avant de proposer quoi que ce soit : le
coffre ne vit que dans l'app, ouverte ou non. coffre ne vit que dans l'app, ouverte ou non.
## Modifier une entrée — 2026-09-25 à 09-28
**Un formulaire pour toute l'entrée**, ouvert par le crayon de l'en-tête. Le
crayon par ligne a disparu : chaque validation était un enregistrement complet,
donc un commit de plus dans le dépôt.
- **Un seul envoi, seulement ce qui a changé** : `setfields?f0=&v0=&f1=&v1=…`
→ `SaveEntryFields`, plusieurs `PatchEntryField` dans **un** `CommitChange`.
Le filtre des noms est celui de `SaveEntryField` (ce qui traverse le pont se
traite comme une entrée extérieure).
- **La page ne connaît pas le schéma** : elle rend une ligne par champ que
Delphi a marqué modifiable (`e` porte son nom, dans `DetailToJson`). Le champ
`folder` est une liste (`folderSelect`, avec *+ new folder*), pas un champ
libre — une faute de frappe rangerait l'entrée dans un dossier invisible.
- **Mot de passe et secret 2FA naissent masqués** (création et édition), avec
un œil (`data-eye`) pour les révéler.
- **Icône** en tête du formulaire, trois boutons : *From site* (recherche
automatique), *Image…* (sélecteur de photos d'Android via
`TTakePhotoFromLibraryAction`, réduite à 128 px, provenance **custom** — le
poste ne la remplacera jamais par une recherche), *Remove*. Une action
d'icône passe par un enregistrement : le formulaire est rouvert ensuite avec
ce qui y était tapé (`editAgain`). Manifeste : `READ_MEDIA_IMAGES`, et
`READ_EXTERNAL_STORAGE` bornée à `maxSdkVersion="32"`.
- **Scanner le QR 2FA** d'un site : bouton QR du champ 2FA →
`scanqr?for=totp` → même caméra que le scan des réglages (`FScanTotp`). Le
lien `otpauth://` part à la page (`PM.totpFromQr`), qui n'en garde que le
secret ; le journal ne note que le fait. Un autre QR fait dire au bandeau
« pas un code 2FA » et le scan continue.
- **Pas de menu ⋮ ni de crayon sur une entrée en création** : l'écran de
détail est réutilisé, et côté Delphi ces commandes visent la **dernière
entrée ouverte** — *Delete* depuis la création en aurait supprimé une autre.
### Piège : `view` nomme l'écran, pas ce qu'on y fait
Un gardien `focusin` défocalise tout ce qui prend le focus hors de l'écran
courant en comparant `view` à l'**identifiant de l'écran**. Le formulaire vit
dans l'écran `detail` ; poser `view = 'edit'` le faisait donc défocaliser
**chaque champ à l'instant où il prenait le focus** — clavier ouvert, pas de
curseur, rien ne rentrait. Trouvé en inspectant la page en direct par le
protocole DevTools (APK debug, `adb forward … localabstract:webview_devtools_remote_…`) :
le champ était atteignable et focalisé, ce qui éliminait le DOM et le CSS.
`view` reste `detail`, un drapeau `editing` dit qu'on modifie, et la flèche
comme la touche retour referment le formulaire avant de quitter l'entrée.
### Piège : `toast` était un `<div>`
Le navigateur expose chaque `id` comme variable globale : `toast` désignait
l'élément `<div id="toast">`, et **sept appels `toast('…')` de la page levaient
« toast is not a function »** — le message ne s'affichait pas et le reste du
gestionnaire ne s'exécutait pas (dont « Tap again to delete »). Une vraie
`function toast(text)` globale l'emporte sur l'id.
## Synchro : ce qui a changé — 2026-09-25 à 09-26
- **L'ouverture ne tire plus l'instantané.** Elle ouvre depuis le cache, puis
`ProbeRemote` pose une requête conditionnelle : si le dépôt a bougé, le
témoin **bleu** du bouton Sync s'allume et l'utilisateur tire quand il veut.
Le journal dit les trois cas : `unchanged`, `it moved`, `no answer (HTTP 0)`
— ce dernier n'est pas « rien n'a changé ».
- **Dépôt vide = création.** Un 404 à la lecture pendant une publication était
traité comme un échec : rien ne partait jamais vers un dépôt neuf. Sans `sha`,
l'API des contenus crée le fichier.
- **Réseau : 8 s pour se connecter, 30 s pour répondre, trois essais**, et
`Connection: close`. Mesuré : `api.github.com` rend plusieurs adresses,
`140.82.121.6` répond en 0,22 s et `140.82.121.5` n'est joignable depuis ce
réseau **ni par l'application ni par un navigateur**. Un navigateur passe à
la suivante aussitôt ; l'application attendait son délai entier. Le poste
applique la même règle.
- **Pas de clavier de recherche pendant une synchro** : synchroniser coffre
fermé ouvre d'abord le coffre, et ce chemin dépliait le clavier.
- **Messages de commit neutres** : `update from phone` partout (le téléphone
écrivait `new entry from phone`, `icon fetched…`, `entries deleted…`). Le
détail reste dans le journal local.
- **Nom par défaut `data.json`** au lieu de `vault-sync.json` — voir CLAUDE.md.
## Saisie : ce qui a résisté ## Saisie : ce qui a résisté
- **`user-select: none` du `body`** était hérité par les champs : le curseur ne - **`user-select: none` du `body`** était hérité par les champs : le curseur ne
+287 -122
View File
@@ -98,7 +98,10 @@
.search { flex: 1; position: relative; } .search { flex: 1; position: relative; }
.search input { .search input {
width: 100%; height: 46px; padding: 0 38px 0 40px; width: 100%; height: 46px; padding: 0 38px 0 40px;
border: 0; border-radius: var(--radius-s); /* MEME ARRONDI QUE LES PASTILLES DE DOSSIER. Trois rayons differents se
voyaient d'un coup d'oeil sur le meme ecran : la recherche, les
pastilles, les boutons. Les deux premiers se touchent presque. */
border: 0; border-radius: 999px;
background: rgba(255,255,255,.10); color: var(--text); background: rgba(255,255,255,.10); color: var(--text);
font-size: 15px; outline: none; font-size: 15px; outline: none;
} }
@@ -153,8 +156,9 @@
text-overflow: ellipsis; white-space: nowrap; } text-overflow: ellipsis; white-space: nowrap; }
.txt span { display: block; color: var(--muted); font-size: 13px; .txt span { display: block; color: var(--muted); font-size: 13px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Les boutons prennent le meme arrondi que la recherche et les pastilles. */
.btn { .btn {
flex: 0 0 auto; border: 0; border-radius: var(--radius-s); flex: 0 0 auto; border: 0; border-radius: 999px;
background: var(--raised); color: var(--text); background: var(--raised); color: var(--text);
padding: 11px 13px; font-size: 13px; font-family: inherit; padding: 11px 13px; font-size: 13px; font-family: inherit;
} }
@@ -229,6 +233,7 @@
/* --- detail ------------------------------------------------------------ */ /* --- detail ------------------------------------------------------------ */
.head { display: flex; align-items: center; gap: 10px; padding: 10px 12px; } .head { display: flex; align-items: center; gap: 10px; padding: 10px 12px; }
.head .av { width: 30px; height: 30px; font-size: 13px; flex: 0 0 auto; }
.head h1 { flex: 1; margin: 0; font-size: 19px; font-weight: 600; .head h1 { flex: 1; margin: 0; font-size: 19px; font-weight: 600;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.field { .field {
@@ -309,6 +314,32 @@
jamais - la fin d'une URL longue restait hors d'atteinte. */ jamais - la fin d'une URL longue restait hors d'atteinte. */
-webkit-user-select: text; user-select: text; -webkit-user-select: text; user-select: text;
} }
/* UN FORMULAIRE EST UNE SEULE CARTE. Sept champs faisaient sept cartes,
chacune avec son fond et ses coins - un empilement de rectangles ou l'oeil
ne voit plus le formulaire. Ici une carte, et des lignes dedans. */
.form { background: var(--surface); border-radius: var(--radius);
padding: 0 14px; margin-bottom: 8px; }
.form .field { background: none; border-radius: 0; margin: 0;
padding: 8px 0 6px;
border-bottom: 1px solid rgba(255,255,255,.07); }
.form .field:last-child { border-bottom: 0; }
.form .field .field-in { border-bottom: 0; }
/* Le de a la meme hauteur que le champ qu'il accompagne, sinon il etire la
ligne et le trait de saisie remonte tout seul. */
.form .field .btn.ico-btn { width: 38px; min-width: 38px; height: 38px; }
/* UNE CARTE NE CONTIENT PAS UNE SECONDE BOITE. La carte .field porte deja un
fond et des coins arrondis ; le champ qui s'y pose n'a donc besoin ni de
l'un ni de l'autre - juste d'un trait qui dise ou l'on ecrit, et qui
s'allume quand le doigt s'y pose. Hors carte (reglages, deverrouillage),
.field-in garde sa boite : la, rien d'autre ne la dessine. */
.field .field-in {
background: transparent; border-radius: 0; padding-left: 0; padding-right: 0;
height: 38px; margin-top: 0;
border-bottom: 1px solid rgba(255,255,255,.16);
}
.field .field-in:focus { border-bottom-color: var(--accent); }
.field select.field-in { padding-left: 0; }
.field textarea.field-in { padding: 6px 0; }
.field-in.multi { height: auto; min-height: 46px; overflow: hidden; padding: 12px 44px 12px 14px; resize: none; .field-in.multi { height: auto; min-height: 46px; overflow: hidden; padding: 12px 44px 12px 14px; resize: none;
line-height: 1.35; word-break: break-all; display: block; } line-height: 1.35; word-break: break-all; display: block; }
.field-in::placeholder { color: var(--muted); } .field-in::placeholder { color: var(--muted); }
@@ -558,7 +589,9 @@
<div class="screen" id="detail"> <div class="screen" id="detail">
<div class="head"> <div class="head">
<button class="btn ico-btn back-btn" id="back"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-label="Back" role="img"><path d="M20 12H5M11 5l-7 7 7 7"/></svg></button> <button class="btn ico-btn back-btn" id="back"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-label="Back" role="img"><path d="M20 12H5M11 5l-7 7 7 7"/></svg></button>
<div class="av" id="dav" hidden></div>
<h1 id="dtitle"></h1> <h1 id="dtitle"></h1>
<button class="btn ico-btn" id="pencil" aria-label="Edit entry"></button>
<button class="btn ico-btn" id="kebab" aria-label="Entry menu"></button> <button class="btn ico-btn" id="kebab" aria-label="Entry menu"></button>
</div> </div>
<div class="scroll"><div id="fields"></div></div> <div class="scroll"><div id="fields"></div></div>
@@ -1010,6 +1043,8 @@ var ICONS = {
+ '<circle cx="9" cy="9" r="1.2"/><circle cx="15" cy="15" r="1.2"/>' + '<circle cx="9" cy="9" r="1.2"/><circle cx="15" cy="15" r="1.2"/>'
+ '<circle cx="15" cy="9" r="1.2"/><circle cx="9" cy="15" r="1.2"/>', + '<circle cx="15" cy="9" r="1.2"/><circle cx="9" cy="15" r="1.2"/>',
close: '<path d="M6 6l12 12M18 6L6 18"/>', close: '<path d="M6 6l12 12M18 6L6 18"/>',
qr: '<rect x="4" y="4" width="6" height="6" rx="1"/><rect x="14" y="4" width="6" height="6" rx="1"/>'
+ '<rect x="4" y="14" width="6" height="6" rx="1"/><path d="M14 14h2v2h-2zM20 14v.01M14 20h.01M17 20h3v-3"/>',
// Un arc, pas un cercle : c'est l'ouverture qui rend la rotation visible. // Un arc, pas un cercle : c'est l'ouverture qui rend la rotation visible.
loader: '<path d="M12 3a9 9 0 019 9"/>', loader: '<path d="M12 3a9 9 0 019 9"/>',
eyeOff: '<path d="M4 4l16 16"/>' eyeOff: '<path d="M4 4l16 16"/>'
@@ -1040,6 +1075,13 @@ function renderDetail(d) {
lastDetail = d; lastDetail = d;
detailRows = d.rows || []; detailRows = d.rows || [];
document.getElementById('dtitle').textContent = d.title || ''; document.getElementById('dtitle').textContent = d.title || '';
document.getElementById('kebab').hidden = false;
document.getElementById('pencil').hidden = false;
editing = false;
var dav = document.getElementById('dav');
dav.hidden = false;
dav.className = 'av' + (d.icon ? ' has-img' : '');
dav.innerHTML = d.icon ? '<img src="' + esc(d.icon) + '">' : esc(d.initial || '');
var html = '', i; var html = '', i;
if (d.totp) if (d.totp)
html += '<div class="field totp" id="totp"><div class="cap">2FA code</div>' html += '<div class="field totp" id="totp"><div class="cap">2FA code</div>'
@@ -1064,8 +1106,6 @@ function renderDetail(d) {
+ (r.a === undefined ? '' + (r.a === undefined ? ''
: '<button class="btn ico-btn" data-save="' + i + '">' : '<button class="btn ico-btn" data-save="' + i + '">'
+ ico('down', 'Save to Downloads') + '</button>') + ico('down', 'Save to Downloads') + '</button>')
+ (r.e ? '<button class="btn ico-btn" data-edit="' + i + '">'
+ ico('edit', 'Edit') + '</button>' : '')
+ '</div></div>'; + '</div></div>';
} }
document.getElementById('fields').innerHTML = html; document.getElementById('fields').innerHTML = html;
@@ -1073,9 +1113,13 @@ function renderDetail(d) {
document.body.classList.add('on-detail'); document.body.classList.add('on-detail');
view = 'detail'; view = 'detail';
send('state?view=detail'); send('state?view=detail');
// Retour au formulaire apres une action d'icone : elle passe par un
// enregistrement, donc par ce rendu-ci.
if (editAgain) openEditEntry();
} }
document.getElementById('fields').addEventListener('click', function (ev) { document.getElementById('fields').addEventListener('click', function (ev) {
// Meme raison que dans la liste : le dessin recoit l'appui, le bouton porte // Meme raison que dans la liste : le dessin recoit l'appui, le bouton porte
// l'intention. Tout ce qui suit lit et ecrit sur le bouton. // l'intention. Tout ce qui suit lit et ecrit sur le bouton.
var t = ev.target.closest('button') || ev.target; var t = ev.target.closest('button') || ev.target;
@@ -1099,49 +1143,34 @@ document.getElementById('fields').addEventListener('click', function (ev) {
send('touch'); send('touch');
return; return;
} }
var eye = t.getAttribute('data-eye');
if (eye) {
var ef = document.getElementById(eye);
if (!ef) return;
var shown = ef.type === 'text';
ef.type = shown ? 'password' : 'text';
t.innerHTML = ico(shown ? 'eye' : 'eyeOff', shown ? 'Show' : 'Hide');
send('touch');
return;
}
var sc = t.getAttribute('data-scan');
if (sc) { totpTarget = sc; send('scanqr?for=totp'); return; }
var ia = t.getAttribute('data-iconact');
if (ia !== null) {
// L'ICONE SE COMMET COMME LE RESTE, donc Delphi rouvre l'entree et la vue
// de lecture reprenait la place - on se retrouvait dehors sans avoir fini.
// Ce drapeau dit a renderDetail de rendre la main au formulaire, avec ce
// qui y etait deja tape.
editAgain = collectEditValues();
send('icon?a=' + ia);
return;
}
var save = t.getAttribute('data-save'); var save = t.getAttribute('data-save');
if (save !== null) { send('savefile?k=' + save); return; } if (save !== null) { send('savefile?k=' + save); return; }
var copy = t.getAttribute('data-copy'); var copy = t.getAttribute('data-copy');
if (copy === 'totp') { send('copy?f=totp'); return; } if (copy === 'totp') { send('copy?f=totp'); return; }
if (copy === 'row') { send('copy?f=row&k=' + t.getAttribute('data-k')); return; } if (copy === 'row') { send('copy?f=row&k=' + t.getAttribute('data-k')); return; }
var edit = t.getAttribute('data-edit');
if (edit !== null) { openEditor(edit); return; }
if (t.getAttribute('data-editshow') !== null) {
// Masque par defaut, revelable a la demande : on tape un mot de passe
// qu'on ne peut relire nulle part ailleurs, mais il n'a pas a rester
// expose a l'ecran pendant qu'on cherche le bouton.
var iv = document.getElementById('editval');
var hid = iv.type === 'password';
iv.type = hid ? 'text' : 'password';
t.innerHTML = ico(hid ? 'eyeOff' : 'eye', hid ? 'Hide' : 'Show');
return;
}
if (t.getAttribute('data-editcancel') !== null) { renderDetail(lastDetail); return; }
if (t.getAttribute('data-editsave') !== null) {
var input = document.getElementById('editval');
if (!input) return;
// VIDER EST LEGITIME sur un site ou un identifiant - pas sur un mot de
// passe, ou ce serait presque toujours une fausse manoeuvre. Le refus se
// dit, sinon le bouton semble ne pas prendre l'appui.
if (!input.value && editField === 'password') {
toast('An empty password is not saved');
return;
}
// DIRE QUE CA TRAVAILLE. L'enregistrement dechiffre, rechiffre et publie :
// une a deux secondes pendant lesquelles rien ne bougeait, et le bouton
// semblait n'avoir pas pris l'appui. La coche cede la place a un arc qui
// tourne - immobile, elle laisserait croire que c'est fini.
t.innerHTML = ico('loader', 'Saving');
t.classList.add('spin');
t.disabled = true;
var cancel = document.querySelector('[data-editcancel]');
if (cancel) cancel.disabled = true;
input.disabled = true;
send('setfield?f=' + encodeURIComponent(editField)
+ '&v=' + encodeURIComponent(input.value));
return;
}
}); });
/// LE DOSSIER SE CHOISIT, il ne se tape pas. /// LE DOSSIER SE CHOISIT, il ne se tape pas.
@@ -1153,40 +1182,22 @@ document.getElementById('fields').addEventListener('click', function (ev) {
/// ///
/// 'All' est ajoute d'office : c'est le dossier par defaut du poste, et il ne /// 'All' est ajoute d'office : c'est le dossier par defaut du poste, et il ne
/// figure pas toujours dans la liste. /// figure pas toujours dans la liste.
function editorControl(k) { /// La liste des dossiers du coffre, pour le champ 'folder' du formulaire.
var val = detailRows[k].v; /// Le dossier courant y est ajoute s'il n'y figure plus - renomme ailleurs, ou
var style = ' style="margin:0;flex:1;min-width:0;width:auto"'; /// venu d'un import -, sinon l'ouvrir suffirait a le remplacer en silence.
// UNE ZONE DE TEXTE POUR CE QUI TIENT SUR PLUSIEURS LIGNES. Un <input> function folderSelect(val) {
// applique la sanitisation de HTML et retire les retours a la ligne en var names = ['All'], i, html;
// silence : le corps d'une note y perdait sa mise en forme, et la perte
// partait au depot a l'enregistrement suivant.
if (detailRows[k].m)
return '<textarea class="field-in" id="editval" rows="6"'
+ ' enterkeyhint="enter" autocorrect="off" spellcheck="false"'
+ ' style="margin:0;flex:1;min-width:0;width:auto;height:auto;'
+ 'padding:10px 14px;resize:vertical"></textarea>';
if (editField !== 'folder')
return '<input class="field-in" id="editval" autocomplete="off"'
+ ' type="' + (detailRows[k].s ? 'password' : 'text') + '"'
+ ' autocorrect="off" autocapitalize="none" spellcheck="false"'
+ style + '>';
var names = ['All'], i;
for (i = 0; i < folders.length; i++) for (i = 0; i < folders.length; i++)
if (folders[i] && names.indexOf(folders[i]) < 0) names.push(folders[i]); if (folders[i] && names.indexOf(folders[i]) < 0) names.push(folders[i]);
// Le dossier courant peut ne plus figurer dans la liste - il a ete renomme
// ailleurs, ou l'entree vient d'un import. Le proposer quand meme evite de
// le remplacer silencieusement en ouvrant simplement l'editeur.
if (val && names.indexOf(val) < 0) names.push(val); if (val && names.indexOf(val) < 0) names.push(val);
html = '<select class="field-in" data-ef="folder" style="' + FIELD_STYLE + '">';
var html = '<select class="field-in" id="editval"' + style + '>';
for (i = 0; i < names.length; i++) for (i = 0; i < names.length; i++)
html += '<option value="' + esc(names[i]) + '"' html += '<option value="' + esc(names[i]) + '"'
+ (names[i] === val ? ' selected' : '') + '>' + esc(names[i]) + (names[i] === val ? ' selected' : '') + '>' + esc(names[i])
+ '</option>'; + '</option>';
// CHOISIR NE SUFFIT PAS : le premier dossier d'un coffre n'existe pas encore, // CHOISIR NE SUFFIT PAS : le premier dossier d'un coffre n'existe pas encore,
// et rien ne permettait de le nommer. Cette option echange la liste contre un // et rien ne permettait de le nommer. Cette option echange la liste contre un
// champ libre, sans quitter l'editeur. // champ libre, sans quitter le formulaire.
html += '<option value="' + NEW_FOLDER + '">+ new folder</option>'; html += '<option value="' + NEW_FOLDER + '">+ new folder</option>';
return html + '</select>'; return html + '</select>';
} }
@@ -1196,11 +1207,12 @@ var NEW_FOLDER = '\u0001new';
// Le choix "+ new folder" remplace la liste par un champ. Delegue sur #fields // Le choix "+ new folder" remplace la liste par un champ. Delegue sur #fields
// parce que le controle nait et meurt avec l'editeur. // parce que le controle nait et meurt avec l'editeur.
document.getElementById('fields').addEventListener('change', function (ev) { document.getElementById('fields').addEventListener('change', function (ev) {
if (ev.target.id !== 'editval' || ev.target.value !== NEW_FOLDER) return; if (ev.target.getAttribute('data-ef') !== 'folder'
|| ev.target.value !== NEW_FOLDER) return;
var line = ev.target.parentNode; var line = ev.target.parentNode;
var input = document.createElement('input'); var input = document.createElement('input');
input.className = 'field-in'; input.className = 'field-in';
input.id = 'editval'; input.setAttribute('data-ef', 'folder');
input.setAttribute('placeholder', 'folder name'); input.setAttribute('placeholder', 'folder name');
input.setAttribute('autocomplete', 'off'); input.setAttribute('autocomplete', 'off');
input.setAttribute('autocapitalize', 'none'); input.setAttribute('autocapitalize', 'none');
@@ -1225,44 +1237,6 @@ document.getElementById('fields').addEventListener('change', function (ev) {
input.focus(); input.focus();
}); });
// EDITEUR EN PLACE, pas window.prompt : une invite JavaScript demande au
// moteur un WebChromeClient que FMX ne fournit pas, et prompt() rend alors
// null sans un mot - le bouton semblerait ne rien faire.
//
// La valeur s'affiche EN CLAIR pendant la saisie. On tape un mot de passe
// qu'on ne peut pas relire ailleurs ; le masquer ferait valider a l'aveugle
// une frappe qu'aucune confirmation ne rattrape.
var editField = '';
function openEditor(k) {
var row = document.querySelector('[data-v="' + k + '"]');
if (!row) return;
// 'e' porte le nom du champ dans l'instantane, pas un booleen : c'est Delphi
// qui decide de ce qui se modifie, la page n'a pas a le deduire d'un libelle.
editField = detailRows[k].e || '';
if (!editField) return;
var line = row.parentNode;
line.classList.toggle('wrap', !!detailRows[k].m);
line.innerHTML = editorControl(k)
+ (editField === 'password' ? genButton('editval') : '')
// Show n'a de sens que sur un champ masque. Sur un site en clair il
// n'aurait rien a reveler - et l'inverse : il le CACHERAIT.
//
// LES TROIS EN ICONES, comme les lignes au-dessus : ils partageaient la
// rangee avec le champ de saisie, et trois mots y laissaient a peine de
// quoi lire ce qu'on tape. La croix et la coche se lisent sans traduction.
+ (detailRows[k].s ? '<button class="btn ico-btn" data-editshow>'
+ ico('eye', 'Show') + '</button>' : '')
+ '<button class="btn ico-btn" data-editcancel>'
+ ico('close', 'Cancel') + '</button>'
+ '<button class="btn ico-btn main" data-editsave>'
+ ico('check', 'Save') + '</button>';
var input = document.getElementById('editval');
input.value = detailRows[k].v;
input.focus();
send('touch');
}
// RIEN NE GLISSE DE COTE, sauf la bande de dossiers. // RIEN NE GLISSE DE COTE, sauf la bande de dossiers.
// //
// overflow:hidden n'interdit que le GESTE. Le moteur, lui, garde le droit de // overflow:hidden n'interdit que le GESTE. Le moteur, lui, garde le droit de
@@ -1347,8 +1321,9 @@ addEventListener('keydown', function (ev) {
if (el.tagName === 'TEXTAREA') return; if (el.tagName === 'TEXTAREA') return;
ev.preventDefault(); ev.preventDefault();
el.blur(); el.blur();
if (el.id !== 'editval') return; // Entree sur un champ du formulaire = enregistrer, comme la coche d'avant.
var save = document.querySelector('[data-editsave]'); if (!el.getAttribute('data-ef')) return;
var save = document.getElementById('eSave');
if (save && !save.disabled) save.click(); if (save && !save.disabled) save.click();
}); });
@@ -1359,6 +1334,17 @@ addEventListener('keydown', function (ev) {
// defile. Une entree qu'on cree et une entree qu'on consulte occupent la meme // defile. Une entree qu'on cree et une entree qu'on consulte occupent la meme
// place a l'ecran, et c'est exactement ce que cette vue veut dire. // place a l'ecran, et c'est exactement ce que cette vue veut dire.
var newKind = 'login'; var newKind = 'login';
// LE CLAVIER NE MONTE PAS TOUT SEUL. Le champ prend bien le focus - mesure
// faite : l'appui arrive et focusin part -, mais la vue web est posee dans une
// activite FMX, et le systeme n'ouvre le clavier que si on le lui demande.
// C'est deja ce que fait la recherche ; les champs du formulaire passent par
// la meme porte.
document.addEventListener('focusin', function (ev) {
var el = ev.target;
if (!el.classList || !el.classList.contains('field-in')) return;
if (el.tagName === 'SELECT') return; // il ouvre sa propre liste
send('kbd');
}, true);
/// Le dossier a la creation. Le meme choix que dans l'editeur, et pour la /// Le dossier a la creation. Le meme choix que dans l'editeur, et pour la
/// meme raison : une entree rangee des le depart evite d'avoir a la deplacer. /// meme raison : une entree rangee des le depart evite d'avoir a la deplacer.
@@ -1407,6 +1393,29 @@ function genPassword(n) {
} }
return out; return out;
} }
/// AFFICHER / MASQUER. Le champ nait masque : on ouvre un formulaire dans un
/// lieu public autant qu'on le remplit, et le mot de passe n'a pas a
/// s'afficher tant qu'on ne le demande pas. Un appui le revele pour relire ce
/// qu'on tape.
function eyeButton(target) {
return '<button class="btn ico-btn" data-eye="' + target + '">'
+ ico('eye', 'Show') + '</button>';
}
/// SCANNER LE QR QU'AFFICHE LE SITE a l'activation de la 2FA. La camera est
/// celle du scan des reglages ; seul ce qu'elle cherche change.
function scanButton(target) {
return '<button class="btn ico-btn" data-scan="' + target + '">'
+ ico('qr', 'Scan the 2FA QR code') + '</button>';
}
/// UN VRAI toast() GLOBAL. Sans lui, le nom designait l'element <div
/// id="toast"> - le navigateur expose chaque id comme variable globale - et
/// chaque appel toast('...') de cette page levait "toast is not a function" :
/// le message ne s'affichait pas, et le reste du gestionnaire ne s'executait
/// pas davantage. Une declaration de fonction l'emporte sur l'id.
function toast(text) { PM.toast(text); }
function genButton(target) { function genButton(target) {
return '<button class="btn ico-btn" data-gen="' + target + '">' return '<button class="btn ico-btn" data-gen="' + target + '">'
+ ico('dice', 'Generate') + '</button>'; + ico('dice', 'Generate') + '</button>';
@@ -1417,17 +1426,26 @@ function newBodyControl(kind) {
return '<textarea class="field-in" id="nPwd" rows="6" enterkeyhint="enter"' return '<textarea class="field-in" id="nPwd" rows="6" enterkeyhint="enter"'
+ ' autocorrect="off" spellcheck="false" style="' + FIELD_STYLE + ' autocorrect="off" spellcheck="false" style="' + FIELD_STYLE
+ ';height:auto;padding:10px 14px;resize:vertical"></textarea>'; + ';height:auto;padding:10px 14px;resize:vertical"></textarea>';
return '<input class="field-in" id="nPwd" type="text" autocomplete="off"' return '<input class="field-in" id="nPwd" type="password" autocomplete="off"'
+ ' autocorrect="off" autocapitalize="none" spellcheck="false"' + ' autocorrect="off" autocapitalize="none" spellcheck="false"'
+ ' style="' + FIELD_STYLE + '">' + genButton('nPwd'); + ' style="' + FIELD_STYLE + '">' + eyeButton('nPwd') + genButton('nPwd');
} }
function openNewEntry() { function openNewEntry() {
newKind = 'login'; newKind = 'login';
detailRows = []; detailRows = [];
document.getElementById('dtitle').textContent = 'New entry'; document.getElementById('dtitle').textContent = 'New entry';
// PAS DE MENU SUR UNE ENTREE QUI N'EXISTE PAS ENCORE : l'ecran de detail est
// reutilise pour la creation, et son menu proposait Duplicate et Delete -
// qui, cote Delphi, visent la derniere entree ouverte.
closeMenu();
editing = false;
document.getElementById('kebab').hidden = true;
document.getElementById('pencil').hidden = true;
document.getElementById('dav').hidden = true;
document.getElementById('fields').innerHTML = document.getElementById('fields').innerHTML =
'<div class="field"><div class="cap">Kind</div><div class="line">' '<div class="form">'
+ '<div class="field"><div class="cap">Kind</div><div class="line">'
+ '<button class="btn main" data-kind="login">Login</button>' + '<button class="btn main" data-kind="login">Login</button>'
+ '<button class="btn" data-kind="note">Note</button></div></div>' + '<button class="btn" data-kind="note">Note</button></div></div>'
+ newFolderField() + newFolderField()
@@ -1442,12 +1460,17 @@ function openNewEntry() {
// deja : on pouvait poser un second facteur sur une entree existante, pas // deja : on pouvait poser un second facteur sur une entree existante, pas
// sur une entree qu'on venait de creer - il fallait enregistrer puis // sur une entree qu'on venait de creer - il fallait enregistrer puis
// rouvrir. Meme intitule que l'editeur pour qu'on reconnaisse le champ. // rouvrir. Meme intitule que l'editeur pour qu'on reconnaisse le champ.
+ newField('nTotp', '2FA secret', 'nTotpRow') + '<div class="field" id="nTotpRow"><div class="cap">2FA secret</div>'
+ '<div class="field"><div class="line">' + '<div class="line"><input class="field-in" id="nTotp" type="password"'
+ ' autocomplete="off" autocorrect="off" autocapitalize="characters"'
+ ' spellcheck="false" style="' + FIELD_STYLE + '">'
+ eyeButton('nTotp') + scanButton('nTotp') + '</div></div>'
+ '</div>'
+ '<div class="line" style="display:flex;margin-top:12px">'
// flex:1 et non .wide : .wide ne regle que le rembourrage, et la largeur // flex:1 et non .wide : .wide ne regle que le rembourrage, et la largeur
// pleine vient d'etre etire dans son conteneur. // pleine vient d'etre etire dans son conteneur.
+ '<button class="btn main wide" id="nSave" style="flex:1">Save</button>' + '<button class="btn main wide" id="nSave" style="flex:1">Save</button>'
+ '</div></div>'; + '</div>';
document.body.classList.remove('on-list'); document.body.classList.remove('on-list');
document.body.classList.add('on-detail'); document.body.classList.add('on-detail');
view = 'detail'; view = 'detail';
@@ -1531,13 +1554,127 @@ function closeMenu() {
function openMenu() { function openMenu() {
var m = document.getElementById('menu'); var m = document.getElementById('menu');
m.innerHTML = '<button data-hist>Previous passwords</button>' m.innerHTML = '<button data-hist>Previous passwords</button>'
+ '<button data-icon="fetch">Fetch icon</button>'
+ '<button data-icon="clear">Remove icon</button>'
+ '<button data-dup>Duplicate entry</button>' + '<button data-dup>Duplicate entry</button>'
+ '<button class="danger" data-del>Delete entry</button>'; + '<button class="danger" data-del>Delete entry</button>';
m.hidden = false; m.hidden = false;
} }
// MODIFIER L'ENTREE ENTIERE, PAS UN CHAMP A LA FOIS.
//
// Chaque ligne avait son crayon : ouvrir, taper, valider, recommencer - et
// CHAQUE validation etait un enregistrement complet, donc un commit de plus
// dans le depot. Ici un seul formulaire, un seul Save, un seul commit.
//
// La page ne connait toujours pas le schema : elle rend une ligne par champ
// que Delphi a marque modifiable ('e' porte son nom), dans l'ordre qu'il a
// choisi. Ajouter un champ modifiable cote Delphi suffit a le voir ici.
/// Ce que le formulaire porte a cet instant, par nom de champ. Sert a le
/// rouvrir tel quel apres une action d'icone, qui passe par un enregistrement.
var editAgain = null;
/// Le champ 2FA qui attend le resultat du scan - creation ou edition.
var totpTarget = '';
/// Le formulaire occupe l'ecran du detail : 'view' reste donc 'detail', et
/// c'est ce drapeau qui dit qu'on y modifie - la fleche et la touche retour
/// s'en servent pour refermer le formulaire avant de quitter l'entree.
var editing = false;
function collectEditValues() {
var ins = document.querySelectorAll('#fields [data-ef]'), m = {}, i;
for (i = 0; i < ins.length; i++)
m[ins[i].getAttribute('data-ef')] = ins[i].value;
return m;
}
function openEditEntry() {
if (!lastDetail) return;
closeMenu();
// L'icone reste celle que renderDetail a posee dans l'en-tete : on voit ce
// que les trois boutons vont remplacer.
// Pas de crayon pendant qu'on modifie : il n'a plus rien a ouvrir.
document.getElementById('pencil').hidden = true;
var html = '<div class="form">'
+ '<div class="field"><div class="cap">Icon</div><div class="line">'
+ '<button class="btn" data-iconact="fetch">From site</button>'
+ '<button class="btn" data-iconact="pick">Image...</button>'
+ '<button class="btn" data-iconact="clear">Remove</button>'
+ '</div></div>', i, r;
for (i = 0; i < detailRows.length; i++) {
r = detailRows[i];
if (!r.e) continue;
html += '<div class="field"><div class="cap">' + esc(r.c) + '</div><div class="line'
+ (r.m ? ' wrap' : '') + '">'
+ (r.e === 'folder' ? folderSelect(r.v) : r.m
? '<textarea class="field-in" data-ef="' + esc(r.e) + '" rows="5"'
+ ' enterkeyhint="enter" autocorrect="off" spellcheck="false"'
+ ' style="' + FIELD_STYLE + ';height:auto;resize:vertical"></textarea>'
: '<input class="field-in"'
+ (r.e === 'password' ? ' id="editpwd"'
: r.e === 'totp_secret' ? ' id="edittotp"' : '')
+ ' data-ef="' + esc(r.e) + '" type="'
+ (r.e === 'password' || r.e === 'totp_secret' ? 'password' : 'text') + '"'
+ ' autocomplete="off" autocorrect="off" autocapitalize="none"'
+ ' spellcheck="false" style="' + FIELD_STYLE + '">')
+ (r.e === 'password' ? eyeButton('editpwd') + genButton('editpwd') : '')
+ (r.e === 'totp_secret' ? eyeButton('edittotp') + scanButton('edittotp') : '')
+ '</div></div>';
}
// L'ICONE SE CHANGE OU SE CHANGENT LES CHAMPS. Elle vivait dans le menu de
// l'entree, a cote de Supprimer : deux actions de nature opposee au meme
// endroit, et rien qui montre l'icone du moment.
html += '</div>'
+ '<div class="line" style="display:flex;gap:8px;margin-top:12px">'
+ '<button class="btn wide" id="eCancel" style="flex:1">Cancel</button>'
+ '<button class="btn main wide" id="eSave" style="flex:1">Save</button>'
+ '</div>';
document.getElementById('fields').innerHTML = html;
// Les valeurs APRES le rendu, jamais en attribut : c'est ainsi que
// procedait l'editeur d'une ligne, qui acceptait la frappe.
var fill = document.querySelectorAll('#fields [data-ef]'), fi, fe;
for (fi = 0; fi < fill.length; fi++) {
fe = fill[fi].getAttribute('data-ef');
for (var fk = 0; fk < detailRows.length; fk++)
if (detailRows[fk].e === fe && fill[fi].tagName !== 'SELECT')
fill[fi].value = detailRows[fk].v;
}
document.getElementById('eCancel').addEventListener('click', function () {
renderDetail(lastDetail);
});
document.getElementById('eSave').addEventListener('click', saveEditEntry);
editing = true;
if (editAgain) {
var ins = document.querySelectorAll('#fields [data-ef]'), i, f;
for (i = 0; i < ins.length; i++) {
f = ins[i].getAttribute('data-ef');
if (editAgain[f] !== undefined) ins[i].value = editAgain[f];
}
editAgain = null;
}
// MEME SORTIE QUE LA CREATION, qui elle accepte la frappe : c'est le seul
// appel qui differait a l'ouverture du formulaire.
send('state?view=detail');
}
/// UN SEUL ENVOI, ET SEULEMENT CE QUI A CHANGE : le reste du coffre n'a pas a
/// etre reecrit parce qu'on a corrige une faute de frappe dans un nom.
function saveEditEntry() {
var ins = document.querySelectorAll('#fields [data-ef]'), q = [], n = 0, i, el, f, v, k;
for (i = 0; i < ins.length; i++) {
el = ins[i];
f = el.getAttribute('data-ef');
v = el.value;
for (k = 0; k < detailRows.length; k++)
if (detailRows[k].e === f && detailRows[k].v === v) { f = ''; break; }
if (!f) continue;
q.push('f' + n + '=' + encodeURIComponent(f) + '&v' + n + '=' + encodeURIComponent(v));
n++;
}
if (!n) { renderDetail(lastDetail); return; }
document.getElementById('eSave').disabled = true;
send('setfields?' + q.join('&'));
}
document.getElementById('pencil').innerHTML = ico('edit', 'Edit entry');
document.getElementById('pencil').addEventListener('click', openEditEntry);
document.getElementById('kebab').innerHTML = ico('dots', 'Entry menu'); document.getElementById('kebab').innerHTML = ico('dots', 'Entry menu');
document.getElementById('kebab').addEventListener('click', function (ev) { document.getElementById('kebab').addEventListener('click', function (ev) {
ev.stopPropagation(); ev.stopPropagation();
@@ -1749,14 +1886,18 @@ document.addEventListener('input', function (ev) {
}); });
// L'inverse, pour l'affichage : l'URL d'API se relit comme l'adresse du depot. // L'inverse, pour l'affichage : l'URL d'API se relit comme l'adresse du depot.
// Meme nom par defaut que le poste - les deux doivent designer le meme
// fichier. Un depot configure avec un autre nom garde son URL complete.
var SYNC_DEFAULT_FILE = 'data.json';
function shortRepoUrl(u) { function shortRepoUrl(u) {
var g = /^https:\/\/api\.github\.com\/repos\/([^\/]+)\/([^\/]+)\/contents\/(.+)$/i.exec(u || ''); var g = /^https:\/\/api\.github\.com\/repos\/([^\/]+)\/([^\/]+)\/contents\/(.+)$/i.exec(u || '');
if (g) return 'https://github.com/' + g[1] + '/' + g[2] + if (g) return 'https://github.com/' + g[1] + '/' + g[2] +
(g[3] === 'vault-sync.json' ? '' : '/blob/main/' + g[3]); (g[3] === SYNC_DEFAULT_FILE ? '' : '/blob/main/' + g[3]);
var m = /^(https?:\/\/[^\/]+)\/api\/v1\/repos\/([^\/]+)\/([^\/]+)\/contents\/(.+)$/i.exec(u || ''); var m = /^(https?:\/\/[^\/]+)\/api\/v1\/repos\/([^\/]+)\/([^\/]+)\/contents\/(.+)$/i.exec(u || '');
if (!m) return u; if (!m) return u;
return m[1] + '/' + m[2] + '/' + m[3] + return m[1] + '/' + m[2] + '/' + m[3] +
(m[4] === 'vault-sync.json' ? '' : '/src/branch/main/' + m[4]); (m[4] === SYNC_DEFAULT_FILE ? '' : '/src/branch/main/' + m[4]);
} }
function normalizeRepoUrl(u) { function normalizeRepoUrl(u) {
u = u.replace(/\s+/g, ''); u = u.replace(/\s+/g, '');
@@ -1765,10 +1906,10 @@ function normalizeRepoUrl(u) {
// GITHUB : l'adresse du depot est sur github.com, l'API sur api.github.com. // GITHUB : l'adresse du depot est sur github.com, l'API sur api.github.com.
var g = u.match(/^https?:\/\/(www\.)?github\.com\/([^\/]+)\/([^\/]+?)(?:\.git)?(?:\/blob\/[^\/]+\/(.+?))?\/?$/i); var g = u.match(/^https?:\/\/(www\.)?github\.com\/([^\/]+)\/([^\/]+?)(?:\.git)?(?:\/blob\/[^\/]+\/(.+?))?\/?$/i);
if (g) return 'https://api.github.com/repos/' + g[2] + '/' + g[3] + if (g) return 'https://api.github.com/repos/' + g[2] + '/' + g[3] +
'/contents/' + (g[4] || 'vault-sync.json'); '/contents/' + (g[4] || SYNC_DEFAULT_FILE);
var m = u.match(/^(https?:\/\/[^\/]+)\/([^\/]+)\/([^\/]+?)(?:\.git)?(?:\/src\/branch\/[^\/]+\/(.+?))?\/?$/i); var m = u.match(/^(https?:\/\/[^\/]+)\/([^\/]+)\/([^\/]+?)(?:\.git)?(?:\/src\/branch\/[^\/]+\/(.+?))?\/?$/i);
if (!m) return u; if (!m) return u;
return m[1] + '/api/v1/repos/' + m[2] + '/' + m[3] + '/contents/' + (m[4] || 'vault-sync.json'); return m[1] + '/api/v1/repos/' + m[2] + '/' + m[3] + '/contents/' + (m[4] || SYNC_DEFAULT_FILE);
} }
// Pas de Save pour ces deux-la : une case se voit cochee, elle s'applique. // Pas de Save pour ces deux-la : une case se voit cochee, elle s'applique.
['sBack', 'sKbd'].forEach(function (id) { ['sBack', 'sKbd'].forEach(function (id) {
@@ -1893,12 +2034,19 @@ function goList() {
document.body.classList.remove('on-detail'); document.body.classList.remove('on-detail');
document.body.classList.add('on-list'); document.body.classList.add('on-list');
view = 'list'; view = 'list';
editing = false;
// Les valeurs en clair partent avec la vue, pas seulement de l'ecran. // Les valeurs en clair partent avec la vue, pas seulement de l'ecran.
detailRows = []; detailRows = [];
document.getElementById('fields').innerHTML = ''; document.getElementById('fields').innerHTML = '';
send('state?view=list'); send('state?view=list');
} }
document.getElementById('back').addEventListener('click', goList); // LA FLECHE REFERME D'ABORD LE FORMULAIRE. Elle ramenait a la liste, donc une
// modification en cours disparaissait sans qu'on ait vu l'entree.
document.getElementById('back').addEventListener('click', function () {
if (editing) { renderDetail(lastDetail); return; }
goList();
});
// --- appele par Delphi ----------------------------------------------------- // --- appele par Delphi -----------------------------------------------------
var PM = { var PM = {
@@ -2142,7 +2290,24 @@ var PM = {
ico('loader', 'Working') + '<span>Please wait...</span>'; ico('loader', 'Working') + '<span>Please wait...</span>';
document.body.classList.toggle('is-busy', !!on); document.body.classList.toggle('is-busy', !!on);
}, },
back: function () { if (view === 'detail') goList(); }, // Touche retour du telephone : le formulaire d'abord, la liste ensuite.
// Delphi ne connait que 'detail' - la page seule sait qu'elle edite.
back: function () {
if (editing) { renderDetail(lastDetail); return; }
if (view === 'detail') goList();
},
cancelEdit: function () { renderDetail(lastDetail); },
/// Le QR d'un site porte otpauth://totp/Label?secret=XXXX&issuer=... - on
/// n'en garde que le secret, comme le champ l'attend. Un QR sans secret
/// (un autre lien) ne touche a rien.
totpFromQr: function (uri) {
var m = /[?&]secret=([^&#]+)/i.exec(uri || '');
var el = document.getElementById(totpTarget);
if (!m || !el) { PM.toast('Not a 2FA QR code'); return; }
el.value = decodeURIComponent(m[1]).replace(/\s+/g, '').toUpperCase();
PM.toast('2FA secret read from the QR code');
},
/// La touche retour annule la selection avant de verrouiller le coffre. /// La touche retour annule la selection avant de verrouiller le coffre.
/// Sans cela, un geste d'annulation fermait le coffre - le contraire de ce /// Sans cela, un geste d'annulation fermait le coffre - le contraire de ce
/// qu'il veut dire. /// qu'il veut dire.
+17 -1
View File
@@ -2080,7 +2080,9 @@ input[type="range"]::-webkit-slider-thumb {
flex-shrink: 0; flex-shrink: 0;
} }
.quick-search-input input { .quick-search-input input {
flex: 1; /* min-width:0 : sans lui le champ garde la largeur de son texte d'aide et
pousse l'interrupteur et Esc hors du cadre. */
flex: 1; min-width: 0;
background: transparent; background: transparent;
border: none; border: none;
color: var(--text); color: var(--text);
@@ -2088,6 +2090,7 @@ input[type="range"]::-webkit-slider-thumb {
outline: none; outline: none;
} }
.quick-search-input kbd { .quick-search-input kbd {
flex-shrink: 0;
font-size: 10px; font-size: 10px;
padding: 2px 6px; padding: 2px 6px;
border-radius: 4px; border-radius: 4px;
@@ -2133,6 +2136,19 @@ input[type="range"]::-webkit-slider-thumb {
color: var(--text); color: var(--text);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
} }
.qs-mode {
display: flex; gap: 2px; padding: 2px; flex-shrink: 0;
background: var(--bg-elev-2); border-radius: 999px;
}
.qs-mode button {
border: 0; background: none; cursor: pointer;
color: var(--text-dim); font-size: 12px;
padding: 4px 10px; border-radius: 999px;
}
.qs-mode button.is-on { background: var(--accent); color: #fff; }
.quick-search-acts {
display: flex; gap: 2px; flex-shrink: 0;
}
.quick-search-sub { .quick-search-sub {
font-size: 12px; font-size: 12px;
color: var(--text-dim); color: var(--text-dim);
+45 -7
View File
@@ -1202,6 +1202,47 @@ begin
Result := CallWindowProc(GLazyOldBtnProc, AWnd, AMsg, AWp, ALp); Result := CallWindowProc(GLazyOldBtnProc, AWnd, AMsg, AWp, ALp);
end; end;
// LA BARRE DES TACHES EST TOPMOST ELLE AUSSI, et la barrette a le droit de se
// poser dessus. Redemander HWND_TOPMOST ne suffit pas quand on l'est deja :
// Windows n'y voit rien a changer et la barrette reste dessous - invisible au
// demarrage de session, puis des qu'on ouvre le menu Demarrer. Le passage par
// NOTOPMOST force la remontee en tete de la bande. Fait seulement quand elle
// est vraiment recouverte (un point de sa surface ne lui appartient plus),
// pour ne pas remuer l'ordre des fenetres cinq fois par seconde.
function LazyIsCovered(AWnd: HWND): Boolean;
var
LRect: TRect;
LPt: TPoint;
LHit: HWND;
LPid: DWORD;
begin
Result := False;
if not GetWindowRect(AWnd, LRect) then Exit;
LPt.X := (LRect.Left + LRect.Right) div 2;
LPt.Y := (LRect.Top + LRect.Bottom) div 2;
LHit := WindowFromPoint(LPt);
if LHit = 0 then Exit;
if (LHit = AWnd) or (GetAncestor(LHit, GA_ROOT) = AWnd) then Exit;
// NOS PROPRES FENETRES NE RECOUVRENT PAS. L'info-bulle d'un bouton s'affiche
// PAR-DESSUS la barrette : la compter comme un recouvrement faisait
// repasser la barrette devant (NOTOPMOST puis TOPMOST - elle disparaissait
// un instant), ce qui fermait l'info-bulle, qui se rouvrait sous la souris
// immobile... en boucle.
GetWindowThreadProcessId(LHit, @LPid);
Result := LPid <> GetCurrentProcessId;
end;
procedure LazyForceToTop(AWnd: HWND);
begin
// UNE SEULE REMONTEE, PAS DE DETOUR PAR LE BAS. Le passage par NOTOPMOST
// faisait descendre la barrette sous les fenetres ordinaires l'espace d'un
// instant - un flash visible a chaque battement - et, mesure a l'appui, il
// ne gagnait rien contre la barre des taches active, seul cas ou elle reste
// recouverte. Contre une fenetre TOPMOST ordinaire, TOPMOST suffit.
SetWindowPos(AWnd, HWND_TOPMOST, 0, 0, 0, 0,
SWP_NOMOVE or SWP_NOSIZE or SWP_NOACTIVATE);
end;
procedure TPMBridge.DrawLazyButton(ADraw: PDrawItemStruct); procedure TPMBridge.DrawLazyButton(ADraw: PDrawItemStruct);
var var
LRect: TRect; LRect: TRect;
@@ -1389,13 +1430,10 @@ begin
ApplyLazyAlpha(LIn); ApplyLazyAlpha(LIn);
end; end;
// TOPMOST N'EST PAS UN RANG, C'EST UNE BANDE. La barre des taches y est // TOPMOST N'EST PAS UN RANG, C'EST UNE BANDE. La barre des taches y est
// elle aussi, et cliquer son fond vide la remonte en tete de cette // elle aussi, et se remet en tete de cette bande au demarrage de la
// bande : une barrette posee dessus passe DERRIERE et semble avoir // session comme a l'ouverture du menu Demarrer : une barrette posee
// disparu, alors qu'elle est seulement recouverte. La reposer a chaque // dessus passe DERRIERE et semble avoir disparu.
// battement est ce que font tous les outils flottants ; sans mouvement if LazyIsCovered(FLazyWnd) then LazyForceToTop(FLazyWnd);
// ni taille a changer, l'appel ne repeint rien.
SetWindowPos(FLazyWnd, HWND_TOPMOST, 0, 0, 0, 0,
SWP_NOMOVE or SWP_NOSIZE or SWP_NOACTIVATE);
Exit; Exit;
end; end;
// Fin du glissement (la boucle modale ouverte par HTCAPTION), donc une // Fin du glissement (la boucle modale ouverte par HTCAPTION), donc une
+31 -8
View File
@@ -1404,9 +1404,14 @@ begin
procedure procedure
var var
LText: string; LText: string;
LFound: Boolean;
begin begin
Sleep(400); // laisser Windows retirer la fenetre de l'ecran Sleep(400); // laisser Windows retirer la fenetre de l'ecran
LText := ScanScreenForQr; LText := ScanScreenForQr;
// UN QR LU N'EST PAS UN QR DE 2FA. Le texte d'un autre QR ne part pas a
// la page - il n'a rien a y faire -, mais le FAIT qu'un QR a ete lu,
// si : "aucun QR" alors qu'il y en avait un envoyait chercher ailleurs.
LFound := LText <> '';
if Pos('otpauth://', LowerCase(LText)) <> 1 then LText := ''; if Pos('otpauth://', LowerCase(LText)) <> 1 then LText := '';
TThread.Queue(nil, TThread.Queue(nil,
procedure procedure
@@ -1414,12 +1419,14 @@ begin
LJs: TJSONString; LJs: TJSONString;
begin begin
RestoreWindowFromTray; RestoreWindowFromTray;
if LText = '' then LogLine('qr/screen: no otpauth QR found') if LText <> '' then LogLine('qr/screen: otpauth QR found')
else LogLine('qr/screen: otpauth QR found'); else if LFound then LogLine('qr/screen: a QR code, but not a 2FA one')
else LogLine('qr/screen: no QR code on screen');
LJs := TJSONString.Create(LText); LJs := TJSONString.Create(LText);
try try
WebBrowser.ExecuteJavaScript( WebBrowser.ExecuteJavaScript(
'if(window.Bridge&&Bridge.onScreenQr)Bridge.onScreenQr(' + LJs.ToJSON + ')'); 'if(window.Bridge&&Bridge.onScreenQr)Bridge.onScreenQr(' + LJs.ToJSON
+ ',' + BoolToStr(LFound, True).ToLower + ')');
finally finally
LJs.Free; LJs.Free;
end; end;
@@ -1448,16 +1455,21 @@ begin
var LShort := LQrUrl; var LShort := LQrUrl;
var LApi := Pos('/api/v1/repos/', LShort); var LApi := Pos('/api/v1/repos/', LShort);
// GitHub : l'adresse du depot est sur github.com, l'API sur api.github.com. // GitHub : l'adresse du depot est sur github.com, l'API sur api.github.com.
// RACCOURCI SEULEMENT POUR LE NOM PAR DEFAUT COURANT. Un depot
// configure avec un autre nom - dont l'ancien vault-sync.json - voyage
// en URL complete : le telephone rallongerait sinon vers le defaut et
// les deux appareils ne regarderaient pas le meme fichier.
const SyncDefaultFile = '/contents/data.json';
if StartsText('https://api.github.com/repos/', LShort) and if StartsText('https://api.github.com/repos/', LShort) and
EndsText('/contents/vault-sync.json', LShort) then EndsText(SyncDefaultFile, LShort) then
LShort := 'https://github.com/' + LShort := 'https://github.com/' +
Copy(LShort, Length('https://api.github.com/repos/') + 1, Copy(LShort, Length('https://api.github.com/repos/') + 1,
Length(LShort) - Length('https://api.github.com/repos/') - Length(LShort) - Length('https://api.github.com/repos/') -
Length('/contents/vault-sync.json')) Length(SyncDefaultFile))
else if (LApi > 0) and EndsText('/contents/vault-sync.json', LShort) then else if (LApi > 0) and EndsText(SyncDefaultFile, LShort) then
LShort := Copy(LShort, 1, LApi) + LShort := Copy(LShort, 1, LApi) +
Copy(LShort, LApi + Length('/api/v1/repos/'), Copy(LShort, LApi + Length('/api/v1/repos/'),
Length(LShort) - LApi - Length('/api/v1/repos/') - Length('/contents/vault-sync.json') + 1); Length(LShort) - LApi - Length('/api/v1/repos/') - Length(SyncDefaultFile) + 1);
LShort := StringReplace(LShort, '%', '%25', [rfReplaceAll]); LShort := StringReplace(LShort, '%', '%25', [rfReplaceAll]);
LShort := StringReplace(LShort, '&', '%26', [rfReplaceAll]); LShort := StringReplace(LShort, '&', '%26', [rfReplaceAll]);
LShort := StringReplace(LShort, '#', '%23', [rfReplaceAll]); LShort := StringReplace(LShort, '#', '%23', [rfReplaceAll]);
@@ -1682,6 +1694,15 @@ begin
LErr: string; LErr: string;
LEtag: string; LEtag: string;
begin begin
// TROIS ESSAIS, ATTENTE DE CONNEXION COURTE. GitHub publie plusieurs
// adresses et l'une d'elles, 140.82.121.5, n'est joignable depuis ce
// reseau ni par l'application ni par un navigateur - verifie des deux
// cotes. Un navigateur passe aussitot a la suivante ; nous attendions
// le delai entier puis abandonnions. Seul un status 0 - la requete n'a
// pas abouti - fait recommencer : un 404 ou un 409 sont des reponses.
LStatus := 0;
for var LTry := 1 to 3 do
begin
LStatus := 0; LStatus := 0;
LBodyB64 := ''; LBodyB64 := '';
LErr := ''; LErr := '';
@@ -1689,7 +1710,7 @@ begin
try try
LHttp := System.Net.HttpClient.THTTPClient.Create; LHttp := System.Net.HttpClient.THTTPClient.Create;
try try
LHttp.ConnectionTimeout := 10000; LHttp.ConnectionTimeout := 8000; // voir la boucle ci-dessus
LHttp.ResponseTimeout := 30000; LHttp.ResponseTimeout := 30000;
// GitHub REFUSE toute requete sans User-Agent (403) ; Gitea l'ignore. // GitHub REFUSE toute requete sans User-Agent (403) ; Gitea l'ignore.
LHttp.UserAgent := 'PMServer'; LHttp.UserAgent := 'PMServer';
@@ -1774,6 +1795,8 @@ begin
LErr := E.Message; LErr := E.Message;
end; end;
end; end;
if LStatus <> 0 then Break;
end;
TThread.Queue(nil, TThread.Queue(nil,
procedure procedure
var var
+9 -1
View File
@@ -47,6 +47,8 @@
<symbol id="i-printer" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></symbol> <symbol id="i-printer" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></symbol>
<symbol id="i-pin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/></symbol> <symbol id="i-pin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/></symbol>
<symbol id="i-paperclip" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05 12.25 20.24a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></symbol> <symbol id="i-paperclip" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05 12.25 20.24a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></symbol>
<symbol id="i-clock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15 14"/></symbol>
<symbol id="i-note" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="14 3 14 9 20 9"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="13" y2="17"/></symbol>
<symbol id="i-qr" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><path d="M14 14h3v3h-3zM21 14v.01M14 21h.01M17 21h4v-4"/></symbol> <symbol id="i-qr" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><path d="M14 14h3v3h-3zM21 14v.01M14 21h.01M17 21h4v-4"/></symbol>
<symbol id="i-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></symbol> <symbol id="i-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></symbol>
<symbol id="i-empty-vault" viewBox="0 0 120 120" fill="none"> <symbol id="i-empty-vault" viewBox="0 0 120 120" fill="none">
@@ -1227,8 +1229,14 @@
<div class="quick-search-input"> <div class="quick-search-input">
<svg><use href="#i-search"/></svg> <svg><use href="#i-search"/></svg>
<input id="quickSearchInput" type="text" <input id="quickSearchInput" type="text"
placeholder="Search and press Enter to copy password…" placeholder="Search…"
autocomplete="off"> autocomplete="off">
<!-- Copier ou remplir : visible seulement quand une fenetre cible
existe - sans elle, remplir n'aurait rien a viser. -->
<div class="qs-mode" id="qsMode" hidden>
<button type="button" data-qsmode="copy">Copy</button>
<button type="button" data-qsmode="fill">Fill</button>
</div>
<kbd>Esc</kbd> <kbd>Esc</kbd>
</div> </div>
<div class="quick-search-results" id="quickSearchResults"></div> <div class="quick-search-results" id="quickSearchResults"></div>
+6 -2
View File
@@ -4839,9 +4839,13 @@ function soTotpField(plainSecret) {
const scan = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Scan the 2FA QR code shown on screen' }); 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.appendChild(icon('i-qr'));
scan.addEventListener('click', () => { scan.addEventListener('click', () => {
Bridge.onScreenQr = (text) => { Bridge.onScreenQr = (text, found) => {
const secret = parseOtpAuthUri(text || ''); const secret = parseOtpAuthUri(text || '');
if (!secret) { toast('No 2FA QR code found on screen', 'warning'); return; } if (!secret) {
toast(found ? 'This QR code is not a 2FA code (it has no otpauth:// link)'
: 'No QR code found on screen', 'warning');
return;
}
input.value = secret; input.value = secret;
updateTotpDisplay(); updateTotpDisplay();
soDirtyCheck(); soDirtyCheck();
+94 -7
View File
@@ -27,6 +27,39 @@ let quickSearchHideAfter = false;
// HWND and is waiting for cmd://autofill/execute. On pick we SendInput // HWND and is waiting for cmd://autofill/execute. On pick we SendInput
// the password instead of copying to the clipboard. // the password instead of copying to the clipboard.
let quickSearchFillMode = false; let quickSearchFillMode = false;
// CE QUE L'UTILISATEUR PREFERE quand une cible existe : remplir, ou copier
// quand meme - le remplissage echoue vers une application lancee en
// administrateur, ou quand le gestionnaire de Chrome s'en mele. Retenu en pref
// DPAPI : localStorage se vide a chaque lancement (port ephemere).
let quickSearchPreferCopy = false;
/// Remplit-on vraiment ? Il faut une cible ET ne pas avoir choisi Copy.
function qsFills() { return quickSearchFillMode && !quickSearchPreferCopy; }
function qsApplyMode() {
const box = document.getElementById('qsMode');
if (box) {
box.hidden = !quickSearchFillMode;
box.querySelectorAll('[data-qsmode]').forEach(b =>
b.classList.toggle('is-on', (b.dataset.qsmode === 'fill') === qsFills()));
}
const hintEl = document.querySelector('#quickSearchModal .quick-search-hint');
if (hintEl) {
hintEl.textContent = qsFills()
? 'Enter / click = password · Shift+Enter / right-click = user+password · Ctrl+Enter / Ctrl+click = username · Esc = cancel'
: 'Enter / click = copy password · Ctrl+Enter / Ctrl+click = copy username · Esc = close';
}
}
document.addEventListener('click', ev => {
const b = ev.target.closest && ev.target.closest('[data-qsmode]');
if (!b) return;
quickSearchPreferCopy = b.dataset.qsmode === 'copy';
if (Bridge.active) Bridge.setPref('qsCopy', quickSearchPreferCopy ? '1' : '0');
qsApplyMode();
quickSearchRender();
document.getElementById('quickSearchInput').focus();
});
function quickSearchScoreEntry(e, q) { function quickSearchScoreEntry(e, q) {
if (!q) return 1; // empty query → all entries pass, ordering preserved if (!q) return 1; // empty query → all entries pass, ordering preserved
@@ -88,6 +121,24 @@ function quickSearchRender() {
main.appendChild(el('div', { class: 'quick-search-sub' }, e.username)); main.appendChild(el('div', { class: 'quick-search-sub' }, e.username));
row.appendChild(avatar); row.appendChild(avatar);
row.appendChild(main); row.appendChild(main);
// Un bouton par valeur presente : pas besoin de retenir clic / Ctrl+clic.
const verb = qsFills() ? 'Fill ' : 'Copy ';
const acts = el('div', { class: 'quick-search-acts' });
const act = (ic, title, mode) => {
const b = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title });
b.appendChild(icon(ic));
b.addEventListener('click', ev => { ev.stopPropagation(); quickSearchPickEntry(e, mode); });
b.addEventListener('contextmenu', ev => ev.stopPropagation());
acts.appendChild(b);
};
if (e.username) act('i-user', verb + 'username', 'user');
// UNE NOTE SE COPIE TOUJOURS, meme en Fill : tapee, chacun de ses
// retours a la ligne partirait comme un Entree dans le formulaire.
if (e.kind === 'note')
act('i-note', qsFills() ? 'Copy note (a note is never typed)' : 'Copy note', 'note');
else act('i-key', verb + 'password', 'pwd');
if (e.totp_secret && e.totp_iv) act('i-clock', verb + '2FA code', 'totp');
row.appendChild(acts);
// Left click → password only; Ctrl+click → username only // Left click → password only; Ctrl+click → username only
// (step-2 forms / unlock screens). // (step-2 forms / unlock screens).
row.addEventListener('click', ev => row.addEventListener('click', ev =>
@@ -106,12 +157,45 @@ function quickSearchRender() {
// mode: 'full' (user + Tab + password), 'user' (username only) or 'pwd' // mode: 'full' (user + Tab + password), 'user' (username only) or 'pwd'
// (password only). In fill mode each maps to a SendInput variant; in copy // (password only). In fill mode each maps to a SendInput variant; in copy
// mode 'full' has no meaning so it falls back to copying the password. // mode 'full' has no meaning so it falls back to copying the password.
// 'totp' = code 2FA courant (tape comme un mot de passe, ou copie) ; 'note' =
// corps d'une note, toujours COPIE - le taper enverrait ses retours a la ligne
// comme des Entree dans le formulaire cible.
async function quickSearchPickEntry(entry, mode) { async function quickSearchPickEntry(entry, mode) {
mode = mode || 'full'; mode = mode || 'full';
if (mode === 'totp') {
const secret = await decryptTotpSecret(entry.totp_secret, entry.totp_iv);
let code = '';
try { if (secret && secret !== '[ERROR]') code = (await generateTOTP(secret)).code; }
catch (_) { code = ''; }
if (!code) { toast('2FA: cannot read the secret', 'error'); return; }
if (qsFills()) {
autofillPendingToast = entryDisplayName(entry) + ' · 2FA code sent';
if (Bridge.active) Bridge.executeAutofill('', code, quickSearchHideAfter);
quickSearchFillMode = false;
quickSearchHideAfter = false;
closeQuickSearchModal();
return;
}
if (Bridge.active) Bridge.copySecure(code, 30000);
else { try { await navigator.clipboard.writeText(code); } catch (_) {} }
toast(entryDisplayName(entry) + ' · 2FA code copied');
closeQuickSearchModal(true);
return;
}
if (mode === 'note') {
const body = await decryptPwd(entry.encrypted_password, entry.iv);
if (body === '[ERROR]') { toast('Decryption error', 'error'); return; }
if (Bridge.active) Bridge.copySecure(body, 30000);
else { try { await navigator.clipboard.writeText(body); } catch (_) {} }
toast(entryDisplayName(entry) + ' · note copied');
closeQuickSearchModal(true);
return;
}
// Fill mode (Ctrl+Shift+Q hotkey): SendInput directly into the HWND // Fill mode (Ctrl+Shift+Q hotkey): SendInput directly into the HWND
// Delphi saved when the hotkey fired. No clipboard touch. // Delphi saved when the hotkey fired. No clipboard touch.
if (quickSearchFillMode) { if (qsFills()) {
if (mode === 'user') { if (mode === 'user') {
const u = entry.username || ''; const u = entry.username || '';
if (!u) { toast('No username on this entry', 'warning'); return; } if (!u) { toast('No username on this entry', 'warning'); return; }
@@ -479,12 +563,15 @@ function openQuickSearchModal(hideAfter, forFill) {
quickSearchSelected = 0; quickSearchSelected = 0;
quickSearchHideAfter = !!hideAfter; quickSearchHideAfter = !!hideAfter;
quickSearchFillMode = !!forFill; quickSearchFillMode = !!forFill;
// Subtle hint to the user about what Enter will do. qsApplyMode();
const hintEl = modal.querySelector('.quick-search-hint'); // La preference arrive de Delphi : on affiche tout de suite, et on
if (hintEl) { // rectifie des qu'elle est la (une fraction de seconde).
hintEl.textContent = forFill if (forFill && Bridge.active) {
? 'Enter / click = password · Shift+Enter / right-click = user+password · Ctrl+Enter / Ctrl+click = username · Esc = cancel' Bridge.getPref('qsCopy').then(v => {
: 'Enter / click = copy password · Ctrl+Enter / Ctrl+click = copy username · Esc = close'; quickSearchPreferCopy = v === '1';
qsApplyMode();
quickSearchRender();
}).catch(() => {});
} }
quickSearchRender(); quickSearchRender();
setTimeout(() => input.focus(), 50); setTimeout(() => input.focus(), 50);
+15 -4
View File
@@ -92,21 +92,28 @@ function syncIsGitea(url) {
// ADRESSE DU DEPOT TELLE QUE LE NAVIGATEUR (OU "git clone") LA DONNE. // ADRESSE DU DEPOT TELLE QUE LE NAVIGATEUR (OU "git clone") LA DONNE.
// https://github.com/O/R(.git) -> API de GitHub ; https://hote/O/R(.git) -> API // https://github.com/O/R(.git) -> API de GitHub ; https://hote/O/R(.git) -> API
// de Gitea. Fichier par defaut vault-sync.json, ou celui d'un lien .../blob/B/F // de Gitea. Fichier par defaut SYNC_DEFAULT_FILE, ou celui d'un lien .../blob/B/F
// (GitHub) ou .../src/branch/B/F (Gitea). Tout le reste passe tel quel - une // (GitHub) ou .../src/branch/B/F (Gitea). Tout le reste passe tel quel - une
// URL WebDAV, une URL d'API deja complete. GARDE : un dernier segment qui a // URL WebDAV, une URL d'API deja complete. GARDE : un dernier segment qui a
// l'air d'un fichier (.json...) n'est pas un depot - sinon // l'air d'un fichier (.json...) n'est pas un depot - sinon
// https://serveur/webdav/vault.json deviendrait une API Gitea imaginaire. // https://serveur/webdav/vault.json deviendrait une API Gitea imaginaire.
// LE NOM NE DIT PAS CE QUE C'EST. Un fichier appele "vault-sync.json" dans un
// depot annonce un coffre a qui parcourt la liste des depots ; le contenu est
// chiffre, mais le nom, lui, designe la cible. Les anciennes configurations
// gardent leur nom : c'est l'URL complete qui est enregistree, et elle ne
// passe pas par ici.
const SYNC_DEFAULT_FILE = 'data.json';
function syncNormalizeRepoUrl(u) { function syncNormalizeRepoUrl(u) {
u = (u || '').replace(/\s+/g, ''); u = (u || '').replace(/\s+/g, '');
if (!u || syncIsGitea(u)) return u; if (!u || syncIsGitea(u)) return u;
const g = u.match(/^https?:\/\/(www\.)?github\.com\/([^\/]+)\/([^\/]+?)(?:\.git)?(?:\/blob\/[^\/]+\/(.+?))?\/?$/i); const g = u.match(/^https?:\/\/(www\.)?github\.com\/([^\/]+)\/([^\/]+?)(?:\.git)?(?:\/blob\/[^\/]+\/(.+?))?\/?$/i);
if (g) return 'https://api.github.com/repos/' + g[2] + '/' + g[3] + if (g) return 'https://api.github.com/repos/' + g[2] + '/' + g[3] +
'/contents/' + (g[4] || 'vault-sync.json'); '/contents/' + (g[4] || SYNC_DEFAULT_FILE);
const m = u.match(/^(https?:\/\/[^\/]+)\/([^\/]+)\/([^\/]+?)(\.git)?(?:\/src\/branch\/[^\/]+\/(.+?))?\/?$/i); const m = u.match(/^(https?:\/\/[^\/]+)\/([^\/]+)\/([^\/]+?)(\.git)?(?:\/src\/branch\/[^\/]+\/(.+?))?\/?$/i);
if (!m) return u; if (!m) return u;
if (!m[4] && !m[5] && /\.[a-z0-9]{2,5}$/i.test(m[3])) return u; // un fichier, pas un depot if (!m[4] && !m[5] && /\.[a-z0-9]{2,5}$/i.test(m[3])) return u; // un fichier, pas un depot
return m[1] + '/api/v1/repos/' + m[2] + '/' + m[3] + '/contents/' + (m[5] || 'vault-sync.json'); return m[1] + '/api/v1/repos/' + m[2] + '/' + m[3] + '/contents/' + (m[5] || SYNC_DEFAULT_FILE);
} }
// UN DEPOT PUBLIC EST REFUSE. Chaque synchro est un commit : l'historique garde // UN DEPOT PUBLIC EST REFUSE. Chaque synchro est un commit : l'historique garde
@@ -839,7 +846,11 @@ async function runSyncNow(_attempt) {
// not the same as none. `branch` is omitted too, so the repo's // not the same as none. `branch` is omitted too, so the repo's
// default branch is used instead of a name we would be guessing. // default branch is used instead of a name we would be guessing.
const env = { const env = {
message: 'sync from ' + (state.username || 'desktop'), // NI LE NOM DU COMPTE NI CE QU'IL GARDE. L'historique du
// depot affichait "sync from <utilisateur du coffre>" a chaque
// envoi : le contenu est chiffre, cette ligne ne l'etait pas.
// L'appareil d'origine suffit a savoir qui a publie.
message: 'update from desktop',
content: bytesToBase64(containerBytes) content: bytesToBase64(containerBytes)
}; };
if (remoteEtag) env.sha = remoteEtag; if (remoteEtag) env.sha = remoteEtag;
+305 -23
View File
@@ -48,6 +48,7 @@ uses
FMX.Controls.Presentation, FMX.StdCtrls, FMX.Memo, FMX.Memo.Types, FMX.Controls.Presentation, FMX.StdCtrls, FMX.Memo, FMX.Memo.Types,
FMX.ScrollBox, FMX.Layouts, FMX.Edit, FMX.ListBox, FMX.Objects, FMX.ScrollBox, FMX.Layouts, FMX.Edit, FMX.ListBox, FMX.Objects,
FMX.WebBrowser, System.Messaging, FMX.Media, FMX.WebBrowser, System.Messaging, FMX.Media,
FMX.MediaLibrary, FMX.MediaLibrary.Actions,
PMA.Vault; PMA.Vault;
const const
@@ -340,6 +341,9 @@ type
/// Change UN champ de l'entree ouverte. Le nom du champ vient de la page, /// Change UN champ de l'entree ouverte. Le nom du champ vient de la page,
/// et n'est donc pas cru sur parole. /// et n'est donc pas cru sur parole.
procedure SaveEntryField(const AField, ANewValue: string); procedure SaveEntryField(const AField, ANewValue: string);
/// Plusieurs champs d'un coup, en UN SEUL enregistrement - donc un seul
/// commit dans le depot. Les deux tableaux vont par paires.
procedure SaveEntryFields(const AFields, AValues: TArray<string>);
/// Ajoute une entree au coffre. Le mot de passe voyage par le pont, /// Ajoute une entree au coffre. Le mot de passe voyage par le pont,
/// comme 'setfield' - meme regle, meme journalisation muette. /// comme 'setfield' - meme regle, meme journalisation muette.
procedure CreateEntry(const ATitle, ASite, AUser, APwd, AKind, procedure CreateEntry(const ATitle, ASite, AUser, APwd, AKind,
@@ -382,6 +386,12 @@ type
// Scanner du QR affiche par le poste (ZXing.Delphi, Apache 2.0). // Scanner du QR affiche par le poste (ZXing.Delphi, Apache 2.0).
var var
FScanning, FScanBusy: Boolean; FScanning, FScanBusy: Boolean;
/// Le scan en cours cherche un QR de 2FA (otpauth://) et non le lien des
/// reglages. Pose par 'scanqr?for=totp', lu au decodage.
FScanTotp: Boolean;
/// L'action de la galerie survit a l'appel : le selecteur d'Android rend
/// la main tout de suite et rappelle plus tard.
FPickIcon: TTakePhotoFromLibraryAction;
FScanFrame: Integer; FScanFrame: Integer;
FScanLay: TLayout; FScanLay: TLayout;
FScanImg: TImage; FScanImg: TImage;
@@ -401,6 +411,12 @@ type
function PwdName: string; function PwdName: string;
/// Va chercher l'icone du site de l'entree ouverte, ou l'efface. /// Va chercher l'icone du site de l'entree ouverte, ou l'efface.
procedure SetEntryIcon(AFetch: Boolean); procedure SetEntryIcon(AFetch: Boolean);
/// CHOISIR UNE IMAGE DE LA GALERIE comme icone. Elle est reduite avant
/// d'entrer dans le coffre - une photo de telephone pese quelques Mo, et
/// une icone se regarde a 32 px dans une liste.
procedure PickEntryIcon;
procedure BeginPickIcon;
procedure IconPicked(AImage: TBitmap);
/// Le depot a bouge, ou ne l'a plus : LE DRAPEAU ET SON TEMOIN ENSEMBLE. /// Le depot a bouge, ou ne l'a plus : LE DRAPEAU ET SON TEMOIN ENSEMBLE.
/// ///
/// Ils ne l'etaient pas. FRemoteAhead etait remis a faux a trois endroits - /// Ils ne l'etaient pas. FRemoteAhead etait remis a faux a trois endroits -
@@ -494,7 +510,10 @@ begin
// le filet. // le filet.
FDeadline := TTimer.Create(Self); FDeadline := TTimer.Create(Self);
FDeadline.Enabled := False; FDeadline.Enabled := False;
FDeadline.Interval := 25000; // Plus long que le delai du client HTTP (40 s) : ce garde-fou est la pour
// le cas ou la requete ne rend jamais la main, pas pour couper une reponse
// lente que le client attend encore - c'est ce qu'il faisait a 25 s.
FDeadline.Interval := 50000;
FDeadline.OnTimer := OnDeadline; FDeadline.OnTimer := OnDeadline;
FTotpTimer := TTimer.Create(Self); FTotpTimer := TTimer.Create(Self);
@@ -707,6 +726,15 @@ begin
FWebView := 'list'; FWebView := 'list';
Key := 0; Key := 0;
end end
// LE FORMULAIRE D'ABORD, l'entree ensuite. Il occupe la meme vue que le
// detail, mais il se referme sur lui - sinon une modification en cours
// sautait d'un coup jusqu'a la liste.
else if FWebView = 'edit' then
begin
WebCall('PM.cancelEdit()');
FWebView := 'detail';
Key := 0;
end
else if FWebView = 'detail' then else if FWebView = 'detail' then
begin begin
WebCall('PM.back()'); WebCall('PM.back()');
@@ -1036,6 +1064,10 @@ end;
/// TIRER, sinon il ecraserait ce qu'un autre appareil a publie entre-temps. /// TIRER, sinon il ecraserait ce qu'un autre appareil a publie entre-temps.
/// ///
/// AReopenUuid vide = revenir a la liste plutot qu'a une entree. /// AReopenUuid vide = revenir a la liste plutot qu'a une entree.
/// AComment EST LE MESSAGE DU COMMIT : il part au depot et s'y lit dans
/// l'historique. Il dit donc "update from phone" et rien de plus - ni le nom
/// du compte, ni ce qui a change. Le detail reste dans le journal local, ou
/// il sert au diagnostic sans quitter l'appareil.
procedure TForm8.CommitChange(const ALabel, AComment, AReopenUuid: string; procedure TForm8.CommitChange(const ALabel, AComment, AReopenUuid: string;
const APatch: TFunc<string, string>); const APatch: TFunc<string, string>);
begin begin
@@ -1247,13 +1279,53 @@ begin
end; end;
LUuid := FCurrent.Uuid; LUuid := FCurrent.Uuid;
LTitle := FCurrent.Display; LTitle := FCurrent.Display;
CommitChange('Saved: ' + LTitle, AField + ' change from phone', LUuid, CommitChange('Saved: ' + LTitle, 'update from phone', LUuid,
function (APlain: string): string function (APlain: string): string
begin begin
Result := PatchEntryField(APlain, LUuid, AField, ANewValue); Result := PatchEntryField(APlain, LUuid, AField, ANewValue);
end); end);
end; end;
/// L'EDITION DE L'ENTREE ENTIERE. Un champ a la fois, c'etait un commit par
/// champ : corriger un nom et son adresse laissait deux instantanes dans le
/// depot. Ici les corrections s'appliquent l'une apres l'autre au meme clair,
/// et c'est le resultat qui part.
///
/// Le filtre des noms est celui de SaveEntryField, et pour la meme raison : ce
/// qui traverse le pont se traite comme une entree exterieure.
procedure TForm8.SaveEntryFields(const AFields, AValues: TArray<string>);
var
LUuid, LTitle: string;
I: Integer;
begin
if Length(AFields) = 0 then Exit;
if FCurrent.Uuid = '' then
begin
RefuseChange('This entry has no uuid - cannot be updated');
Exit;
end;
for I := 0 to High(AFields) do
if (AFields[I] <> 'title') and (AFields[I] <> 'site')
and (AFields[I] <> 'username') and (AFields[I] <> 'password')
and (AFields[I] <> 'folder') and (AFields[I] <> 'totp_secret') then
begin
RefuseChange('That field cannot be changed from here');
Exit;
end;
LUuid := FCurrent.Uuid;
LTitle := FCurrent.Display;
CommitChange('Saved: ' + LTitle,
'update from phone', LUuid,
function (APlain: string): string
var
J: Integer;
begin
Result := APlain;
for J := 0 to High(AFields) do
Result := PatchEntryField(Result, LUuid, AFields[J], AValues[J]);
end);
end;
/// SUPPRIMER - la seule action sans retour depuis le telephone. /// SUPPRIMER - la seule action sans retour depuis le telephone.
/// ///
/// Elle ne met pas a la corbeille : celle du poste ne voyage pas dans la /// Elle ne met pas a la corbeille : celle du poste ne voyage pas dans la
@@ -1322,6 +1394,12 @@ begin
// Le depot est reste ou nous l'avions laisse : rien a fondre, il // Le depot est reste ou nous l'avions laisse : rien a fondre, il
// ne manque que notre publication. // ne manque que notre publication.
FETag := LGet.ETag FETag := LGet.ETag
else if LGet.Status = ssNoSnapshot then
// DEPOT VIDE : c'est une creation, pas un echec. Sans sha, l'API
// des contenus cree le fichier ; avec un sha inconnu d'elle, elle
// refuserait. On publiait donc "read failed (HTTP 404)" en boucle
// sur un depot neuf, et rien ne partait jamais.
FETag := ''
else else
begin begin
LErr := Format('read failed (HTTP %d) %s', [LGet.HttpCode, LGet.Error]); LErr := Format('read failed (HTTP %d) %s', [LGet.HttpCode, LGet.Error]);
@@ -1329,7 +1407,7 @@ begin
end; end;
LBody := EncryptContainerWithKey(LMerged, FKey, FKeySalt, FKeyIters); LBody := EncryptContainerWithKey(LMerged, FKey, FKeySalt, FKeyIters);
LPut := LClient.Put(LBody, FETag, 'offline changes from phone'); LPut := LClient.Put(LBody, FETag, 'update from phone');
if LPut.Status = ssOK then if LPut.Status = ssOK then
begin begin
FETag := LPut.ETag; FETag := LPut.ETag;
@@ -1469,7 +1547,7 @@ begin
// quelques secondes, et il n'arrive qu'une fois. // quelques secondes, et il n'arrive qu'une fois.
if FKey = nil then LBody := EncryptContainer(LMerged, FSyncPwd) if FKey = nil then LBody := EncryptContainer(LMerged, FSyncPwd)
else LBody := EncryptContainerWithKey(LMerged, FKey, FKeySalt, FKeyIters); else LBody := EncryptContainerWithKey(LMerged, FKey, FKeySalt, FKeyIters);
LPut := LClient.Put(LBody, LGet.ETag, 'reconciled from phone'); LPut := LClient.Put(LBody, LGet.ETag, 'update from phone');
if LPut.Status = ssOK then if LPut.Status = ssOK then
begin begin
FETag := LPut.ETag; FETag := LPut.ETag;
@@ -1574,7 +1652,7 @@ begin
// Pas de rang a rouvrir : les entrees n'existeront plus. AdoptVault ramene la // Pas de rang a rouvrir : les entrees n'existeront plus. AdoptVault ramene la
// liste, ce qui est exactement ou l'on veut se retrouver. // liste, ce qui est exactement ou l'on veut se retrouver.
CommitChange(Format('Deleted %d', [Length(LUuids)]), CommitChange(Format('Deleted %d', [Length(LUuids)]),
'entries deleted from phone', '', 'update from phone', '',
function (APlain: string): string function (APlain: string): string
var var
I: Integer; I: Integer;
@@ -1665,7 +1743,7 @@ begin
Exit; Exit;
end; end;
CommitChange(Format('Duplicated %d', [Length(LUuids)]), CommitChange(Format('Duplicated %d', [Length(LUuids)]),
'entries duplicated from phone', '', 'update from phone', '',
function (APlain: string): string function (APlain: string): string
var var
I: Integer; I: Integer;
@@ -1686,7 +1764,7 @@ begin
RefuseChange('Give it at least a name'); RefuseChange('Give it at least a name');
Exit; Exit;
end; end;
CommitChange('Added: ' + ATitle, 'new entry from phone', '', CommitChange('Added: ' + ATitle, 'update from phone', '',
function (APlain: string): string function (APlain: string): string
begin begin
Result := AddEntry(APlain, ATitle, ASite, AUser, APwd, AKind, AFolder, Result := AddEntry(APlain, ATitle, ASite, AUser, APwd, AKind, AFolder,
@@ -1950,7 +2028,10 @@ begin
end end
else if LAction = 'icon' then else if LAction = 'icon' then
SetEntryIcon(Param('a') = 'fetch') begin
if Param('a') = 'pick' then PickEntryIcon
else SetEntryIcon(Param('a') = 'fetch');
end
{$IFDEF DEBUG} {$IFDEF DEBUG}
// JETER LE COFFRE LOCAL - debogage seulement, et absent a la compilation // JETER LE COFFRE LOCAL - debogage seulement, et absent a la compilation
@@ -1984,7 +2065,10 @@ begin
// SCANNER LE QR DU POSTE depuis l'app : le scanner du systeme envoyait // SCANNER LE QR DU POSTE depuis l'app : le scanner du systeme envoyait
// le lien a une recherche web ou demandait avec quoi l'ouvrir. // le lien a une recherche web ou demandait avec quoi l'ouvrir.
else if LAction = 'scanqr' then else if LAction = 'scanqr' then
StartScan begin
FScanTotp := Param('for') = 'totp';
StartScan;
end
else if LAction = 'pinclear' then else if LAction = 'pinclear' then
ClearUnlockCode(TNetEncoding.URL.Decode(Param('c'))) ClearUnlockCode(TNetEncoding.URL.Decode(Param('c')))
@@ -2002,6 +2086,21 @@ begin
SaveEntryField(TNetEncoding.URL.Decode(Param('f')), SaveEntryField(TNetEncoding.URL.Decode(Param('f')),
TNetEncoding.URL.Decode(Param('v'))) TNetEncoding.URL.Decode(Param('v')))
// f0=&v0=&f1=&v1=... : autant de paires que de champs modifies.
else if LAction = 'setfields' then
begin
var LF: TArray<string> := [];
var LV: TArray<string> := [];
var LN := 0;
while Param('f' + IntToStr(LN)) <> '' do
begin
LF := LF + [TNetEncoding.URL.Decode(Param('f' + IntToStr(LN)))];
LV := LV + [TNetEncoding.URL.Decode(Param('v' + IntToStr(LN)))];
Inc(LN);
end;
SaveEntryFields(LF, LV);
end
// La page vient de poser le focus sur la recherche : on deplie le clavier. // La page vient de poser le focus sur la recherche : on deplie le clavier.
else if LAction = 'kbd' then else if LAction = 'kbd' then
ShowSoftKeyboard ShowSoftKeyboard
@@ -2478,8 +2577,12 @@ begin
try try
LSw := TStopwatch.StartNew; LSw := TStopwatch.StartNew;
AdoptVault(LPlain); AdoptVault(LPlain);
if FUiKbdOnSearch then // ON OUVRE POUR CHERCHER - sauf quand l'ouverture n'est qu'une etape.
WebCall('PM.focusSearch()'); // on ouvre pour chercher : clavier pret // Synchroniser coffre ferme passe par ici avant de publier : le clavier
// montait alors au milieu d'une synchro, pour une recherche que personne
// n'avait demandee.
if FUiKbdOnSearch and (not LChainSync) then
WebCall('PM.focusSearch()');
Log(' displayed in ' + LSw.ElapsedMilliseconds.ToString + ' ms'); Log(' displayed in ' + LSw.ElapsedMilliseconds.ToString + ' ms');
// Afficher d'abord, synchroniser ensuite : le coffre est a l'ecran, le // Afficher d'abord, synchroniser ensuite : le coffre est a l'ecran, le
// reseau se debrouille derriere. Sans mot de passe range, on ne peut // reseau se debrouille derriere. Sans mot de passe range, on ne peut
@@ -2497,8 +2600,15 @@ begin
[MinutesBetween(Now, FLastRefresh), REFRESH_MIN_MINUTES])) [MinutesBetween(Now, FLastRefresh), REFRESH_MIN_MINUTES]))
else else
begin begin
// ON REGARDE, ON N'ADOPTE PAS. L'ouverture tirait tout l'instantane et
// le dechiffrait, alors qu'elle venait d'ouvrir depuis le cache : le
// reseau decidait du moment ou la liste changeait sous les yeux, et
// une panne se voyait en plein ecran pour un simple lancement.
// Une requete conditionnelle suffit a savoir si le depot a bouge ;
// s'il a bouge, le temoin de synchro s'allume et c'est l'utilisateur
// qui decide quand tirer.
FLastRefresh := Now; FLastRefresh := Now;
FetchAndDecrypt(FUrl, FToken, FSyncPwd, True); TThread.ForceQueue(nil, procedure begin ProbeRemote; end);
end; end;
except except
on E: Exception do on E: Exception do
@@ -2907,6 +3017,30 @@ begin
begin begin
// Le serveur repond pareil dans les deux cas, par conception. // Le serveur repond pareil dans les deux cas, par conception.
Log(' 404: no snapshot OR invalid token'); Log(' 404: no snapshot OR invalid token');
// DEPOT VIDE ET DES CHANGEMENTS QUI ATTENDENT ICI : on publie.
//
// Le telephone verifiait d'abord le mot de passe de synchro en
// telechargeant l'instantane - et s'arretait la quand il n'y en
// avait pas encore, en repetant 404 a chaque essai. Rien ne
// partait jamais, alors que c'est exactement le premier envoi :
// le poste fait de meme sur un depot neuf.
//
// Le mot de passe tape devient celui du depot, faute d'un
// instantane contre lequel l'eprouver - c'est ce que veut dire
// "premiere publication".
if (FPlain <> '') and FPending then
begin
TThread.Queue(nil,
procedure
begin
FSyncPwd := APwd;
FPwdUserSet := False;
FPwdShown := APwd;
Log(' empty repository - publishing what waits here');
PublishPending;
end);
Exit;
end;
if not ASilent then if not ASilent then
begin begin
// TRANCHER L'AMBIGUITE : fichier absent, depot ou utilisateur // TRANCHER L'AMBIGUITE : fichier absent, depot ou utilisateur
@@ -3342,7 +3476,7 @@ begin
if not AFetch then if not AFetch then
begin begin
CommitChange('Icon removed: ' + LTitle, 'icon removed from phone', LUuid, CommitChange('Icon removed: ' + LTitle, 'update from phone', LUuid,
function (APlain: string): string function (APlain: string): string
begin begin
Result := PatchEntryIcon(APlain, LUuid, '', False); Result := PatchEntryIcon(APlain, LUuid, '', False);
@@ -3383,7 +3517,7 @@ begin
// Provenance a FAUX : elle vient d'une recherche automatique, pas // Provenance a FAUX : elle vient d'une recherche automatique, pas
// d'un choix. Le poste ne remplace jamais une icone choisie lors // d'un choix. Le poste ne remplace jamais une icone choisie lors
// d'un rafraichissement, et ce drapeau est ce qui le lui dit. // d'un rafraichissement, et ce drapeau est ce qui le lui dit.
CommitChange('Icon set: ' + LTitle, 'icon fetched from phone', LUuid, CommitChange('Icon set: ' + LTitle, 'update from phone', LUuid,
function (APlain: string): string function (APlain: string): string
begin begin
Result := PatchEntryIcon(APlain, LUuid, LUri, False); Result := PatchEntryIcon(APlain, LUuid, LUri, False);
@@ -3392,6 +3526,100 @@ begin
end).Start; end).Start;
end; end;
/// L'ICONE VENUE DE LA GALERIE. Provenance a VRAI, comme un televersement
/// depuis le poste : une icone choisie ne doit jamais etre remplacee par une
/// recherche automatique, et ce drapeau est ce qui le dit au poste.
const
ICON_MAX_PX = 128;
procedure TForm8.IconPicked(AImage: TBitmap);
var
LSmall: TBitmap;
LStream: TMemoryStream;
LB64: TBase64Encoding;
LUri, LTitle, LUuid: string;
LScale: Single;
begin
if (AImage = nil) or (AImage.Width = 0) or (AImage.Height = 0) then Exit;
if FCurrent.Uuid = '' then Exit;
LUuid := FCurrent.Uuid;
LTitle := FCurrent.Display;
if AImage.Width > AImage.Height then
LScale := ICON_MAX_PX / AImage.Width
else
LScale := ICON_MAX_PX / AImage.Height;
if LScale > 1 then LScale := 1;
LSmall := TBitmap.Create(Round(AImage.Width * LScale),
Round(AImage.Height * LScale));
try
LSmall.Canvas.BeginScene;
try
LSmall.Canvas.DrawBitmap(AImage, RectF(0, 0, AImage.Width, AImage.Height),
RectF(0, 0, LSmall.Width, LSmall.Height), 1, True);
finally
LSmall.Canvas.EndScene;
end;
LStream := TMemoryStream.Create;
try
LSmall.SaveToStream(LStream); // PNG
// Sans coupure de ligne : une URI data: coupee ne s'affiche pas.
LB64 := TBase64Encoding.Create(0);
try
LUri := 'data:image/png;base64,'
+ LB64.EncodeBytesToString(LStream.Memory, LStream.Size);
finally
LB64.Free;
end;
finally
LStream.Free;
end;
finally
LSmall.Free;
end;
Log(Format('icon: picked image, %d chars', [Length(LUri)]));
CommitChange('Icon set: ' + LTitle, 'update from phone', LUuid,
function (APlain: string): string
begin
Result := PatchEntryIcon(APlain, LUuid, LUri, True);
end);
end;
procedure TForm8.BeginPickIcon;
begin
if FPickIcon = nil then
begin
FPickIcon := TTakePhotoFromLibraryAction.Create(Self);
FPickIcon.OnDidFinishTaking := IconPicked;
end;
FPickIcon.Execute;
end;
procedure TForm8.PickEntryIcon;
begin
if FBusy then Exit;
if FCurrent.Uuid = '' then
begin
RefuseChange('This entry has no uuid');
Exit;
end;
{$IFDEF ANDROID}
// Les deux noms : le premier est celui d'Android 13 et plus, le second celui
// d'avant. Un nom inconnu du systeme est simplement refuse, et le selecteur
// recent n'exige de toute facon rien - on ouvre donc dans tous les cas, et
// c'est Android qui refuse s'il doit refuser.
PermissionsService.RequestPermissions(
['android.permission.READ_MEDIA_IMAGES',
'android.permission.READ_EXTERNAL_STORAGE'],
procedure(const APermissions: TClassicStringDynArray;
const AGrantResults: TClassicPermissionStatusDynArray)
begin
BeginPickIcon;
end);
{$ELSE}
BeginPickIcon;
{$ENDIF}
end;
/// POSER OU CHANGER LE CODE, IMMEDIATEMENT ET SUR DEMANDE. /// POSER OU CHANGER LE CODE, IMMEDIATEMENT ET SUR DEMANDE.
/// ///
/// Le coffre doit etre ouvert : c'est la seule preuve que le code courant est /// Le coffre doit etre ouvert : c'est la seule preuve que le code courant est
@@ -3566,7 +3794,10 @@ begin
except except
// Pas d'autofocus continu : l'image restera nette a bonne distance. // Pas d'autofocus continu : l'image restera nette a bonne distance.
end; end;
FScanLbl.Text := 'Point the camera at the QR shown on the PC'; if FScanTotp then
FScanLbl.Text := 'Point the camera at the 2FA QR code'
else
FScanLbl.Text := 'Point the camera at the QR shown on the PC';
FCam.Active := True; FCam.Active := True;
FScanWatch.Enabled := True; FScanWatch.Enabled := True;
Log('QR scan started'); Log('QR scan started');
@@ -3653,13 +3884,30 @@ begin
// CHERCHE, NE COMPARE PAS LE DEBUT : le texte decode est arrive // CHERCHE, NE COMPARE PAS LE DEBUT : le texte decode est arrive
// avec un caractere invisible en tete (104 lus pour 103 emis), // avec un caractere invisible en tete (104 lus pour 103 emis),
// et le lien etait ignore alors qu'il etait lu. // et le lien etait ignore alors qu'il etait lu.
if Pos('pmserver://sync?', LText) > 0 then // 2FA : le lien otpauth:// porte le SECRET. Il part a la page,
// qui le range dans le champ ; le journal n'en dit que le fait.
if FScanTotp and (Pos('otpauth://', LowerCase(LText)) > 0) then
begin
StopScan;
Log('QR scan: 2FA code read');
WebCall('PM.totpFromQr(' + JsArg(Trim(Copy(LText,
Pos('otpauth://', LowerCase(LText)), MaxInt))) + ')');
end
else if (not FScanTotp) and (Pos('pmserver://sync?', LText) > 0) then
begin begin
StopScan; StopScan;
ApplySyncLink(Trim(Copy(LText, Pos('pmserver://sync?', LText), MaxInt))); ApplySyncLink(Trim(Copy(LText, Pos('pmserver://sync?', LText), MaxInt)));
end end
// UN QR LU, MAIS PAS LE BON. On continue de viser - plusieurs QR
// peuvent etre dans le champ - et le bandeau dit pourquoi rien ne
// se passe, au lieu d'un scan qui semble ne rien voir.
else if FScanTotp then
FScanLbl.Text := 'This QR code is not a 2FA code - aim at the one the site shows'
else else
begin
FScanLbl.Text := 'This QR code is not the one shown by the PC';
Log('QR scan: not a sync link, first char code ' + IntToStr(Ord(LText[1]))); Log('QR scan: not a sync link, first char code ' + IntToStr(Ord(LText[1])));
end;
end); end);
end).Start; end).Start;
end); end);
@@ -3773,10 +4021,26 @@ end;
procedure TForm8.ProbeRemote; procedure TForm8.ProbeRemote;
begin begin
if FBusy or (FUrl = '') or (FToken = '') then Exit; // JOURNALISE MEME QUAND ELLE NE FAIT RIEN. Une verification muette ne se
// distingue pas d'une verification absente : on ne savait pas si le depot
// avait ete interroge, seulement qu'il n'avait rien dit.
if FBusy or (FUrl = '') or (FToken = '') then
begin
Log('check: skipped (busy or no repository configured)');
Exit;
end;
// Un coffre ne ici n'a pas de depot a interroger. // Un coffre ne ici n'a pas de depot a interroger.
if HasLocalContainer then Exit; if HasLocalContainer then
if FETag = '' then Exit; begin
Log('check: skipped (local vault, no repository)');
Exit;
end;
if FETag = '' then
begin
Log('check: skipped (no fingerprint to compare)');
Exit;
end;
Log('check: asking the repository if it moved');
TThread.CreateAnonymousThread( TThread.CreateAnonymousThread(
procedure procedure
@@ -3792,10 +4056,29 @@ begin
LGet := LClient.Fetch(FETag); LGet := LClient.Fetch(FETag);
LAhead := (LGet.Status = ssOK) and (LGet.ETag <> '') LAhead := (LGet.Status = ssOK) and (LGet.ETag <> '')
and not SameText(LGet.ETag.Trim, FETag.Trim); and not SameText(LGet.ETag.Trim, FETag.Trim);
// TROIS VERDICTS, PAS DEUX. "Rien n'a change" et "je n'ai pas pu
// demander" se ressemblent - le temoin reste eteint dans les deux
// cas - et le journal les confondait sous "unchanged (HTTP 0)".
TThread.Queue(nil,
procedure
begin
if LAhead then
Log(' it moved - sync to bring it in')
else if LGet.Status in [ssOK, ssNotModified] then
Log(Format(' unchanged (HTTP %d)', [LGet.HttpCode]))
else
Log(Format(' no answer (HTTP %d) %s',
[LGet.HttpCode, LGet.Error]));
end);
except except
// Hors reseau : ce n'est pas une nouvelle du depot, c'est une absence on E: Exception do
// de nouvelle. Le temoin ne change pas. begin
Exit; // Hors reseau : ce n'est pas une nouvelle du depot, c'est une
// absence de nouvelle. Le temoin ne change pas, mais le journal le
// dit - sinon rien ne distingue "pas joignable" de "rien change".
TThread.Queue(nil, procedure begin Log(' not reachable'); end);
Exit;
end;
end; end;
finally finally
LClient.Free; LClient.Free;
@@ -3805,7 +4088,6 @@ begin
procedure procedure
begin begin
if FRemoteAhead then Exit; // deja dit if FRemoteAhead then Exit; // deja dit
Log('remote has moved - sync to bring it in');
SetRemoteAhead(True); SetRemoteAhead(True);
end); end);
end).Start; end).Start;