Compare commits

..

52 Commits

Author SHA1 Message Date
r-zakarya 092eb2f73a feat(fillbar): bouton Paste qui tape le presse-papiers Windows
Pour les clients a distance qui ne transmettent pas le presse-papiers
(TigerVNC vers un Mac) : le texte est tape par le chemin de l'autofill,
touches VNC et dispositions comprises, insere au curseur. Et l'arabe des
champs a chasse fixe s'affiche a la bonne taille (repli Segoe UI / Tahoma).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:53:09 +01:00
r-zakarya bf7a8f4d38 fix(autofill): texte arabe + latin tape en entier vers VNC
Un caractere sans touche dans la disposition courante est cherche dans les
autres dispositions installees ; la fenetre VNC y bascule le temps de le
taper, puis la disposition d'origine est rendue. Eprouve vers un Mac, clavier
francais comme arabe actif.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:55:09 +01:00
r-zakarya 9999dd113d fix(autofill): AltGr et caracteres sans touche vers VNC
AltGr envoye en Ctrl gauche + Alt droit etendu, avec scancodes : "@" et
"#" en AZERTY arrivaient sans AltGr. Un caractere sans touche dans la
disposition locale est saute et compte dans le journal (copier a la place).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:45:33 +01:00
r-zakarya a3e63ee8f1 fix(autofill): vraies touches vers un visualiseur VNC
VK_PACKET (texte Unicode) n'est pas retransmis par TigerVNC : le Mac
distant recevait un V par caractere. Cible VNC -> touches du clavier via
VkKeyScanEx dans la disposition de la cible, modificateurs compris.
Les autres fenetres gardent l'envoi Unicode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:34:47 +01:00
r-zakarya 6397ed008a docs: LISEZ-MOI du paquet entre au depot
Le mode d'emploi livre avec l'exe et l'APK ne vivait que dans le ZIP.
A jour des notes de commandes, de Precedent / Suivant et des raccourcis
desactivables.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:47:39 +01:00
r-zakarya b1c0c1e11b feat(apk): notes de commandes, une commande copiee a la fois
Bloc Commands en tete d'une note "commands", groupe par groupe, avec un
bouton copier. ParseCommandNote est le portage du decoupage du poste
(groupes, lignes continuees). La copie passe par un rang, comme les autres
lignes du detail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:39:38 +01:00
r-zakarya f112de508b feat(notes): commandes sur plusieurs lignes recollees en une seule
Fin de ligne en \ (bash), ` (PowerShell) ou ^ (cmd) : la ligne suivante
est rattachee, et la commande part sur une ligne - un retour a la ligne
tape dans un terminal la lancerait a moitie ecrite. Teste.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:08:08 +01:00
r-zakarya a0527eb806 feat(quicksearch): bouton retour visible depuis une note de commandes
Fleche a la place de la loupe et Retour arriere sur champ vide, en plus
d'Esc qui ne se voyait pas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:38:56 +01:00
r-zakarya 6ca1e4b2cf feat(notes): commandes masquees avec l'oeil des notes
Partage d'ecran : premier mot et 3 derniers caracteres visibles
(ssh ••••.11), dans le panneau et la recherche rapide. Filtre, copie et
remplissage gardent le vrai texte. Aide de la recherche rapide adaptee aux
commandes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:35:37 +01:00
r-zakarya bbd4e2ef06 feat(notes): notes de commandes, une commande copiee ou tapee a la fois
Modele "commands" : "# titre" ouvre un groupe, chaque ligne est une
commande. Le corps reste du texte (aucune colonne, sync et APK inchanges).
Panneau : liste avec Copier. Recherche rapide : la note s'ouvre sur sa
liste filtrable ; Entree tape la commande sans Entree et sans vider le
champ, Ctrl+Entree copie, Esc revient. Decoupage teste.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:24:49 +01:00
r-zakarya 63e532d5bb feat(entries): Precedent / Suivant parmi les entrees ouvertes
Fleches dans l'en-tete du panneau et Alt+fleches, comme un navigateur :
un clic par erreur dans une liste filtree ne force plus a refaire la
recherche. Historique en memoire (50), vide au verrouillage ; la navigation
repasse par openSlideOver et sa confirmation de modifications non
enregistrees. Logique pure testee (history.test.js).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:39:24 +01:00
r-zakarya 28f8a42846 feat(hotkeys): chaque raccourci se desactive (Suppr pendant la capture)
Off = vk 0, non enregistre : la combinaison reste libre pour les autres
applis. Combinaisons retenues en pref DPAPI et rejouees au demarrage, pour
qu'un raccourci coupe le reste coffre ferme. Esc annule enfin la capture au
lieu de fermer les reglages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 18:15:29 +01:00
r-zakarya 27afc4e8ca fix(webview): cache Edge vide au demarrage, plus a la fermeture
TMS effacait EdgeCache dans son destructeur pendant que msedgewebview2
supprimait encore ses fichiers : EDirectoryNotFoundException (DIPS-wal) a
la fermeture. AutoClearCache coupe ; l'app vide le dossier avant de creer
le navigateur. Cinq lancements / fermetures sans erreur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 18:04:21 +01:00
r-zakarya e8e93cba56 fix(fillbar): la barrette remplit meme quand les raccourcis sont coupes
"Autofill with global hotkey" desactive faisait ignorer a la page toute
demande, barrette comprise. Delphi dit maintenant d'ou vient la demande ;
seuls les raccourcis sont coupes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:45:34 +01:00
r-zakarya 553d9db621 fix(lock): le coffre ouvert n'est plus vu verrouille apres un verrou Windows
Demarrage dans le tray, veille ou Win+L avant toute connexion : lockVault
posait state.locked, et seul doUnlock le levait - la connexion complete non.
Le coffre s'affichait ouvert, l'autofill repondait "Vault is locked".
state.locked = false dans enterApp, commun a tous les chemins d'ouverture.
Reproduit avec l'exe precedent, corrige avec celui-ci.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 21:37:13 +01:00
r-zakarya 84a349e54d docs(sync): nom de jeton neutre dans le guide GitHub
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:41:50 +01:00
r-zakarya 4d9cb909c4 docs(sync): guide pas a pas de la synchro par un depot GitHub prive
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:38:08 +01:00
r-zakarya c78b80a073 docs(sync): guide pas a pas du WebDAV local (WsgiDAV, PC et telephone)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:30:01 +01:00
r-zakarya 0604344c60 feat(sync): http:// limite au reseau local, Basic des la 1re requete
APK : le clair est permis par Android et trie dans TSyncClient.RefusalReason
(IsLocalHost : RFC 1918, 127.x, .local, .lan, nom sans point) - chacun pointe
vers son propre WebDAV sans IP connue a la compilation ; vers Internet, HTTPS.
PC : Authorization Basic envoye d'emblee, plus de 401 anonyme ni de PUT du
coffre envoye deux fois.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:26:17 +01:00
r-zakarya 3051c3c392 feat(sync): WebDAV sur l'APK, et le QR du poste qui le porte
Jeton "utilisateur:mot-de-passe" sur une URL ni Gitea ni GitHub = WebDAV,
authentification Basic. Fichier lu et ecrit tel quel ; l'ETag fait le sha
(If-None-Match, If-Match, 412 -> conflit). Clair autorise vers l'IP fixe du
poste seulement. Eprouve contre le WsgiDAV local : 304, PUT 204, puis
synchro du poste. Docs : CLAUDE.md, android-client/README.md (page eveillee
comprise).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:13:05 +01:00
r-zakarya 2cc8d1b62b fix(autofill): page tenue eveillee dans le tray et au retour de veille
Premier autofill apres des heures cachee : la page mettait 3 a 5 s a
repondre, alors que les drapeaux anti-gel de Chromium etaient appliques.
Un ExecuteJavaScript trivial toutes les 5 min, et tout de suite a la sortie
de veille ou au deverrouillage de session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:13:05 +01:00
r-zakarya bcbbcebe16 diag(lock): chaque verrouillage retient sa raison, et l'autofill la dit
Apres un demarrage en tray, une connexion reussie puis un remplissage
repondait "Vault is locked" sans rien dire de plus. Chaque lockVault() porte
desormais sa cause (inactivite, session Windows verrouillee ou en veille, menu
du tray, PIN faux, geste manuel) avec l'heure ; sans verrouillage connu, le
message donne l'heure de chargement de la page - ce qui trahit une page
rechargee entre la connexion et le remplissage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 21:47:17 +01:00
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
22 changed files with 2687 additions and 274 deletions
+7
View File
@@ -34,6 +34,13 @@
<!-- Scanner du QR des reglages de synchro, demandee au premier appui. -->
<uses-permission android:name="android.permission.CAMERA" />
<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"/>
<queries>
<%queries-child-elements%>
+159 -2
View File
@@ -198,6 +198,19 @@ Retour Delphi → JS via `WebBrowser.ExecuteJavaScript('Bridge.onX(...)')`.
Combos autofill configurables depuis Settings (synced en DB
via `settings_json`).
**Desactiver un raccourci (2026-10-05)** : clic sur le bouton, puis **Suppr**
(ou Retour arriere) → `key: ''` → vk 0, que Delphi n'enregistre pas ; libelle
*Off*. Les combinaisons sont aussi **retenues en pref DPAPI `hotkeys`** (la
requete `autofill/hotkeys` telle quelle, hors suspension de capture) et
**rejouees au demarrage** a la place des valeurs par defaut : `settings_json`
n'arrive qu'apres la connexion, et un `Ctrl+Shift+A` desactive restait pris
tant que le coffre etait ferme. Piege corrige au passage : l'Esc des reglages
(ecouteur `document` en capture, enregistre avant) fermait Settings au lieu
d'annuler la capture — il ignore Esc tant qu'un `[data-capturing="1"]` existe.
**La barrette remplit meme raccourcis coupes** : `onAutofillRequest` recoit
`fromBar`, l'option « Autofill with global hotkey » ne bloque que les
raccourcis.
## Fill bar (remplir à la souris)
Menu du tray → **Fill bar** : une barrette de trois
@@ -262,6 +275,26 @@ Détails :
`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
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`,
une seule proc d'origine — même classe). Sur `WM_LBUTTONDOWN`, **`DragDetect`**
tranche entre clic et glissement en respectant le seuil réglé par
@@ -283,6 +316,16 @@ Détails :
Le cadre est peint dans `WM_ERASEBKGND` plutôt que par `WS_BORDER`, qui se
sert dans les couleurs système.
**Bouton Paste (2026-10-14)** : `LAZY_ID_PASTE` / cle `paste` (desactive par
defaut) → `PasteClipboardAsKeystrokes` lit le presse-papiers **Windows**
(`CF_UNICODETEXT`) et le **tape** dans la fenetre en avant par
`ExecuteAutofill(..., AClearFirst=False)` - donc vraies touches et bascule de
disposition vers un client VNC (TigerVNC vers un Mac ne transmet pas le
presse-papiers). Insere au curseur, saut de ligne final retire (un terminal
lancerait la commande), sauts internes tapes en Entree. Marche coffre ferme :
rien ne passe par la page. Pas de texte (image, fichier) → bulle « No text in
the clipboard ».
### Réglages de la barrette (2026-09-18)
**Settings → onglet Fill bar**, et le **clic droit** sur la barrette pour les
@@ -493,7 +536,10 @@ faire semblant couterait plus que de l'admettre.
**Le reste du travail Android est dans
[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
survit au schema retire, le scanner de QR (`ThirdParty/ZXing`), le define
`AllowCreateLocal` — creer un coffre sur le telephone est reserve au debogage —
@@ -774,7 +820,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
chemin de recherche du `.dproj`. Pas de sélection de zone : le décodeur trouve
le QR où qu'il soit. **Seul un `otpauth://` repart vers la page**
(`Bridge.onScreenQr`), le reste de l'écran ne quitte pas l'exe. Format `BGR32`
(`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
→ image blanche.
@@ -1124,6 +1173,11 @@ QR que l'application Android scanne elle-meme.
- **Piege CSS rencontre** : `css/style.css` impose `svg { width: 16px }` a
toutes les icones, ce qui reduisait le QR a la taille d'une icone. La taille
est donc posee **en style direct** sur le `<svg>`.
- **WebDAV (2026-10-02)** : le telephone n'a qu'un champ « jeton », donc pour une
URL qui n'est ni Gitea ni GitHub le QR porte `syncUser:syncPwd` — le
telephone en fait une authentification Basic (voir android-client/README.md).
Le QR reprend l'URL telle quelle : une configuration en `127.0.0.1` designe
le telephone lui-meme une fois scannee, il faut l'IP du poste.
### GitHub, et refus d'un depot public (2026-09-18)
@@ -1143,6 +1197,25 @@ QR que l'application Android scanne elle-meme.
- 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`.
### 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
**Rien n'est fait, et le code actuel reste tel quel** (decide le 2026-09-17).
@@ -1380,6 +1453,25 @@ d'accord.
### Contraintes connues autofill
- **VNC (2026-10-14)** : les caracteres partent en `KEYEVENTF_UNICODE`
(`VK_PACKET`), que TigerVNC & co ne savent pas retransmettre - un Mac
recevait un `V` par caractere. Cible dont l'exe contient « vnc »
(`TargetTypesByKeyCodes`) → **vraies touches** (`SendKeyCodeString` :
`VkKeyScanEx` dans la disposition de la cible, + Maj/Ctrl/Alt, AltGr =
Ctrl+Alt). **AltGr = `VK_LCONTROL` + `VK_RMENU` etendu**, comme le vrai
clavier : un Ctrl+Alt generique etait lu par TigerVNC comme deux
modificateurs, et `@` (AltGr+0 en AZERTY) arrivait en `a`. Chaque touche porte
aussi son scancode (`MapVirtualKeyEx`). **Texte melange (arabe + latin)** :
un caractere sans touche dans la disposition courante est cherche dans les
autres dispositions installees (`GetKeyboardLayoutList`) ; la fenetre cible
y est basculee (`WM_INPUTLANGCHANGEREQUEST`, attente que son fil l'ait
prise - TigerVNC traduit chaque touche avec la disposition de SON fil), et
la disposition d'origine est rendue a la fin. Seul un caractere qu'aucune
disposition installee ne sait taper est **saute et compte** - journal
`N character(s) have no key on this keyboard and were NOT typed - copy
instead`. Eprouve vers un Mac : `تاسيب... jhbs... @ #` complet, clavier
francais comme arabe actif.
- **UIPI** : SendInput vers process élevé bloqué. Notepad++ admin,
regedit → autofill silencieusement no-op. Tester avec apps non-élevées.
- **Chrome password manager** : si Chrome a des credentials sauvegardés
@@ -1464,6 +1556,15 @@ après chaque `DELETE /attachments/{id}`. Un petit vault sain ne paie rien.
→ retombe sur pwd).
Fix clipboard : `MinimizeToTray(AClearClipboard=False)` via `keepclip=1`
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
brand, expiry…) rendent un `<input>` + menu custom `.so-combo` (flèche =
toutes les options, input = frappe libre). PAS un `<datalist>` natif
@@ -1489,6 +1590,42 @@ après chaque `DELETE /attachments/{id}`. Un petit vault sain ne paie rien.
clic extérieur est déjà câblée). Basculer le flag secret se fait **en
place**, jamais via `rerender()` : reconstruire la liste fait flasher le
panneau et perd le focus + la hauteur auto du multiligne.
- **Precedent / Suivant des entrees ouvertes** (2026-10-10) : fleches ‹ › de
l'en-tete du panneau + **Alt+←/→** (le `preventDefault` empeche aussi la page
de « reculer »). `state.entryHistory = {ids, pos}`, 50 au plus, memoire
seulement, vide par `lockVault`. Alimente par `openSlideOver` (sauf
`opts.fromHistory`) ; la navigation repasse par `openSlideOver`, donc par la
confirmation « modifications non enregistrees » — la position ne bouge
qu'une fois l'entree vraiment ouverte. Entree supprimee sautee ; panneau
ferme, Alt+← rouvre celle qu'on vient de quitter. Logique pure
(`entryHistoryPush` / `entryHistoryTarget`) testee dans
`js/tests/history.test.js`. Raison : un clic par erreur dans une liste
filtree obligeait a refaire la recherche.
- **Notes de commandes** (2026-10-10, modele `commands`) : le corps reste du
texte - `# titre` ouvre un groupe, chaque autre ligne non vide est une
commande (`parseCommandNote`, teste dans `js/tests/commands.test.js`). Aucune
colonne : le texte est deja chiffre, synchronise, exporte et lu par l'APK.
Panneau : case *Command list* (bascule le modele, compte dans `isSoDirty` via
`originalTemplate`) + liste avec **Copier** seulement - la fenetre au premier
plan y serait l'app elle-meme. Recherche rapide : choisir la note ouvre sa
liste (`qsCommands`), la saisie filtre, Entree **tape sans Entree** et
**sans Ctrl+A + Suppr** (`executeAutofill(..., noClear)` : dans un terminal
Ctrl+A ramene en debut de ligne), Ctrl+Entree copie, Esc revient aux entrees.
**Masquage** (partage d'ecran) sur l'oeil existant des notes (`maskNotes`) :
`maskCommand` garde le premier mot et les 3 derniers caracteres
(`ssh ••••••••••••.11`), une fin de 6 caracteres ou moins est masquee en
entier. Affichage seulement : filtre, copie et remplissage voient le vrai
texte. L'oeil re-declenche `input` sur le textarea pour redessiner la liste.
**Lignes continuees** : une ligne finie par `\`, `` ` `` ou `^` se recolle a
la suivante (sans le caractere) - une seule commande, sur UNE ligne, car un
retour a la ligne tape dans un terminal vaut Entree. Ligne vide ou fin du
texte = fin de la commande ; un `#` apres une continuation reste du texte.
Limite : un chemin Windows fini par `\` en fin de ligne passe pour une
continuation.
- **Polices a chasse fixe et arabe** (2026-10-14) : aucune n'a l'arabe, et
Windows empruntait une police plus petite. Chaque pile `…, monospace` de
`css/style.css` finit par `'Segoe UI', Tahoma, monospace` : le latin reste a
chasse fixe, l'arabe prend une taille comparable.
- **Settings search** : `#settingsSearch` filtre les `.setting-row` par
texte ; match sur le label de section garde toute la section. Esc dans
la search avec query = clear ; Esc vide = ferme Settings.
@@ -1504,6 +1641,20 @@ checkpoint le WAL proprement (sinon Windows force-kill après timeout →
résidus `-shm`/`-wal`). Kill brutal (Task Manager) laisse les résidus, mais
SQLite récupère au prochain open.
## Page tenue éveillée (autofill lent après le tray, 2026-10-02)
Premier autofill après des heures dans le tray ou une veille : `Autofill: page
answered in 4907 ms` (8 ms coffre tout juste ouvert). Les drapeaux
`--disable-renderer-backgrounding` & co **sont bien appliqués** (vérifié sur la
ligne de commande de `msedgewebview2.exe`) : ce n'est pas Chromium qui gèle la
page, c'est Windows qui laisse s'endormir un processus que personne ne sollicite.
- `FKeepWarmTimer` (UMainForm) : un `ExecuteJavaScript` trivial **toutes les
5 min** (choix de l'utilisateur, pas 30 s).
- `TPMBridge.OnSystemResume` : `PBT_APMRESUMEAUTOMATIC` et `WTS_SESSION_UNLOCK`
réveillent la page **tout de suite** — le minuteur ne tourne pas pendant la
veille. Journal : `Windows resumed / unlocked — waking the page`.
## WebView2 nav race (écran noir cold-start)
Le timer de navigation (1.5 s) ne consomme `FPendingURL` que si
@@ -1589,6 +1740,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
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
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.
+109
View File
@@ -0,0 +1,109 @@
# Synchroniser le PC et le téléphone par un dépôt GitHub privé
Le PC publie une copie **chiffrée** de votre coffre dans un dépôt GitHub
**privé** ; le téléphone la reprend. Contrairement au WebDAV local
(`WEBDAV-LOCAL.md`), la synchro marche **de partout**, en 4G comme sur
n'importe quel Wi-Fi, PC éteint.
> **Ce qui est sur GitHub** : un fichier `data.json` illisible sans le *mot de
> passe de synchro*, qui ne quitte jamais vos appareils.
> **Ce mot de passe est la seule protection de cette copie** : choisissez-le
> fort (5 mots au hasard, ou 16 caractères tirés au hasard).
Il faut un compte GitHub (gratuit) : <https://github.com/signup>.
---
## 1. Créer le dépôt privé
1. Allez sur <https://github.com/new>.
2. **Repository name** : un nom neutre, par exemple `notes` (évitez `vault`,
`passwords`…).
3. Cochez **Private**. **Important** : l'application **refuse** un dépôt
public — chaque synchro y reste dans l'historique, lisible par tous.
4. **Create repository**. Pas besoin de README.
Notez son adresse, par exemple `https://github.com/moi/notes`.
## 2. Créer le jeton d'accès
Le jeton permet à l'application d'écrire **dans ce dépôt-là seulement**.
1. Allez sur <https://github.com/settings/personal-access-tokens/new>
(ou : photo de profil → **Settings** → **Developer settings** →
**Personal access tokens** → **Fine-grained tokens** →
**Generate new token**).
2. **Token name** : un nom neutre lui aussi, par exemple `notes-sync`.
3. **Expiration** : la durée de votre choix. À l'expiration, la synchro
s'arrête (erreur 401) et il faudra en créer un nouveau (voir plus bas).
4. **Repository access** : **Only select repositories** → choisissez
**votre dépôt** (`notes`). N'accordez pas l'accès à tous vos dépôts.
5. **Permissions** → **Repository permissions** → **Contents** :
**Read and write**. (GitHub ajoute *Metadata : Read-only* tout seul.)
Rien d'autre.
6. **Generate token**, puis **copiez-le tout de suite** (`github_pat_…`) :
GitHub ne le montre qu'une fois.
## 3. Configurer le PC
Password Manager → **Settings → Sync** :
| Champ | Valeur |
|---|---|
| Enable sync | coché |
| URL | l'adresse du dépôt : `https://github.com/moi/notes` |
| Username | laisser vide (ignoré pour GitHub) |
| Password / token | le jeton `github_pat_…` |
1. **Test connection** → *Connection OK · snapshot not created yet*.
2. **Set sync password** → choisissez le **mot de passe de synchro** (le même
sur tous vos appareils ; différent du mot de passe maître).
3. **Sync now** → un fichier `data.json` apparaît dans le dépôt, avec un
commit *update from desktop*.
## 4. Configurer le téléphone
1. Installez `pmserverApk.apk` (autorisez l'installation depuis cette source
quand Android le demande).
2. Sur le PC : **Settings → Sync → QR for phone** (le QR reste 60 secondes).
3. Sur le téléphone : **Settings → Scan the QR shown on the PC**. L'adresse
et le jeton se remplissent.
4. Tapez le **mot de passe de synchro** (il n'est **jamais** dans le QR),
puis **Save**. Le coffre se télécharge.
Sans QR, à la main : URL `https://github.com/moi/notes`, Token `github_pat_…`.
## 5. Au quotidien
- **PC** : **Sync now** après vos modifications.
- **Téléphone** : à l'ouverture, il vérifie en arrière-plan. Si le PC a
publié du nouveau, le bouton Sync s'allume ; touchez-le pour récupérer.
Une modification faite sur le téléphone est publiée aussitôt.
- Chaque synchro est un **commit** : c'est normal de voir l'historique du
dépôt grandir.
## Quand le jeton expire
La synchro échoue en **401**. Créez un nouveau jeton (étape 2), collez-le sur
le PC (**Password / token**), puis refaites **QR for phone** et le scan sur le
téléphone. Le mot de passe de synchro, lui, ne change pas.
---
## En cas de problème
| Message | Cause probable |
|---|---|
| *This repository is PUBLIC - make it private…* | Le dépôt est public. Sur GitHub : dépôt → **Settings** → **Danger Zone** → **Change visibility** → **Private**. |
| *Auth failed (401)* | Jeton faux, mal copié ou expiré. |
| *Auth failed (403)* | Le jeton n'a pas **Contents : Read and write**, ou n'a pas accès à ce dépôt. |
| *404* / *Nothing to download* | Le PC n'a pas encore fait **Sync now** — ou bien le jeton ne voit pas ce dépôt (GitHub répond 404 plutôt que de révéler un dépôt privé). Vérifiez l'étape 2, point 4. |
| *Network failure*, *HTTP 0* | Pas de réseau. L'application réessaie trois fois ; relancez la synchro. |
| Le coffre ne s'ouvre pas sur le téléphone | Le mot de passe de synchro n'est pas le même que sur le PC. |
**Limite** : GitHub ne rend plus un fichier de plus de **1 Mo** par cette
voie. Un coffre chiffré fait environ 250 Ko ; seules de très nombreuses
icônes personnalisées pourraient s'en approcher.
**GitLab n'est pas pris en charge.** Gitea (auto-hébergé) fonctionne de la même
façon : adresse du dépôt, jeton avec le droit *write:repository*.
+193
View File
@@ -0,0 +1,193 @@
Password Manager - installation
================================
Quatre fichiers dans ce dossier :
PMServer.exe l'application Windows - c'est elle qui compte
pmserverApk.apk l'application Android, FACULTATIVE (voir le point 7)
WEBDAV-LOCAL.md le guide pour synchroniser le telephone sans
depot en ligne, par ton propre PC (point 7)
GITHUB.md le guide pour synchroniser par un depot GitHub
prive, de partout (point 7)
Il n'y a pas d'installeur.
L'activation se fait en DEUX temps, parce que la licence est liee a
ta machine : tu m'envoies un code que l'application t'affiche, je te
renvoie ta cle. Compte un aller-retour avant de pouvoir t'en servir.
1. OU LE METTRE
Mets le dossier ou tu veux, SAUF dans "Program Files" :
l'application ecrit son coffre (vault.db) juste a cote de l'exe,
et Windows interdit l'ecriture dans Program Files.
Un bon endroit : C:\Users\<toi>\PasswordManager\
Choisis bien maintenant : deplacer l'exe plus tard deplace le coffre
avec lui, il faut penser a prendre le vault.db aussi.
2. PREMIER LANCEMENT - M'ENVOYER TON "INSTALLATION ID"
Double-clic sur PMServer.exe. Le premier ecran demande une cle
d'activation, que tu n'as pas encore. En BAS de cet ecran il y a un
code de seize caracteres, du genre :
Installation ID
CD6F-F1B3-25C4-0111
Envoie-moi ce code (photo de l'ecran, copier-coller, peu importe).
Il ne dit rien sur toi : c'est une empreinte de ta carte mere et de
ton disque, melangees et hachees. Il ne permet ni de t'identifier,
ni de remonter a ton materiel.
Je te renvoie ta cle. Tu la colles dans le champ, "Activate", et
c'est fini : elle n'est demandee qu'une fois.
- Un AUTRE compte Windows sur le meme PC la redemandera.
- Elle ne marchera QUE sur cette machine. Si tu changes de carte
mere ou de disque systeme, redemande-m'en une, c'est gratuit
et immediat.
3. CREER TON COFFRE
Ensuite, "Register" : tu choisis ton nom d'utilisateur et ton mot
de passe maitre.
LE MOT DE PASSE MAITRE N'EST STOCKE NULLE PART. Il n'y a aucun
moyen de le recuperer s'il est perdu - le coffre devient illisible,
y compris pour moi. Va dans Settings > Security des le premier jour
et genere un "recovery code", garde-le hors de l'ordinateur.
4. SI L'APPLICATION NE S'OUVRE PAS (fenetre blanche ou rien)
Il manque le moteur d'affichage de Microsoft, deja present sur
Windows 11 mais pas toujours sur Windows 10. Il est gratuit :
https://developer.microsoft.com/microsoft-edge/webview2/
-> "Evergreen Bootstrapper", telecharge, installe, relance l'app.
5. SAUVEGARDE
Tout le coffre tient dans vault.db, a cote de l'exe. Le copier
suffit a le sauvegarder. Settings > Backup fait une copie chiffree
automatique dans le dossier de ton choix - mets-le en route.
Si tu ranges ce dossier dans Google Drive, OneDrive ou Dropbox, la
sauvegarde part avec, et elle est chiffree : le service ne voit
qu'un fichier illisible.
6. AU QUOTIDIEN
- L'application vit dans la zone de notification (a cote de l'heure),
elle ne se ferme pas avec la croix. Clic droit sur l'icone > Quit.
- Ctrl+Shift+L remplit identifiant + mot de passe dans le navigateur :
clique d'abord le champ identifiant, puis la combinaison.
- Clic droit sur l'icone > "Fill bar" pour faire la meme chose
a la souris, sans raccourci. Settings > Fill bar (ou clic droit
sur la barrette) choisit ses boutons, icones ou texte, et sa
transparence (fond retire possible : seuls les boutons restent).
- Ctrl+Shift+Q ouvre une recherche rapide. Ouverte par ce raccourci
(ou le bouton Search de la barrette), elle remplit le champ ou tu
etais ; l'interrupteur Copy / Fill en haut permet de copier a la
place. Depuis l'icone du tray, elle copie seulement.
- Activer une 2FA sur un site : laisse son QR code a l'ecran, ouvre
l'entree, et clique le bouton QR du champ "Two-factor (TOTP)".
Le secret se remplit tout seul.
- Ouvert une entree par erreur ? Alt+fleche gauche (ou la fleche < en
haut du panneau) revient a la precedente, sans refaire la recherche.
Alt+fleche droite repart en avant.
- Un raccourci clavier gene une autre application ? Settings >
Autofill, clique le raccourci puis appuie sur Suppr : il passe a
"Off" et la combinaison redevient libre. La barrette continue de
remplir meme si les raccourcis sont coupes.
- NOTES DE COMMANDES (ssh, scripts...) : + > "Commands (note)", ou
coche "Command list" dans une note existante. Une commande par
ligne, et une ligne "# Titre" pour faire des groupes :
# Prod
ssh admin@10.0.0.11
ssh admin@10.0.0.12
Une commande trop longue peut continuer sur la ligne suivante si la
ligne finit par \ (ou ` en PowerShell, ^ en cmd).
Ctrl+Shift+Q, choisis la note, tape un bout de la commande, puis
Entree : elle est tapee dans ton terminal SANS etre lancee (c'est
toi qui valides). Ctrl+Entree la copie. Esc ou < revient aux
entrees. L'oeil de la note masque les commandes (partage d'ecran).
Rien ne sort de ton ordinateur : pas de compte en ligne, pas de
serveur. Tout est chiffre sur place.
7. LE TELEPHONE (pmserverApk.apk) - FACULTATIF
A LIRE AVANT D'INSTALLER : l'application Android ne sait pas creer
de coffre. Elle ne fait que reprendre celui du PC, et pour cela il
faut un endroit ou le PC publie une copie chiffree. Deux choix :
- UN WEBDAV LOCAL, sur ton propre PC : rien en ligne, pas de compte.
Le telephone se synchronise quand il est sur le meme Wi-Fi que le
PC, et que le PC est allume. Tout est explique pas a pas dans
WEBDAV-LOCAL.md - suis ce guide A LA PLACE des points a. et d.
ci-dessous.
- UN DEPOT PRIVE Gitea ou GitHub : la synchro marche de partout.
Un depot public est refuse : n'importe qui pourrait telecharger
ton coffre chiffre et tout son historique.
Sans l'un ou l'autre, l'application s'installe mais reste vide : ne
perds pas de temps avec elle, dis-le moi et on regarde ensemble.
Ce qui suit resume le depot Gitea / GitHub ; GITHUB.md le detaille
ecran par ecran (creer le depot, le jeton, regler PC et telephone).
Ce qu'il faut, une fois :
a. Sur le PC : Settings > Sync, renseigner l'adresse du depot et
le jeton, choisir un MOT DE PASSE DE SYNCHRO (different du mot
de passe maitre), puis "Sync now".
Adresse : celle du depot, telle que GitHub ou Gitea l'affiche,
par exemple https://github.com/<toi>/<depot>.git
Pour GitHub, un jeton "fine-grained" limite a ce depot, droit
Contents : Read and write. (GitLab n'est pas pris en charge.)
LE MOT DE PASSE DE SYNCHRO DOIT ETRE FORT (5 mots au hasard, ou
16 caracteres tires au hasard) : c'est lui seul qui protege la
copie qui part sur le depot.
b. Sur le telephone : autoriser l'installation d'applications
depuis cette source quand Android le demande, puis installer
pmserverApk.apk.
c. Sur le PC : Settings > Sync > "QR for phone". Un QR apparait
pour 60 secondes.
d. Sur le telephone : Settings > "Scan the QR shown on the PC",
viser le QR. L'adresse et le jeton se remplissent seuls.
Le QR ne contient PAS le mot de passe de synchro : tape-le a
la main, puis "Save", puis "Download vault".
Ensuite, "Unlock with" choisit comment le coffre s'ouvre sur le
telephone : le verrouillage d'ecran (empreinte, schema), un code
propre a l'application, ou le mot de passe de synchro.
- Si tu RETIRES le verrouillage d'ecran, Android detruit la cle
qui protegeait la copie locale. L'application demande alors le
mot de passe et rouvre le coffre : rien n'est perdu.
- Le telephone lit et modifie le coffre, et "Sync" fait l'aller-
retour avec le depot (ou le WebDAV). Le PC reste la reference.
- A l'ouverture, le telephone verifie en arriere-plan : si le PC a
publie du nouveau, le bouton Sync s'allume.
Pour la 2FA sur le telephone : dans une entree, le crayon, puis le
bouton QR du champ "2FA secret" ; vise le QR du site avec la camera.
Une note de commandes s'affiche sur le telephone commande par
commande, chacune avec son bouton copier.
La licence ne concerne pas le telephone : c'est l'application
Windows qui est licenciee.
+153
View File
@@ -0,0 +1,153 @@
# Synchroniser le PC et le téléphone avec un WebDAV local
Ce guide installe sur votre PC un petit serveur **WebDAV** (WsgiDAV) qui garde
une copie **chiffrée** de votre coffre. Le PC et le téléphone se synchronisent
à travers lui, sur votre Wi-Fi, sans compte en ligne.
> **Ce qui reste chiffré** : le coffre, toujours, par le *mot de passe de
> synchro* — il ne quitte jamais vos appareils.
> **Ce qui passe en clair sur le Wi-Fi** : le mot de passe WebDAV. Utilisez-en
> un qui ne sert nulle part ailleurs.
---
## 1. Installer le serveur (une fois)
1. Installez **Python** depuis <https://www.python.org/downloads/> en cochant
**« Add python.exe to PATH »**.
2. Ouvrez **PowerShell** et tapez :
```
pip install wsgidav cheroot
```
3. Créez le dossier qui recevra la copie, par exemple `C:\webdav`.
## 2. Le fichier de configuration
Créez `C:\webdav\webdav.json` avec ce contenu, en remplaçant **`moi`** et
**`MotDePasseWebDAV`** :
```json
{
"host": "0.0.0.0",
"port": 8080,
"provider_mapping": { "/": "C:\\webdav" },
"simple_dc": {
"user_mapping": {
"*": { "moi": { "password": "MotDePasseWebDAV" } }
}
},
"http_authenticator": {
"accept_basic": true,
"accept_digest": false,
"default_to_digest": false
}
}
```
- `"host": "0.0.0.0"` : le serveur répond au téléphone. Avec `127.0.0.1`,
seul le PC le verrait.
- Les `\` du chemin s'écrivent **doublés** (`C:\\webdav`).
## 3. Lancer le serveur
```
python -m wsgidav.server.server_cli -c C:\webdav\webdav.json
```
La fenêtre doit afficher `Serving on http://0.0.0.0:8080`. **Laissez-la
ouverte** : fermée, plus de synchro.
**Au démarrage de Windows (facultatif)** : touche Windows + R →
`shell:startup` → dans ce dossier, créez un fichier `webdav.cmd` contenant :
```
python -m wsgidav.server.server_cli -c C:\webdav\webdav.json
```
## 4. Ouvrir le port au téléphone
Dans **PowerShell lancé en administrateur** :
```
New-NetFirewallRule -DisplayName "WebDAV 8080" -Direction Inbound -Protocol TCP -LocalPort 8080 -RemoteAddress LocalSubnet -Action Allow
```
`LocalSubnet` : seuls les appareils de votre réseau local y ont accès.
Si vous avez un autre pare-feu (Portmaster, Norton, Kaspersky…), autorisez
aussi **Python** en connexions **entrantes**. Sans cela le port reste fermé
malgré la règle Windows.
## 5. L'adresse du PC
Dans PowerShell : `ipconfig` → ligne **Adresse IPv4** de votre carte réseau,
par exemple `192.168.1.28`.
Cette adresse doit **rester la même**. Fixez-la dans votre box (« bail DHCP
fixe » / « réservation d'adresse ») ou dans les réglages réseau de Windows.
L'adresse de la copie sera alors :
```
http://192.168.1.28:8080/data.json
```
(avec **votre** adresse à la place de `192.168.1.28`)
## 6. Configurer le PC
Password Manager → **Settings → Sync** :
| Champ | Valeur |
|---|---|
| Enable sync | coché |
| URL | `http://192.168.1.28:8080/data.json` |
| Username | `moi` |
| Password / token | `MotDePasseWebDAV` |
1. **Test connection** → *Connection OK · snapshot not created yet*.
2. **Set sync password** → choisissez le **mot de passe de synchro** (le même
sur tous vos appareils ; différent du mot de passe maître et du mot de passe
WebDAV).
3. **Sync now** → la copie `data.json` apparaît dans `C:\webdav`.
## 7. Configurer le téléphone
Application **Vault** → réglages de synchro :
- **Le plus simple** : sur le PC, **QR for phone**, puis sur le téléphone
**Scan the QR shown on the PC**. L'URL, l'utilisateur et le mot de passe
WebDAV sont remplis. Le QR ne contient **pas** le mot de passe de synchro.
- **À la main** :
- URL : `http://192.168.1.28:8080/data.json`
- Token : `moi:MotDePasseWebDAV` (utilisateur, **deux-points**, mot de passe)
Tapez ensuite le **mot de passe de synchro**, puis **Save**. Le coffre se
télécharge.
## 8. Au quotidien
- **PC** : **Sync now** après vos modifications.
- **Téléphone** : à l'ouverture, il vérifie en arrière-plan. Si le PC a
publié du nouveau, le bouton Sync s'allume ; touchez-le pour récupérer.
Une modification faite sur le téléphone est publiée aussitôt.
- Le téléphone doit être **sur le même Wi-Fi** que le PC, et le serveur
**lancé**.
---
## En cas de problème
| Message | Cause probable |
|---|---|
| *Nothing to download (404)* | Le PC n'a pas encore fait **Sync now**, ou l'URL ne finit pas par le bon nom de fichier. |
| *401* | Utilisateur ou mot de passe WebDAV faux (sur le téléphone : `utilisateur:motdepasse`). |
| *Network failure*, *HTTP 0* | Serveur arrêté, PC éteint, téléphone sur un autre réseau, ou pare-feu (étape 4). |
| *http:// is only allowed on the local network* | L'adresse n'est pas une adresse locale. Utilisez l'adresse IPv4 du PC (`192.168.x.x`, `10.x.x.x`). |
| Le coffre ne s'ouvre pas sur le téléphone | Le mot de passe de synchro n'est pas le même que sur le PC. |
**Vérifier depuis le PC** que le serveur répond sur le réseau, dans un
navigateur : `http://192.168.1.28:8080/` → il demande l'utilisateur et le
mot de passe WebDAV, puis liste le dossier.
+204 -32
View File
@@ -56,12 +56,20 @@ type
Error: string; // message brut, pour le journal
end;
/// Vrai pour une adresse du reseau local (RFC 1918, boucle, .local, .lan,
/// nom sans point) : le seul endroit ou le HTTP en clair est accepte.
function IsLocalHost(const AHost: string): Boolean;
type
TSyncClient = class
private
FBaseUrl: string;
FToken: string;
FTimeoutMs: Integer;
/// Attente de la POIGNEE DE MAIN, distincte de celle de la reponse.
FConnectMs: Integer;
FPrivacyChecked: Boolean;
function PublicRepoWarning: string;
public
constructor Create(const ABaseUrl, AToken: string);
@@ -74,11 +82,18 @@ type
/// n'y ressemble pas est de toute facon inutilisable.
function IsGitea: Boolean;
/// WEBDAV : toute autre URL dont le "jeton" s'ecrit utilisateur:mot-de-passe.
/// Le fichier se lit et s'ecrit tel quel (le meme que publie le poste), et
/// l'ETag HTTP joue le role du sha : If-None-Match a la lecture, If-Match a
/// l'ecriture (412 = un autre appareil a publie entre-temps).
function IsWebDav: Boolean;
/// Refus LOCAL, avant tout envoi : http:// hors reseau local, puis depot public.
/// UN DEPOT PUBLIC EST REFUSE (Gitea comme GitHub) : chaque synchro est un
/// commit, et l'historique garde tous les instantanes, attaquables hors
/// ligne par quiconque peut les lire. Message a afficher, ou '' si le depot
/// est prive ou si on n'a pas pu le savoir. Verifie une fois par instance.
function PublicRepoWarning: string;
function RefusalReason: string;
/// POURQUOI UN 404 OU UN 401, en clair pour l'utilisateur. Gitea repond 404
/// aussi bien pour un fichier absent que pour un depot ou un proprietaire
@@ -105,7 +120,7 @@ type
implementation
uses
System.JSON, System.NetEncoding;
System.JSON, System.NetEncoding, System.StrUtils;
function TSyncClient.IsGitea: Boolean;
begin
@@ -114,6 +129,81 @@ begin
FBaseUrl.ToLower.Contains('api.github.com/repos/');
end;
function TSyncClient.IsWebDav: Boolean;
begin
Result := (not IsGitea) and FToken.Contains(':');
end;
/// Basic sur WebDAV, Bearer pour l'endpoint PHP historique.
function PlainAuthHeader(const AToken: string; AWebDav: Boolean): string;
begin
if AWebDav then
Result := 'Basic ' + TNetEncoding.Base64.Encode(AToken).Replace(#13, '').Replace(#10, '')
else
Result := 'Bearer ' + AToken;
end;
function PutWebDav(const AUrl, AToken, ABody, AETag: string;
AConnectMs, ATimeoutMs: Integer): TSyncResult;
var
LHttp: THTTPClient;
LResp: IHTTPResponse;
LReq, LOut: TStringStream;
begin
Result := Default(TSyncResult);
Result.Status := ssNetworkError;
LHttp := THTTPClient.Create;
LReq := TStringStream.Create(ABody, TEncoding.UTF8);
LOut := TStringStream.Create('', TEncoding.UTF8);
try
LHttp.ConnectionTimeout := AConnectMs;
LHttp.ResponseTimeout := ATimeoutMs;
LHttp.HandleRedirects := False;
LHttp.UserAgent := 'PMServer';
LHttp.CustomHeaders['Connection'] := 'close';
LHttp.CustomHeaders['Authorization'] := PlainAuthHeader(AToken, True);
LHttp.CustomHeaders['Content-Type'] := 'application/json';
// Sans ETag connu (premiere publication, serveur qui n'en rend pas) : pas
// de condition, le dernier ecrit gagne - comme le poste dans ce cas.
if AETag <> '' then
LHttp.CustomHeaders['If-Match'] := AETag;
LResp := nil;
for var LTry := 1 to 3 do
begin
try
LReq.Position := 0;
LOut.Clear;
LResp := LHttp.Put(AUrl, LReq, LOut);
Break;
except
on E: Exception do
begin
Result.Error := E.ClassName + ': ' + E.Message;
if LTry = 3 then Exit;
end;
end;
end;
if LResp = nil then Exit;
Result.HttpCode := LResp.StatusCode;
case LResp.StatusCode of
200, 201, 204:
begin
Result.Status := ssOK;
Result.ETag := LResp.HeaderValue['ETag'];
end;
412: Result.Status := ssConflict;
else
Result.Status := ssHttpError;
Result.Error := Format('HTTP %d %s - %s', [LResp.StatusCode,
LResp.StatusText, Copy(LOut.DataString, 1, 300)]);
end;
finally
LOut.Free;
LReq.Free;
LHttp.Free;
end;
end;
/// URL de l'API du depot (sans /contents/...), identique pour Gitea
/// (https://hote/api/v1/repos/O/R) et GitHub (https://api.github.com/repos/O/R).
function RepoApiUrl(const AContentsUrl: string): string;
@@ -124,6 +214,49 @@ begin
if LP > 0 then Result := Copy(AContentsUrl, 1, LP - 1) else Result := '';
end;
/// HTTP EN CLAIR : RESEAU LOCAL SEULEMENT. Android le permet partout
/// (network_security_config) pour que chacun pointe vers SON serveur WebDAV,
/// a une adresse qu'on ne connait pas a la compilation ; le tri se fait donc
/// ici. Vers Internet, le jeton ne part qu'en HTTPS.
/// ponytail: un Wi-Fi etranger qui reutilise la meme plage recevrait le jeton
/// en clair - le coffre, lui, reste chiffre. HTTPS chez soi supprime le risque.
function IsLocalHost(const AHost: string): Boolean;
var
LParts: TArray<string>;
A, B: Integer;
begin
var LHost := AHost.ToLower;
if (LHost = 'localhost') or LHost.EndsWith('.local') or LHost.EndsWith('.lan') or
(LHost.IndexOf('.') < 0) and (LHost <> '') then
Exit(True);
LParts := LHost.Split(['.']);
if (Length(LParts) <> 4) or not TryStrToInt(LParts[0], A) or
not TryStrToInt(LParts[1], B) then
Exit(False);
for var LPart in LParts do
if not TryStrToInt(LPart, A) then Exit(False);
A := StrToInt(LParts[0]);
Result := (A = 10) or (A = 127) or ((A = 192) and (B = 168)) or
((A = 172) and (B >= 16) and (B <= 31));
end;
function TSyncClient.RefusalReason: string;
var
LHost: string;
begin
if StartsText('http://', FBaseUrl) then
begin
try
LHost := TURI.Create(FBaseUrl).Host;
except
LHost := '';
end;
if not IsLocalHost(LHost) then
Exit('http:// is only allowed on the local network (192.168.x.x, 10.x.x.x, .local, .lan) - use https:// for ' + LHost);
end;
Result := PublicRepoWarning;
end;
function TSyncClient.PublicRepoWarning: string;
var
LHttp: THTTPClient;
@@ -136,7 +269,7 @@ begin
FPrivacyChecked := True;
LHttp := THTTPClient.Create;
try
LHttp.ConnectionTimeout := FTimeoutMs;
LHttp.ConnectionTimeout := FConnectMs;
LHttp.ResponseTimeout := FTimeoutMs;
LHttp.HandleRedirects := False;
LHttp.UserAgent := 'PMServer';
@@ -193,7 +326,7 @@ begin
LRepo := LParts[1];
LHttp := THTTPClient.Create;
try
LHttp.ConnectionTimeout := FTimeoutMs;
LHttp.ConnectionTimeout := FConnectMs;
LHttp.ResponseTimeout := FTimeoutMs;
LHttp.HandleRedirects := False;
LHttp.UserAgent := 'PMServer';
@@ -293,18 +426,20 @@ begin
Result := Default(TSyncResult);
Result.Status := ssNetworkError;
if not IsGitea then
begin
Result.Status := ssHttpError;
Result.Error := 'Ecriture disponible seulement vers un depot Gitea ou GitHub';
Exit;
end;
if (FBaseUrl = '') or (FToken = '') then
begin
Result.Error := 'URL ou jeton non configure';
Exit;
end;
Result.Error := PublicRepoWarning;
if IsWebDav then
Exit(PutWebDav(FBaseUrl, FToken, AContainerJson, ASha, FConnectMs, FTimeoutMs));
if not IsGitea then
begin
Result.Status := ssHttpError;
Result.Error := 'Ecriture disponible seulement vers Gitea, GitHub ou WebDAV (jeton utilisateur:mot-de-passe)';
Exit;
end;
Result.Error := RefusalReason;
if Result.Error <> '' then
begin
Result.Status := ssRefused; // HttpCode 0 : refus local, pas du serveur
@@ -328,22 +463,33 @@ begin
try
LHttp := THTTPClient.Create;
try
LHttp.ConnectionTimeout := FTimeoutMs;
LHttp.ConnectionTimeout := FConnectMs;
LHttp.ResponseTimeout := FTimeoutMs;
LHttp.HandleRedirects := False;
LHttp.UserAgent := 'PMServer'; // GitHub refuse toute requete sans
LHttp.CustomHeaders['Connection'] := 'close'; // voir Fetch
LHttp.CustomHeaders['Authorization'] := 'token ' + FToken;
LHttp.CustomHeaders['Content-Type'] := 'application/json';
try
LResp := LHttp.Put(FBaseUrl, LReq, LResync);
except
on E: Exception do
begin
Result.Status := ssNetworkError;
Result.Error := E.ClassName + ': ' + E.Message;
Exit;
// Deux essais, meme raison qu'a la lecture : un SYN perdu ne dit rien
// du serveur. Le corps est renvoye depuis le debut a chaque essai.
LResp := nil;
for var LTry := 1 to 3 do
begin
try
LReq.Position := 0;
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;
if LResp = nil then Exit;
Result.HttpCode := LResp.StatusCode;
case LResp.StatusCode of
200, 201:
@@ -395,7 +541,21 @@ begin
inherited Create;
FBaseUrl := ABaseUrl.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;
function TSyncClient.Fetch(const AKnownETag: string): TSyncResult;
@@ -412,7 +572,7 @@ begin
Result.Error := 'URL ou jeton non configure';
Exit;
end;
Result.Error := PublicRepoWarning;
Result.Error := RefusalReason;
if Result.Error <> '' then
begin
Result.Status := ssRefused; // HttpCode 0 : refus local, pas du serveur
@@ -421,9 +581,14 @@ begin
LHttp := THTTPClient.Create;
try
LHttp.ConnectionTimeout := FTimeoutMs;
LHttp.ConnectionTimeout := FConnectMs;
LHttp.ResponseTimeout := FTimeoutMs;
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
// ferait envoyer le jeton ailleurs.
LHttp.HandleRedirects := False;
@@ -434,7 +599,7 @@ begin
LHttp.CustomHeaders['Authorization'] := 'token ' + FToken
else
begin
LHttp.CustomHeaders['Authorization'] := 'Bearer ' + FToken;
LHttp.CustomHeaders['Authorization'] := PlainAuthHeader(FToken, IsWebDav);
// Pas sur Gitea : le sha n'est pas un ETag HTTP, et l'envoyer ferait
// repondre 200 avec un corps complet de toute facon.
if AKnownETag <> '' then
@@ -443,16 +608,23 @@ begin
LStream := TStringStream.Create('', TEncoding.UTF8);
try
try
LResp := LHttp.Get(FBaseUrl, LStream);
except
on E: Exception do
begin
Result.Status := ssNetworkError;
Result.Error := E.ClassName + ': ' + E.Message;
Exit;
LResp := nil;
for var LTry := 1 to 3 do
begin
try
LResp := LHttp.Get(FBaseUrl, LStream);
Break;
except
on E: Exception do
begin
Result.Status := ssNetworkError;
Result.Error := E.ClassName + ': ' + E.Message;
if LTry = 3 then Exit;
LStream.Clear;
end;
end;
end;
if LResp = nil then Exit;
Result.HttpCode := LResp.StatusCode;
Result.ETag := LResp.HeaderValue['ETag'];
+74
View File
@@ -37,6 +37,19 @@ function VaultToJson(AVault: TVault): string;
/// Les lignes d'une entree, dans l'ordre d'affichage.
function DetailToJson(const AEntry: TVaultEntry): string;
type
TCommandItem = record
Group, Cmd: string;
end;
/// NOTE DE COMMANDES (modele "commands"), meme regle que le poste
/// (js/app.js parseCommandNote) : "# titre" ouvre un groupe, chaque autre ligne
/// non vide est une commande ; une ligne finie par \, ` ou ^ se recolle a la
/// suivante, sur UNE ligne. Une ligne vide ou la fin du texte termine la
/// commande en cours.
function ParseCommandNote(const AText: string): TArray<TCommandItem>;
function IsCommandNote(const AEntry: TVaultEntry): Boolean;
/// Une ligne du detail par son rang, dans le MEME ordre que DetailToJson : la
/// page renvoie un rang, jamais une valeur, et Delphi relit le clair chez lui.
/// Faux si le rang ne designe rien.
@@ -245,6 +258,49 @@ begin
Result := LRows;
end;
function IsCommandNote(const AEntry: TVaultEntry): Boolean;
begin
Result := AEntry.IsNote and SameText(AEntry.Template, 'commands');
end;
function ParseCommandNote(const AText: string): TArray<TCommandItem>;
var
LGroup, LPending, LLine, LPart: string;
LCont: Boolean;
procedure Flush;
begin
if LPending <> '' then
begin
SetLength(Result, Length(Result) + 1);
Result[High(Result)].Group := LGroup;
Result[High(Result)].Cmd := LPending;
end;
LPending := '';
end;
begin
Result := nil;
LGroup := '';
LPending := '';
for LLine in AText.Replace(#13, '').Split([#10]) do
begin
var LTrim := LLine.Trim;
if LTrim = '' then begin Flush; Continue; end;
if (LPending = '') and LTrim.StartsWith('#') then
begin
LGroup := LTrim.TrimLeft(['#']).Trim;
Continue;
end;
LCont := CharInSet(LTrim[High(LTrim)], ['\', '`', '^']);
if LCont then LPart := Copy(LTrim, 1, Length(LTrim) - 1).TrimRight
else LPart := LTrim;
if LPending = '' then LPending := LPart else LPending := LPending + ' ' + LPart;
if not LCont then Flush;
end;
Flush;
end;
function DetailToJson(const AEntry: TVaultEntry): string;
var
LRoot: TJSONObject;
@@ -257,6 +313,9 @@ begin
LRoot := TJSONObject.Create;
try
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 <> ''));
LArr := TJSONArray.Create;
LRoot.AddPair('rows', LArr);
@@ -275,6 +334,21 @@ begin
if LRows[I].Multiline then LObj.AddPair('m', TJSONBool.Create(True));
LArr.AddElement(LObj);
end;
// La liste des commandes, pour l'affichage. La copie passe par un rang
// (copy?f=cmd&k=), comme les lignes : Delphi relit le clair chez lui.
if IsCommandNote(AEntry) then
begin
var LCmds := ParseCommandNote(AEntry.Password);
var LCArr := TJSONArray.Create;
LRoot.AddPair('cmds', LCArr);
for I := 0 to High(LCmds) do
begin
LObj := TJSONObject.Create;
LObj.AddPair('g', LCmds[I].Group);
LObj.AddPair('c', LCmds[I].Cmd);
LCArr.AddElement(LObj);
end;
end;
Result := LRoot.ToJSON;
finally
LRoot.Free;
+122
View File
@@ -341,6 +341,128 @@ reste.
(code, empreinte) **dans le service** avant de proposer quoi que ce soit : le
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.
## WebDAV — 2026-10-02
Troisième transport, à côté de Gitea et GitHub, éprouvé contre le **WsgiDAV
local du poste** (`E:\webdav.json`, `0.0.0.0:8080`) : lecture, 304 à
l'ouverture, écriture 204, puis synchro du poste qui reprend la modification.
- **Reconnu au jeton, pas à l'URL** : une URL ni Gitea ni GitHub dont le jeton
s'écrit `utilisateur:mot-de-passe` → `IsWebDav`, authentification Basic.
Pas de champ « utilisateur » de plus dans l'écran : le jeton en tient lieu
(sans `:`, c'est toujours l'ancien endpoint PHP, en Bearer).
- **Le fichier tel quel**, le même que publie le poste : GET / PUT sans
enveloppe base64. **L'ETag HTTP fait le sha** : `If-None-Match` à la lecture
(304 = rien à faire), `If-Match` à l'écriture, **412 → `ssConflict`**, donc la
même reprise relire-fusionner-republier que le 409/422 de Gitea. Sans ETag
connu, pas de condition : le dernier écrit gagne, comme le poste.
- **Une URL qui finit en `.json` n'est plus réécrite** par `normalizeRepoUrl`
(elle en faisait une URL d'API Gitea) — sauf les formes longues d'un dépôt,
`/src/branch/…` et `/blob/…`.
- **`http://` : réseau local seulement**, pour que chacun (les amis compris)
pointe vers son propre serveur sans adresse connue à la compilation. Android
ne sait autoriser ni plage ni masque : `network_security_config.xml` permet
donc le clair partout, et `TSyncClient.RefusalReason` refuse **avant tout
envoi** un `http://` dont l'hôte n'est pas local (`IsLocalHost` : RFC 1918,
`127.x`, `.local`, `.lan`, nom sans point). Vers Internet, HTTPS obligatoire,
et le refus s'affiche dans les réglages. Risque assumé : un Wi-Fi étranger
qui réutilise la même plage recevrait le mot de passe WebDAV en clair — le
coffre, lui, reste chiffré.
- **Côté poste** : pare-feu Windows (règle `WebDAV 8080`, `LocalSubnet`) **et**
Portmaster (entrant autorisé pour Python) — sans le second, le port restait
fermé malgré la règle.
## Notes de commandes — 2026-10-10
Une note marquée **Commands** sur le poste (modèle `commands`) affiche en tête
un bloc *Commands* : une commande par ligne sous le titre de son groupe, avec
un bouton copier. Le texte entier reste dessous, dans *Content*.
- **Même découpage que le poste** : `ParseCommandNote` (PMA.WebUI) est le
portage de `parseCommandNote` (js/app.js) — `# titre` ouvre un groupe, une
ligne finie par `\`, `` ` `` ou `^` se recolle à la suivante. Les deux
doivent dire la même chose ; éprouvé sur les mêmes cas.
- **La copie passe par un rang** (`copy?f=cmd&k=`), comme les autres lignes :
Delphi relit la note et copie la commande.
- **Ni remplissage ni masquage** ici : pas de fenêtre cible, et le corps des
notes est déjà affiché en clair. Une note devient « Commands » depuis le
poste (case *Command list*) ; la synchro porte le modèle.
## Saisie : ce qui a résisté
- **`user-select: none` du `body`** était hérité par les champs : le curseur ne
@@ -1,26 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Le trafic en clair n'est permis que vers la BOUCLE LOCALE, et interdit
partout ailleurs.
Le clair est PERMIS ICI, et trie dans le code (PMA.SyncClient.IsLocalHost).
Il l'etait auparavant partout, via usesCleartextTraffic="true". C'etait pour
une seule raison : l'essai local passe par "adb reverse", qui fait voir le
poste comme 127.0.0.1 et parle HTTP. Mais l'attribut ne distingue pas la
boucle locale du reste du monde - une URL mal saisie, ou un serveur repassant
en HTTP, envoyait le jeton en clair sur le reseau sans que rien ne le signale.
Ici la permission est nominative. Le jeton ne peut plus sortir qu'en TLS, et
une URL "http://" vers un vrai hote echoue tout de suite, ce qui est
exactement le comportement voulu : bruyant, pas silencieux.
cleartextTrafficPermitted="false" a la racine n'est pas redondant avec le
defaut d'Android 9+ : il vaut aussi pour les versions anterieures, et il dit
l'intention noir sur blanc.
Chacun pointe vers SON serveur WebDAV, souvent en http:// sur son reseau
local, a une adresse qu'on ne connait pas a la compilation - et Android ne
sait autoriser ni une plage ni un masque, seulement des noms un par un.
Avant tout envoi, la synchro refuse donc tout http:// qui ne vise pas le
reseau local (RFC 1918, boucle, .local, .lan, nom sans point) : vers
Internet, le jeton ne part qu'en HTTPS, et le refus s'affiche au lieu de
laisser passer en silence.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">127.0.0.1</domain>
<domain includeSubdomains="false">localhost</domain>
</domain-config>
<base-config cleartextTrafficPermitted="true" />
</network-security-config>
+316 -125
View File
@@ -98,7 +98,10 @@
.search { flex: 1; position: relative; }
.search input {
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);
font-size: 15px; outline: none;
}
@@ -153,8 +156,9 @@
text-overflow: ellipsis; white-space: nowrap; }
.txt span { display: block; color: var(--muted); font-size: 13px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Les boutons prennent le meme arrondi que la recherche et les pastilles. */
.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);
padding: 11px 13px; font-size: 13px; font-family: inherit;
}
@@ -229,6 +233,7 @@
/* --- detail ------------------------------------------------------------ */
.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;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.field {
@@ -274,6 +279,11 @@
.field .val.multi { white-space: pre-wrap; word-break: normal;
overflow-wrap: anywhere; }
.field.totp .val { font-size: 30px; font-weight: 300; letter-spacing: 2px; }
/* Note de commandes : une commande par ligne, sous le titre de son groupe. */
.field.cmds .cmd-group { font-size: 12px; font-weight: 600; opacity: .7;
text-transform: uppercase; margin: 10px 0 2px; }
.field.cmds .cmd-line { padding: 4px 0; }
.field.cmds .val.cmd { font-family: monospace; font-size: 14px; }
.bar { height: 3px; border-radius: 2px; background: rgba(255,255,255,.18);
margin-top: 10px; overflow: hidden; }
.bar i { display: block; height: 100%; background: var(--text);
@@ -309,6 +319,32 @@
jamais - la fin d'une URL longue restait hors d'atteinte. */
-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;
line-height: 1.35; word-break: break-all; display: block; }
.field-in::placeholder { color: var(--muted); }
@@ -497,7 +533,7 @@
champ. Une adresse d'API Gitea en fait un jeton d'acces personnel
envoye en "token" ; l'endpoint PHP en fait un "Bearer". Le
nommer d'apres un seul des deux le rend faux pour l'autre. -->
<input class="field-in" id="tok" placeholder="access token"
<input class="field-in" id="tok" placeholder="access token (WebDAV: user:password)"
type="password" autocomplete="off">
<div class="in-x" data-clear="tok">&#215;</div>
</div>
@@ -558,7 +594,9 @@
<div class="screen" id="detail">
<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>
<div class="av" id="dav" hidden></div>
<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>
</div>
<div class="scroll"><div id="fields"></div></div>
@@ -582,13 +620,13 @@
entier. Un input d'une ligne ne defilait pas sous le doigt dans la
WebView de l'app, et la fin d'une URL d'API restait inatteignable. -->
<textarea class="field-in multi" id="sUrl" rows="1"
placeholder="repository URL - Gitea or github.com/owner/repo (private)"
placeholder="repository URL - Gitea, github.com/owner/repo (private), or WebDAV file URL"
autocomplete="off" autocorrect="off" autocapitalize="none"
spellcheck="false" inputmode="url"></textarea>
<div class="in-x" data-clear="sUrl">&#215;</div>
</div>
<div class="in-wrap">
<input class="field-in" id="sTok" placeholder="access token"
<input class="field-in" id="sTok" placeholder="access token (WebDAV: user:password)"
type="password" autocomplete="off">
<div class="in-x" data-clear="sTok">&#215;</div>
</div>
@@ -1010,6 +1048,8 @@ var ICONS = {
+ '<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"/>',
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.
loader: '<path d="M12 3a9 9 0 019 9"/>',
eyeOff: '<path d="M4 4l16 16"/>'
@@ -1040,6 +1080,13 @@ function renderDetail(d) {
lastDetail = d;
detailRows = d.rows || [];
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;
if (d.totp)
html += '<div class="field totp" id="totp"><div class="cap">2FA code</div>'
@@ -1047,6 +1094,23 @@ function renderDetail(d) {
+ '<button class="btn ico-btn" data-copy="totp">'
+ ico('copy', 'Copy code') + '</button></div>'
+ '<div class="bar"><i id="totpb"></i></div></div>';
// NOTE DE COMMANDES : une ligne par commande, groupees sous leur titre, avec
// un bouton copier. Le texte entier reste plus bas, dans "Content".
if (d.cmds && d.cmds.length) {
var lastG = null;
html += '<div class="field cmds"><div class="cap">Commands</div>';
for (i = 0; i < d.cmds.length; i++) {
var cm = d.cmds[i];
if (cm.g !== lastG) {
lastG = cm.g;
if (cm.g) html += '<div class="cmd-group">' + esc(cm.g) + '</div>';
}
html += '<div class="line cmd-line"><div class="val cmd">' + esc(cm.c) + '</div>'
+ '<button class="btn ico-btn" data-copy="cmd" data-k="' + i + '">'
+ ico('copy', 'Copy command') + '</button></div>';
}
html += '</div>';
}
for (i = 0; i < detailRows.length; i++) {
var r = detailRows[i];
html += '<div class="field"><div class="cap">' + esc(r.c) + '</div>'
@@ -1064,8 +1128,6 @@ function renderDetail(d) {
+ (r.a === undefined ? ''
: '<button class="btn ico-btn" data-save="' + i + '">'
+ ico('down', 'Save to Downloads') + '</button>')
+ (r.e ? '<button class="btn ico-btn" data-edit="' + i + '">'
+ ico('edit', 'Edit') + '</button>' : '')
+ '</div></div>';
}
document.getElementById('fields').innerHTML = html;
@@ -1073,9 +1135,13 @@ function renderDetail(d) {
document.body.classList.add('on-detail');
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) {
// 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.
var t = ev.target.closest('button') || ev.target;
@@ -1099,49 +1165,35 @@ document.getElementById('fields').addEventListener('click', function (ev) {
send('touch');
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');
if (save !== null) { send('savefile?k=' + save); return; }
var copy = t.getAttribute('data-copy');
if (copy === 'totp') { send('copy?f=totp'); return; }
if (copy === 'row') { send('copy?f=row&k=' + t.getAttribute('data-k')); return; }
if (copy === 'cmd') { send('copy?f=cmd&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.
@@ -1153,40 +1205,22 @@ document.getElementById('fields').addEventListener('click', function (ev) {
///
/// 'All' est ajoute d'office : c'est le dossier par defaut du poste, et il ne
/// figure pas toujours dans la liste.
function editorControl(k) {
var val = detailRows[k].v;
var style = ' style="margin:0;flex:1;min-width:0;width:auto"';
// UNE ZONE DE TEXTE POUR CE QUI TIENT SUR PLUSIEURS LIGNES. Un <input>
// applique la sanitisation de HTML et retire les retours a la ligne en
// 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;
/// La liste des dossiers du coffre, pour le champ 'folder' du formulaire.
/// Le dossier courant y est ajoute s'il n'y figure plus - renomme ailleurs, ou
/// venu d'un import -, sinon l'ouvrir suffirait a le remplacer en silence.
function folderSelect(val) {
var names = ['All'], i, html;
for (i = 0; i < folders.length; 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);
var html = '<select class="field-in" id="editval"' + style + '>';
html = '<select class="field-in" data-ef="folder" style="' + FIELD_STYLE + '">';
for (i = 0; i < names.length; i++)
html += '<option value="' + esc(names[i]) + '"'
+ (names[i] === val ? ' selected' : '') + '>' + esc(names[i])
+ '</option>';
// 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
// champ libre, sans quitter l'editeur.
// champ libre, sans quitter le formulaire.
html += '<option value="' + NEW_FOLDER + '">+ new folder</option>';
return html + '</select>';
}
@@ -1196,11 +1230,12 @@ var NEW_FOLDER = '\u0001new';
// Le choix "+ new folder" remplace la liste par un champ. Delegue sur #fields
// parce que le controle nait et meurt avec l'editeur.
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 input = document.createElement('input');
input.className = 'field-in';
input.id = 'editval';
input.setAttribute('data-ef', 'folder');
input.setAttribute('placeholder', 'folder name');
input.setAttribute('autocomplete', 'off');
input.setAttribute('autocapitalize', 'none');
@@ -1225,44 +1260,6 @@ document.getElementById('fields').addEventListener('change', function (ev) {
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.
//
// overflow:hidden n'interdit que le GESTE. Le moteur, lui, garde le droit de
@@ -1347,8 +1344,9 @@ addEventListener('keydown', function (ev) {
if (el.tagName === 'TEXTAREA') return;
ev.preventDefault();
el.blur();
if (el.id !== 'editval') return;
var save = document.querySelector('[data-editsave]');
// Entree sur un champ du formulaire = enregistrer, comme la coche d'avant.
if (!el.getAttribute('data-ef')) return;
var save = document.getElementById('eSave');
if (save && !save.disabled) save.click();
});
@@ -1359,6 +1357,17 @@ addEventListener('keydown', function (ev) {
// 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.
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
/// meme raison : une entree rangee des le depart evite d'avoir a la deplacer.
@@ -1407,6 +1416,29 @@ function genPassword(n) {
}
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) {
return '<button class="btn ico-btn" data-gen="' + target + '">'
+ ico('dice', 'Generate') + '</button>';
@@ -1417,17 +1449,26 @@ function newBodyControl(kind) {
return '<textarea class="field-in" id="nPwd" rows="6" enterkeyhint="enter"'
+ ' autocorrect="off" spellcheck="false" style="' + FIELD_STYLE
+ ';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"'
+ ' style="' + FIELD_STYLE + '">' + genButton('nPwd');
+ ' style="' + FIELD_STYLE + '">' + eyeButton('nPwd') + genButton('nPwd');
}
function openNewEntry() {
newKind = 'login';
detailRows = [];
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 =
'<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" data-kind="note">Note</button></div></div>'
+ newFolderField()
@@ -1442,12 +1483,17 @@ function openNewEntry() {
// deja : on pouvait poser un second facteur sur une entree existante, pas
// sur une entree qu'on venait de creer - il fallait enregistrer puis
// rouvrir. Meme intitule que l'editeur pour qu'on reconnaisse le champ.
+ newField('nTotp', '2FA secret', 'nTotpRow')
+ '<div class="field"><div class="line">'
+ '<div class="field" id="nTotpRow"><div class="cap">2FA secret</div>'
+ '<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
// pleine vient d'etre etire dans son conteneur.
+ '<button class="btn main wide" id="nSave" style="flex:1">Save</button>'
+ '</div></div>';
+ '</div>';
document.body.classList.remove('on-list');
document.body.classList.add('on-detail');
view = 'detail';
@@ -1531,13 +1577,127 @@ function closeMenu() {
function openMenu() {
var m = document.getElementById('menu');
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 class="danger" data-del>Delete entry</button>';
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').addEventListener('click', function (ev) {
ev.stopPropagation();
@@ -1749,26 +1909,33 @@ document.addEventListener('input', function (ev) {
});
// 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) {
var g = /^https:\/\/api\.github\.com\/repos\/([^\/]+)\/([^\/]+)\/contents\/(.+)$/i.exec(u || '');
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 || '');
if (!m) return u;
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) {
u = u.replace(/\s+/g, '');
if (!u || /\/api\/v1\/repos\//i.test(u) || /api\.github\.com\/repos\//i.test(u) ||
/\.php(\?|$)/i.test(u)) return u;
// WEBDAV : l'URL du fichier lui-meme, a garder telle quelle. Seules les
// formes longues d'un depot (/src/branch/, /blob/) finissent aussi en .json.
if (/\.json$/i.test(u) && !/\/(src\/branch|blob)\//i.test(u)) return u;
// 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);
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);
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.
['sBack', 'sKbd'].forEach(function (id) {
@@ -1893,12 +2060,19 @@ function goList() {
document.body.classList.remove('on-detail');
document.body.classList.add('on-list');
view = 'list';
editing = false;
// Les valeurs en clair partent avec la vue, pas seulement de l'ecran.
detailRows = [];
document.getElementById('fields').innerHTML = '';
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 -----------------------------------------------------
var PM = {
@@ -2142,7 +2316,24 @@ var PM = {
ico('loader', 'Working') + '<span>Please wait...</span>';
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.
/// Sans cela, un geste d'annulation fermait le coffre - le contraire de ce
/// qu'il veut dire.
+51 -17
View File
@@ -17,10 +17,14 @@ body {
overflow: hidden;
}
/* Les polices a chasse fixe n'ont pas l'arabe : Windows empruntait une police
au hasard, plus petite. 'Segoe UI' / Tahoma, places avant 'monospace' dans
chaque pile, prennent ces caracteres a une taille comparable ; le latin reste
dans la police a chasse fixe. */
button { font-family: inherit; cursor: pointer; }
input, select, textarea { font-family: inherit; font-size: inherit; }
kbd {
font-family: 'JetBrains Mono', 'SF Mono', Consolas, monospace;
font-family: 'JetBrains Mono', 'SF Mono', Consolas, 'Segoe UI', Tahoma, monospace;
font-size: 11px;
padding: 2px 6px;
border: 1px solid var(--border);
@@ -248,7 +252,7 @@ input[type="range"]::-webkit-slider-thumb {
.licence-machine code {
display: inline-block; padding: 6px 10px;
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm);
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-family: ui-monospace, "Cascadia Mono", Consolas, 'Segoe UI', Tahoma, monospace;
font-size: 14px; letter-spacing: 1px; color: var(--text);
user-select: all;
}
@@ -1216,7 +1220,7 @@ input[type="range"]::-webkit-slider-thumb {
}
.entry-pw {
flex: 1;
font-family: 'JetBrains Mono', 'SF Mono', Consolas, monospace;
font-family: 'JetBrains Mono', 'SF Mono', Consolas, 'Segoe UI', Tahoma, monospace;
font-size: 12px;
color: var(--text-dim);
overflow: hidden;
@@ -1268,7 +1272,7 @@ input[type="range"]::-webkit-slider-thumb {
}
.totp-code {
flex: 1;
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-family: 'JetBrains Mono', ui-monospace, 'Segoe UI', Tahoma, monospace;
font-size: 22px;
letter-spacing: 2px;
color: var(--accent);
@@ -1330,7 +1334,7 @@ input[type="range"]::-webkit-slider-thumb {
}
.auth-card-code {
flex: 1;
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-family: 'JetBrains Mono', ui-monospace, 'Segoe UI', Tahoma, monospace;
font-size: 26px;
letter-spacing: 3px;
color: var(--accent);
@@ -1386,7 +1390,7 @@ input[type="range"]::-webkit-slider-thumb {
.health-score.is-danger { border-color: #ef4444; color: #ef4444; }
.health-score-num {
font-size: 36px; font-weight: 700;
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-family: 'JetBrains Mono', ui-monospace, 'Segoe UI', Tahoma, monospace;
}
.health-score-lbl {
font-size: 11px; font-weight: 600;
@@ -1417,7 +1421,7 @@ input[type="range"]::-webkit-slider-thumb {
.health-bd-n { text-align: right; font-variant-numeric: tabular-nums; }
.health-bd-pts {
text-align: right;
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-family: 'JetBrains Mono', ui-monospace, 'Segoe UI', Tahoma, monospace;
color: var(--text-faint);
}
/* Nothing wrong in this category — dim it so the eye goes to what needs work. */
@@ -1572,7 +1576,7 @@ input[type="range"]::-webkit-slider-thumb {
.history-date {
font-size: 11px;
color: var(--text-dim);
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-family: 'JetBrains Mono', ui-monospace, 'Segoe UI', Tahoma, monospace;
}
.history-preview {
display: flex; align-items: flex-start; gap: 6px;
@@ -1581,7 +1585,7 @@ input[type="range"]::-webkit-slider-thumb {
.history-value {
flex: 1 1 0;
min-width: 0;
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-family: 'JetBrains Mono', ui-monospace, 'Segoe UI', Tahoma, monospace;
font-size: 12px;
color: var(--text);
/* Long revealed passwords wrap to multiple lines (no ellipsis) so
@@ -1634,7 +1638,7 @@ input[type="range"]::-webkit-slider-thumb {
flex-wrap: wrap;
}
.cheatsheet-keys kbd {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-family: 'JetBrains Mono', ui-monospace, 'Segoe UI', Tahoma, monospace;
font-size: 11px;
padding: 2px 7px;
background: var(--bg);
@@ -1814,7 +1818,7 @@ input[type="range"]::-webkit-slider-thumb {
.audit-row:hover { background: var(--bg); }
.audit-date {
color: var(--text-dim);
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-family: 'JetBrains Mono', ui-monospace, 'Segoe UI', Tahoma, monospace;
white-space: nowrap;
}
.audit-action {
@@ -1823,7 +1827,7 @@ input[type="range"]::-webkit-slider-thumb {
}
.audit-ip {
color: var(--text-faint);
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-family: 'JetBrains Mono', ui-monospace, 'Segoe UI', Tahoma, monospace;
text-align: right;
}
@@ -1969,7 +1973,7 @@ input[type="range"]::-webkit-slider-thumb {
.so-note-body {
min-height: 220px;
resize: vertical;
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-family: 'JetBrains Mono', ui-monospace, 'Segoe UI', Tahoma, monospace;
font-size: 13px;
line-height: 1.55;
white-space: pre-wrap;
@@ -1979,6 +1983,15 @@ input[type="range"]::-webkit-slider-thumb {
.so-note-body.is-masked,
.so-custom-multiline.is-masked { -webkit-text-security: disc; }
.so-note-label { display: flex; align-items: center; justify-content: space-between; }
/* Note de commandes : une ligne par commande, groupees sous leur titre. */
.so-cmds-toggle { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-dim); cursor: pointer; }
.so-cmds-list { margin-top: 8px; display: flex; flex-direction: column; gap: 4px; }
.so-cmds-group { font-size: 11px; font-weight: 600; text-transform: uppercase; color: var(--text-dim); margin-top: 8px; }
.so-cmds-row { display: flex; align-items: center; gap: 8px; padding: 4px 8px; border: 1px solid var(--border); border-radius: 6px; }
.so-cmds-cmd { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.so-cmds-empty { font-size: 12px; color: var(--text-dim); }
#qsBack { flex-shrink: 0; }
.qs-cmd { font-family: ui-monospace, Consolas, 'Segoe UI', Tahoma, monospace; font-size: 13px; }
/* ---- + New dropdown ---------------------------------- */
.new-entry-wrap {
@@ -2080,7 +2093,9 @@ input[type="range"]::-webkit-slider-thumb {
flex-shrink: 0;
}
.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;
border: none;
color: var(--text);
@@ -2088,6 +2103,7 @@ input[type="range"]::-webkit-slider-thumb {
outline: none;
}
.quick-search-input kbd {
flex-shrink: 0;
font-size: 10px;
padding: 2px 6px;
border-radius: 4px;
@@ -2133,6 +2149,19 @@ input[type="range"]::-webkit-slider-thumb {
color: var(--text);
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 {
font-size: 12px;
color: var(--text-dim);
@@ -2269,6 +2298,11 @@ body[data-editor-position="center"]:has(#settingsPanel.is-open)::before {
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.slideover-body { padding: 20px; overflow-y: auto; flex: 1; }
/* Precedent / Suivant : le chevron du sprite, tourne. Le titre prend la place. */
#slideover .slideover-header h3 { flex: 1; min-width: 0; margin-left: 6px; }
.so-hist:disabled { opacity: .3; cursor: default; }
.so-hist-back { transform: rotate(90deg); }
.so-hist-fwd { transform: rotate(-90deg); }
.slideover-field { margin-bottom: 16px; }
/* Settings panel search — sits below the header, above the body. Only
present inside #settingsPanel. */
@@ -2513,7 +2547,7 @@ body[data-editor-position="center"]:has(#settingsPanel.is-open)::before {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-family: 'JetBrains Mono', monospace;
font-family: 'JetBrains Mono', 'Segoe UI', Tahoma, monospace;
font-size: 13px;
word-break: break-all;
margin-bottom: 16px;
@@ -2750,7 +2784,7 @@ body[data-editor-position="center"]:has(#settingsPanel.is-open)::before {
/* ---- Hotkey capture buttons (Settings → Autofill) ---- */
.hotkey-capture-btn {
font-family: ui-monospace, "Cascadia Code", Menlo, monospace;
font-family: ui-monospace, "Cascadia Code", Menlo, 'Segoe UI', Tahoma, monospace;
font-size: 11px;
min-width: 140px;
text-align: center;
@@ -3210,7 +3244,7 @@ input[type="password"]::-ms-clear {
margin-bottom: 28px;
}
.prc-code {
font-family: 'Courier New', monospace;
font-family: 'Courier New', 'Segoe UI', Tahoma, monospace;
font-size: 32px;
letter-spacing: 6px;
text-align: center;
+274 -11
View File
@@ -109,6 +109,7 @@ type
// WinEvent hook that watches every foreground change in the session.
FForegroundHook: THandle;
FOnSystemLock: TProc;
FOnSystemResume: TProc;
// Set TRUE the moment Windows tells us the session is ending
// (WM_QUERYENDSESSION / WM_ENDSESSION). FormCloseQuery checks this
// to bypass the "minimize to tray" intercept so the form closes
@@ -182,6 +183,7 @@ type
// les declenche aussi, meme capture de la fenetre cible.
procedure FireNewEntryRequest;
procedure FireQuickSearchRequest;
procedure PasteClipboardAsKeystrokes;
/// Pose l'opacite qui convient a l'etat de survol donne.
procedure ApplyLazyAlpha(AHover: Boolean);
/// Menu du clic droit sur la barrette : le choix de l'opacite au repos.
@@ -296,6 +298,8 @@ type
property AutofillTotpActive: Boolean read FAutofillTotpActive;
// Fired on main thread when Windows locks the session (WTS_SESSION_LOCK).
property OnSystemLock: TProc read FOnSystemLock write FOnSystemLock;
// Sortie de veille ou session deverrouillee : de quoi reveiller la page.
property OnSystemResume: TProc read FOnSystemResume write FOnSystemResume;
// True once WM_QUERYENDSESSION (or WM_ENDSESSION) has been received.
// FormCloseQuery uses this to allow normal close during shutdown.
property ShutdownPending: Boolean read FShutdownPending;
@@ -406,6 +410,7 @@ const
const
WM_WTSSESSION_CHANGE = $02B1;
WTS_SESSION_LOCK = 7;
WTS_SESSION_UNLOCK = 8;
NOTIFY_FOR_THIS_SESSION = 0;
// Power management broadcast — sent to all top-level windows when the
@@ -446,6 +451,7 @@ const
LAZY_ID_ADD = 106; // nouvelle entree depuis la fenetre en avant (Ctrl+Shift+A)
LAZY_ID_SEARCH = 107; // recherche rapide (Ctrl+Shift+Q)
LAZY_ID_SETTINGS = 108; // reglages, onglet Fill bar
LAZY_ID_PASTE = 109; // tape le presse-papiers Windows dans la fenetre en avant
LAZY_MENU_ICONS = 1; // hors des valeurs d'opacite (25..100) du meme menu
LAZY_MENU_SETTINGS = 2;
LAZY_MENU_CLEAR = 3;
@@ -1112,6 +1118,59 @@ end;
// The target of a fill is whatever window owns the foreground at the moment
// the user asks for it - a shortcut, or a click on the lazy strip, which takes
// no foreground precisely so this stays true.
/// COLLER EN TAPANT. Certains clients a distance (TigerVNC vers un Mac) ne
/// transmettent pas le presse-papiers Windows : Ctrl+V n'y colle rien. Le texte
/// est donc TAPE dans la fenetre en avant, par le meme chemin que l'autofill -
/// vraies touches pour un client VNC, dispositions basculees au besoin. Le
/// presse-papiers est celui de Windows : coffre ouvert ou non, rien ne passe
/// par la page. Pas de Ctrl+A + Suppr avant : on insere au curseur, comme un
/// collage.
procedure TPMBridge.PasteClipboardAsKeystrokes;
var
LTarget: HWND;
LData: THandle;
LPtr: PWideChar;
LText: string;
begin
LTarget := GetForegroundWindow;
if IsOwnProcessWindow(LTarget) then
begin
ShowBalloon('Nothing to paste into',
'Click where the text should go first, then press Paste.', True);
Exit;
end;
LText := '';
if OpenClipboard(0) then
try
LData := GetClipboardData(CF_UNICODETEXT);
if LData <> 0 then
begin
LPtr := GlobalLock(LData);
if LPtr <> nil then
try
LText := LPtr;
finally
GlobalUnlock(LData);
end;
end;
finally
CloseClipboard;
end;
// Un retour a la ligne final, tape dans un terminal, vaudrait Entree.
// Les sauts de ligne internes partent en #13 : Entree, comme un collage.
LText := LText.Replace(#13#10, #10).Replace(#13, #10).TrimRight([#10])
.Replace(#10, #13);
if LText = '' then
begin
// Une image ou un fichier copies ne sont pas du texte : le dire tel quel.
ShowBalloon('No text in the clipboard', 'Copy some text first, then press Paste.', True);
Exit;
end;
if not ExecuteAutofill(LTarget, '', LText, False, False, False) then
ShowBalloon('Paste blocked',
'The window did not accept the keystrokes (it may run as administrator).', True);
end;
procedure TPMBridge.FireAutofillRequest(AKind: TAutofillKind;
AFromBar: Boolean = False);
var
@@ -1202,6 +1261,47 @@ begin
Result := CallWindowProc(GLazyOldBtnProc, AWnd, AMsg, AWp, ALp);
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);
var
LRect: TRect;
@@ -1257,6 +1357,7 @@ begin
LAZY_ID_SEARCH: LGlyph := #$E721; // Search
LAZY_ID_OPEN: LGlyph := #$E8A7; // OpenInNewWindow
LAZY_ID_SETTINGS: LGlyph := #$E713; // Settings (roue)
LAZY_ID_PASTE: LGlyph := #$E77F; // Paste
else
LGlyph := #$E711; // Cancel (X)
end;
@@ -1389,13 +1490,10 @@ begin
ApplyLazyAlpha(LIn);
end;
// 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
// bande : une barrette posee dessus passe DERRIERE et semble avoir
// disparu, alors qu'elle est seulement recouverte. La reposer a chaque
// battement est ce que font tous les outils flottants ; sans mouvement
// 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);
// elle aussi, et se remet en tete de cette bande au demarrage de la
// session comme a l'ouverture du menu Demarrer : une barrette posee
// dessus passe DERRIERE et semble avoir disparu.
if LazyIsCovered(FLazyWnd) then LazyForceToTop(FLazyWnd);
Exit;
end;
// Fin du glissement (la boucle modale ouverte par HTCAPTION), donc une
@@ -1463,6 +1561,7 @@ begin
LAZY_ID_OPEN: if Assigned(FOnTrayRestore) then FOnTrayRestore();
LAZY_ID_ADD: FireNewEntryRequest;
LAZY_ID_SEARCH: FireQuickSearchRequest;
LAZY_ID_PASTE: PasteClipboardAsKeystrokes;
LAZY_ID_SETTINGS:
begin
if Assigned(FOnTrayRestore) then FOnTrayRestore();
@@ -1627,6 +1726,7 @@ begin
AddButton('full', LAZY_ID_FULL, 'Full', 'Fill username and password', 56);
AddButton('pwd', LAZY_ID_PWD, 'Password', 'Fill the password only', 76);
AddButton('totp', LAZY_ID_TOTP, '2FA', 'Fill the 2FA code', 52);
AddButton('paste', LAZY_ID_PASTE, 'Paste', 'Type the Windows clipboard into the current window', 52);
AddButton('add', LAZY_ID_ADD, 'Add', 'New entry from the current window', 48);
AddButton('search', LAZY_ID_SEARCH, 'Search', 'Quick search', 60);
// Meme raison que le X : quand la barrette est allumee, elle est souvent la
@@ -1717,6 +1817,8 @@ begin
begin
if AMsg.WParam = WTS_SESSION_LOCK then
if Assigned(FOnSystemLock) then FOnSystemLock();
if AMsg.WParam = WTS_SESSION_UNLOCK then
if Assigned(FOnSystemResume) then FOnSystemResume();
end
else if AMsg.Msg = WM_POWERBROADCAST then
@@ -1730,6 +1832,8 @@ begin
// suspends — fast handler required (no UI prompts, no network).
if AMsg.WParam = PBT_APMSUSPEND then
if Assigned(FOnSystemLock) then FOnSystemLock();
if AMsg.WParam = PBT_APMRESUMEAUTOMATIC then
if Assigned(FOnSystemResume) then FOnSystemResume();
end
else if (AMsg.Msg = WM_QUERYENDSESSION) or (AMsg.Msg = WM_ENDSESSION) then
@@ -2030,6 +2134,157 @@ begin
SendInput(LCount, @LInputs[0], SizeOf(TInput));
end;
function QueryFullProcessImageNameW(hProcess: THandle; dwFlags: DWORD;
lpExeName: PWideChar; var lpdwSize: DWORD): BOOL; stdcall;
external kernel32 name 'QueryFullProcessImageNameW';
/// UN VISUALISEUR VNC NE SAIT PAS TAPER DE L'UNICODE. SendUnicodeString envoie
/// chaque caractere en VK_PACKET ; une fenetre Windows le comprend, mais VNC
/// (TigerVNC, RealVNC, TightVNC, UltraVNC) traduit les VRAIES touches vers la
/// machine distante et ne sait que faire de VK_PACKET : un Mac recevait
/// "VVVVVV", un V par caractere. Pour lui on tape les touches du clavier.
/// ponytail: reconnu au nom de l'exe ("vnc") ; un autre client a distance qui
/// aurait le meme defaut s'ajouterait ici, ou deviendrait un reglage.
function TargetTypesByKeyCodes(AHwnd: HWND): Boolean;
const
PROCESS_QUERY_LIMITED_INFORMATION = $1000;
var
LPid, LLen: DWORD;
LProc: THandle;
LName: array[0..MAX_PATH] of WideChar;
begin
Result := False;
LPid := 0;
GetWindowThreadProcessId(AHwnd, LPid);
if LPid = 0 then Exit;
LProc := OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, LPid);
if LProc = 0 then Exit;
try
LLen := Length(LName);
if QueryFullProcessImageNameW(LProc, 0, @LName[0], LLen) then
Result := ExtractFileName(string(LName)).ToLower.Contains('vnc');
finally
CloseHandle(LProc);
end;
end;
/// Le texte en VRAIES touches, dans la disposition clavier de la cible : chaque
/// caractere devient sa touche plus Maj / Ctrl / Alt selon VkKeyScanEx, avec
/// son scancode (certains clients VNC lisent la position physique).
/// ALTGR = Ctrl GAUCHE + Alt DROIT (touche etendue), comme le clavier le
/// produit : un Ctrl+Alt generique etait lu par TigerVNC comme deux
/// modificateurs ordinaires, et "@" (AltGr+0 en AZERTY) arrivait en "a".
/// TEXTE MELANGE (arabe + latin) : un caractere sans touche dans la
/// disposition courante est cherche dans les AUTRES dispositions installees
/// sur le poste ; on y bascule la fenetre cible (TigerVNC traduit chaque touche
/// avec la disposition de SON fil), on tape, et la disposition d'origine est
/// rendue a la fin. Seul un caractere qu'aucune disposition installee ne sait
/// taper est saute et compte - la copie, que TigerVNC synchronise, reste alors
/// le recours. Rend le nombre de sautes.
function SwitchTargetLayout(ATarget: HWND; ALayout: HKL): Boolean;
var
LThread: DWORD;
begin
LThread := GetWindowThreadProcessId(ATarget, nil);
PostMessage(ATarget, WM_INPUTLANGCHANGEREQUEST, 0, LPARAM(ALayout));
// La bascule est asynchrone : on attend qu'elle soit faite avant de taper,
// sinon les premieres touches partiraient encore dans l'ancienne.
for var I := 1 to 30 do
begin
if GetKeyboardLayout(LThread) = ALayout then Exit(True);
Sleep(10);
end;
Result := GetKeyboardLayout(LThread) = ALayout;
end;
function LayoutForChar(ACh: WideChar; ACurrent: HKL): HKL;
var
LList: array[0..31] of HKL;
N, I: Integer;
begin
Result := 0;
N := GetKeyboardLayoutList(Length(LList), LList);
for I := 0 to N - 1 do
if (LList[I] <> ACurrent) and (VkKeyScanEx(ACh, LList[I]) <> -1) then
Exit(LList[I]);
end;
function SendKeyCodeString(const AText: string; ATarget: HWND; ALayout: HKL): Integer;
var
LInputs: TArray<TInput>;
LCount: Integer;
procedure Key(AVk: Word; AUp: Boolean; AExtended: Boolean = False);
begin
if LCount >= Length(LInputs) then SetLength(LInputs, LCount + 16);
FillChar(LInputs[LCount], SizeOf(TInput), 0);
LInputs[LCount].Itype := INPUT_KEYBOARD;
LInputs[LCount].ki.wVk := AVk;
LInputs[LCount].ki.wScan := MapVirtualKeyEx(AVk, 0 {MAPVK_VK_TO_VSC}, ALayout);
if AUp then LInputs[LCount].ki.dwFlags := KEYEVENTF_KEYUP;
if AExtended then
LInputs[LCount].ki.dwFlags := LInputs[LCount].ki.dwFlags or KEYEVENTF_EXTENDEDKEY;
Inc(LCount);
end;
var
I: Integer;
LScan: SHORT;
LVk, LMods: Byte;
LShift, LCtrl, LAlt: Boolean;
begin
Result := 0;
var LOriginal := ALayout;
try
for I := 1 to Length(AText) do
begin
LScan := VkKeyScanEx(AText[I], ALayout);
if LScan = -1 then
begin
var LOther := LayoutForChar(AText[I], ALayout);
if (LOther <> 0) and SwitchTargetLayout(ATarget, LOther) then
begin
ALayout := LOther;
LScan := VkKeyScanEx(AText[I], ALayout);
end;
end;
if LScan = -1 then
begin
Inc(Result);
Continue;
end;
LVk := Lo(Word(LScan));
LMods := Hi(Word(LScan));
LShift := LMods and 1 <> 0;
LCtrl := LMods and 2 <> 0;
LAlt := LMods and 4 <> 0;
LCount := 0;
if LShift then Key(VK_LSHIFT, False);
if LCtrl then Key(VK_LCONTROL, False);
// Alt seul = Alt gauche ; avec Ctrl, c'est AltGr = Alt droit, etendu.
if LAlt and LCtrl then Key(VK_RMENU, False, True)
else if LAlt then Key(VK_LMENU, False);
Key(LVk, False);
Key(LVk, True);
if LAlt and LCtrl then Key(VK_RMENU, True, True)
else if LAlt then Key(VK_LMENU, True);
if LCtrl then Key(VK_LCONTROL, True);
if LShift then Key(VK_LSHIFT, True);
SendInput(LCount, @LInputs[0], SizeOf(TInput));
end;
finally
// Rendre a l'utilisateur la disposition qu'il avait choisie.
if ALayout <> LOriginal then SwitchTargetLayout(ATarget, LOriginal);
end;
end;
/// Rend le nombre de caracteres qui n'ont pas pu etre tapes (VNC seulement).
function SendTextTo(const AText: string; AByKeys: Boolean; ATarget: HWND;
ALayout: HKL): Integer;
begin
Result := 0;
if AByKeys then Result := SendKeyCodeString(AText, ATarget, ALayout)
else SendUnicodeString(AText);
end;
// Send one virtual-key press (down+up).
procedure SendVKey(AVk: Word);
var
@@ -2244,8 +2499,12 @@ begin
LSw := TStopwatch.StartNew;
WaitForModifierRelease(1000);
ForceReleaseModifiers; // timeout hit with keys still down → clean state
var LByKeys := (ATargetHWND <> 0) and TargetTypesByKeyCodes(ATargetHWND);
var LLayout := GetKeyboardLayout(GetWindowThreadProcessId(ATargetHWND, nil));
FLastFillWaits := Format('foreground=%d ms, modifiers=%d ms',
[LFgMs, LSw.ElapsedMilliseconds]);
if LByKeys then FLastFillWaits := FLastFillWaits + ', typed as key codes (VNC)';
var LMissing := 0;
Sleep(FocusSettleDelayMs);
// Never type into our own window: if the target refused the foreground,
@@ -2269,19 +2528,19 @@ begin
if AUsernameOnly then
begin
ClearFieldIfWanted;
SendUnicodeString(AUsername);
Inc(LMissing, SendTextTo(AUsername, LByKeys, ATargetHWND, LLayout));
Exit;
end;
if AUsername = '' then
begin
ClearFieldIfWanted;
SendUnicodeString(APassword);
Inc(LMissing, SendTextTo(APassword, LByKeys, ATargetHWND, LLayout));
Exit;
end;
ClearFieldIfWanted;
SendUnicodeString(AUsername);
Inc(LMissing, SendTextTo(AUsername, LByKeys, ATargetHWND, LLayout));
Sleep(200);
// Real-VK send: a physically held Shift here turns Tab into Shift+Tab —
// focus moves BACKWARD and the password lands in the username field.
@@ -2293,8 +2552,12 @@ begin
// Tab lands somewhere that is not an editable field (a link, a reveal
// button, the document itself, as on the Reddit login form) it selects the
// ENTIRE PAGE, and the password is then typed into nothing.
SendUnicodeString(APassword);
Inc(LMissing, SendTextTo(APassword, LByKeys, ATargetHWND, LLayout));
finally
if LMissing > 0 then
FLastFillWaits := FLastFillWaits + Format(
', %d character(s) have no key on this keyboard and were NOT typed - copy instead',
[LMissing]);
// Re-register with the LIVE combo (user may have rebound it), not the
// hardcoded default — otherwise a fill would reset new-entry to Ctrl+Shift+A.
if FNewEntryHotkeyRegistered then
+105 -15
View File
@@ -98,6 +98,13 @@ type
FFileSaveChunks: TDictionary<string, TStringBuilder>;
FPendingURL: string;
FNavTimer: TTimer;
// REVEIL DE LA PAGE pendant que l'application dort dans le tray. Les
// drapeaux --disable-renderer-backgrounding & co sont bien appliques, et
// pourtant le premier autofill apres des heures cachee attendait 3 a 5 s
// la reponse de la page (mesure : 8 ms coffre tout juste ouvert, 4907 ms
// apres une veille). Windows laisse s'endormir un processus que personne
// ne sollicite ; une instruction JS triviale toutes les 5 min l'en empeche.
FKeepWarmTimer: TTimer;
// Set once WebView2 fires OnInitialized. The nav timer only consumes
// FPendingURL when this is True — otherwise a timer tick that lands
// before the engine is ready would Navigate() into the void AND clear
@@ -163,6 +170,7 @@ type
procedure UpdateButtons;
procedure NavigateToVault;
procedure NavTimerTick(Sender: TObject);
procedure KeepWarmTick(Sender: TObject);
// JS↔Delphi bridge
procedure WebBrowserBeforeNavigate(Sender: TObject;
var Params: TTMSFNCCustomWebBrowserBeforeNavigateParams);
@@ -287,7 +295,20 @@ begin
// USE_EDGE_BROWSER} directive can swap the concrete class without a
// second .fmx variant. Align Client to fill the area below PanelTop
// and above the (collapsible) PanelLog + Splitter.
// CACHE EFFACE AU DEMARRAGE, PAS A LA FERMETURE. TMS l'effacait dans son
// destructeur, pendant que les processus msedgewebview2 supprimaient encore
// leurs propres fichiers : un fichier disparu en cours de parcours
// (EBWebView\Default\DIPS-wal) levait EDirectoryNotFoundException, non
// rattrapee, a la fermeture. Ici aucun moteur ne tourne encore - la garde
// d'instance unique est passee dans le .dpr. Meme dossier que TMS :
// %TEMP%\<exe>\EdgeCache. Echec ignore : un cache reste un cache.
try
TDirectory.Delete(TPath.Combine(TPath.Combine(TPath.GetTempPath,
ChangeFileExt(ExtractFileName(ParamStr(0)), '')), 'EdgeCache'), True);
except
end;
WebBrowser := TWebBrowserClass.Create(Self);
WebBrowser.AutoClearCache := False;
// Wire events BEFORE Parent assignment: setting Parent triggers the
// TMS browser's async WebView2 init, which fires OnInitialized when
// Edge Chromium is ready. If we assigned the handlers afterwards we'd
@@ -313,6 +334,14 @@ begin
FBridge := TPMBridge.Create(Self);
FFileSaveChunks := TDictionary<string, TStringBuilder>.Create;
FBridge.OnSystemLock := BridgeSystemLock;
// Le reveil des 5 min ne tourne pas pendant la veille : on le donne tout de
// suite au retour, avant le premier autofill.
FBridge.OnSystemResume :=
procedure
begin
LogLine('Windows resumed / unlocked — waking the page');
KeepWarmTick(nil);
end;
FBridge.OnTrayRestore := BridgeTrayRestore;
// Changement fait sur la barrette elle-meme : l'onglet se relit (fillbar/get).
FBridge.OnFillBarChanged :=
@@ -335,7 +364,13 @@ begin
FBridge.OnNewEntryHotkey := BridgeNewEntryHotkey;
FBridge.OnQuickSearchRequest := BridgeQuickSearchRequest;
FBridge.OnQuickSearchHotkey := BridgeQuickSearchHotkey;
FBridge.RegisterAutofillHotkey; // Ctrl+Shift+L active from startup
// Les combinaisons de la derniere session (dont les raccourcis desactives),
// sinon les valeurs par defaut.
var LSavedHotkeys := PM.UserPrefs.GetPref('hotkeys');
if LSavedHotkeys <> '' then
HandleBridgeCommand('autofill/hotkeys', LSavedHotkeys)
else
FBridge.RegisterAutofillHotkey; // Ctrl+Shift+L active from startup
FBridge.ApplyTitleBarTheme(True); // dark by default, JS may toggle later
// Apres le theme et apres OnTrayRestore : la barrette se peint elle-meme, et
// son bouton Open passe par cet evenement.
@@ -351,6 +386,10 @@ begin
FNavTimer.Enabled := False;
FNavTimer.OnTimer := NavTimerTick;
FKeepWarmTimer := TTimer.Create(Self);
FKeepWarmTimer.Interval := 5 * 60 * 1000;
FKeepWarmTimer.OnTimer := KeepWarmTick;
UpdateButtons;
LogLine('Password Manager - d backend ready.');
LogLine('Click Start to launch server + embedded web vault.');
@@ -805,6 +844,12 @@ begin
FNavTimer.Enabled := True;
end;
procedure TMainForm.KeepWarmTick(Sender: TObject);
begin
if FBrowserInitialized then
WebBrowser.ExecuteJavaScript('window.Bridge&&Bridge.active');
end;
procedure TMainForm.NavTimerTick(Sender: TObject);
begin
FNavTimer.Enabled := False;
@@ -1107,6 +1152,13 @@ begin
// Ids, not labels: JS owns the wording AND uses them to flag the exact
// row in Settings. A combo the user cleared (vk=0) is never listed.
var LFailed := '';
// RETENU EN PREF DPAPI pour etre rejoue au demarrage : les combinaisons
// vivent dans settings_json, lu seulement apres la connexion - un raccourci
// desactive restait donc pris par les valeurs par defaut tant que le coffre
// etait ferme. Pas la suspension de la capture (sans full_vk). Rien de
// secret dedans.
if GetParam('full_vk') <> '' then
PM.UserPrefs.SetPref('hotkeys', AParams);
// Quick-search combo is set unconditionally (independent of the
// autofill enabled flag) so the picker stays armed even when the
// autofill hotkeys are turned off.
@@ -1159,9 +1211,10 @@ begin
// One notification for the whole batch, naming the combos that failed —
// empty string = everything registered.
LFailed := LFailed.TrimRight([',', ' ']);
WebBrowser.ExecuteJavaScript(
'if(window.Bridge&&Bridge.onAutofillHotkeysResult)' +
'Bridge.onAutofillHotkeysResult("' + LFailed.Replace('"', '') + '")');
if FBrowserInitialized then
WebBrowser.ExecuteJavaScript(
'if(window.Bridge&&Bridge.onAutofillHotkeysResult)' +
'Bridge.onAutofillHotkeysResult("' + LFailed.Replace('"', '') + '")');
end
// execute: JS has matched an entry, decrypted the password, and is
@@ -1404,9 +1457,14 @@ begin
procedure
var
LText: string;
LFound: Boolean;
begin
Sleep(400); // laisser Windows retirer la fenetre de l'ecran
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 := '';
TThread.Queue(nil,
procedure
@@ -1414,12 +1472,14 @@ begin
LJs: TJSONString;
begin
RestoreWindowFromTray;
if LText = '' then LogLine('qr/screen: no otpauth QR found')
else LogLine('qr/screen: otpauth QR found');
if LText <> '' then 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);
try
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
LJs.Free;
end;
@@ -1431,6 +1491,12 @@ begin
begin
var LQrUrl := PM.UserPrefs.GetPref('syncUrl');
var LQrTok := PM.UserPrefs.GetPref('syncPwd');
// WEBDAV : le telephone attend "utilisateur:mot-de-passe" dans le jeton
// (il en fait une authentification Basic).
if (Pos('/api/v1/repos/', LQrUrl) = 0) and
not ContainsText(LQrUrl, 'api.github.com/repos/') and
(PM.UserPrefs.GetPref('syncUser') <> '') and (LQrTok <> '') then
LQrTok := PM.UserPrefs.GetPref('syncUser') + ':' + LQrTok;
var LBits := '';
var LSize := 0;
if (LQrUrl <> '') and (LQrTok <> '') then
@@ -1448,16 +1514,21 @@ begin
var LShort := LQrUrl;
var LApi := Pos('/api/v1/repos/', LShort);
// 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
EndsText('/contents/vault-sync.json', LShort) then
EndsText(SyncDefaultFile, LShort) then
LShort := 'https://github.com/' +
Copy(LShort, Length('https://api.github.com/repos/') + 1,
Length(LShort) - Length('https://api.github.com/repos/') -
Length('/contents/vault-sync.json'))
else if (LApi > 0) and EndsText('/contents/vault-sync.json', LShort) then
Length(SyncDefaultFile))
else if (LApi > 0) and EndsText(SyncDefaultFile, LShort) then
LShort := Copy(LShort, 1, LApi) +
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, '&', '%26', [rfReplaceAll]);
LShort := StringReplace(LShort, '#', '%23', [rfReplaceAll]);
@@ -1682,6 +1753,15 @@ begin
LErr: string;
LEtag: string;
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;
LBodyB64 := '';
LErr := '';
@@ -1689,7 +1769,7 @@ begin
try
LHttp := System.Net.HttpClient.THTTPClient.Create;
try
LHttp.ConnectionTimeout := 10000;
LHttp.ConnectionTimeout := 8000; // voir la boucle ci-dessus
LHttp.ResponseTimeout := 30000;
// GitHub REFUSE toute requete sans User-Agent (403) ; Gitea l'ignore.
LHttp.UserAgent := 'PMServer';
@@ -1704,6 +1784,13 @@ begin
end
else if (LUser <> '') then
begin
// BASIC DES LA PREMIERE REQUETE, comme le telephone. Laisse a
// Windows, l'authentification partait d'abord en anonyme : 401,
// puis la meme requete - et un PUT envoyait le coffre deux fois.
// Les identifiants restent aussi en reserve pour un serveur Digest.
LHttp.CustomHeaders['Authorization'] := 'Basic ' +
TNetEncoding.Base64.Encode(LUser + ':' + LPwd)
.Replace(#13, '').Replace(#10, '');
LHttp.CredentialsStorage.AddCredential(
System.Net.URLClient.TCredentialsStorage.TCredential.Create(
System.Net.URLClient.TAuthTargetType.Server, '', '', LUser, LPwd));
@@ -1774,6 +1861,8 @@ begin
LErr := E.Message;
end;
end;
if LStatus <> 0 then Break;
end;
TThread.Queue(nil,
procedure
var
@@ -1977,7 +2066,7 @@ begin
LogLine('Windows session locked / suspend — notifying JS');
WebBrowser.ExecuteJavaScript(
'if(window.Bridge&&typeof Bridge.onSystemLock==="function")Bridge.onSystemLock();' +
'else if(typeof lockVault==="function")lockVault()');
'else if(typeof lockVault==="function")lockVault("Windows session locked or computer asleep")');
end;
procedure TMainForm.BridgeTrayRestore;
@@ -1995,7 +2084,7 @@ begin
// User picked "Lock vault" from the tray menu. Trigger lockVault() in
// JS — same path as the WTS_SESSION_LOCK auto-lock.
LogLine('Lock requested from tray menu');
WebBrowser.ExecuteJavaScript('if(typeof lockVault==="function")lockVault()');
WebBrowser.ExecuteJavaScript('if(typeof lockVault==="function")lockVault("tray menu")');
end;
procedure TMainForm.BridgeQuit;
@@ -2205,7 +2294,8 @@ begin
FAutofillClock := TStopwatch.StartNew;
WebBrowser.ExecuteJavaScript(
'if(window.Bridge&&typeof Bridge.onAutofillRequest==="function")' +
'Bridge.onAutofillRequest("' + LTitle + '","' + LKind + '")');
'Bridge.onAutofillRequest("' + LTitle + '","' + LKind + '",' +
BoolToStr(FBridge.LastRequestFromBar, True).ToLower + ')');
LogLine('Autofill ' + IfThen(FBridge.LastRequestFromBar, 'fill bar', 'hotkey') +
' (' + LKind + ') — foreground: "' + ATitle + '"');
end;
+20 -3
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-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-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-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">
@@ -369,6 +371,10 @@
<svg><use href="#i-globe"/></svg>
<span>Server credentials</span>
</button>
<button class="dropdown-item" data-new-template="commands">
<svg><use href="#i-edit"/></svg>
<span>Commands (note)</span>
</button>
<button class="dropdown-item" data-new-template="recovery-codes">
<svg><use href="#i-shield"/></svg>
<span>Recovery codes (note)</span>
@@ -419,6 +425,8 @@
<!-- ---- SLIDE-OVER DETAIL ------------------------------- -->
<aside class="slideover" id="slideover">
<header class="slideover-header">
<button class="icon-btn so-hist" id="slideoverBack" title="Previous entry (Alt+Left)" disabled><svg class="so-hist-back"><use href="#i-chevron-down"/></svg></button>
<button class="icon-btn so-hist" id="slideoverFwd" title="Next entry (Alt+Right)" disabled><svg class="so-hist-fwd"><use href="#i-chevron-down"/></svg></button>
<h3 id="slideoverTitle">Entry</h3>
<button class="icon-btn" id="slideoverClose"><svg><use href="#i-x"/></svg></button>
</header>
@@ -732,7 +740,7 @@
<button class="btn btn-ghost btn-sm hotkey-capture-btn" id="settingNewEntryCombo">Ctrl+Shift+A</button>
</div>
<p style="font-size:11px;color:var(--text-faint);margin:6px 0 0;line-height:1.4">
Click a button, then press your new combo. Needs Ctrl, Alt or Win + a letter / digit / F-key.
Click a button, then press your new combo. Needs Ctrl, Alt or Win + a letter / digit / F-key. Press Delete instead to turn that hotkey off.
</p>
<button class="btn btn-ghost btn-sm" id="settingAutofillResetHotkeys" style="margin-top:6px">
Reset to defaults
@@ -760,6 +768,7 @@
<label><input type="checkbox" class="fb-btn" data-key="full"> Full</label>
<label><input type="checkbox" class="fb-btn" data-key="pwd"> Password</label>
<label><input type="checkbox" class="fb-btn" data-key="totp"> 2FA</label>
<label><input type="checkbox" class="fb-btn" data-key="paste"> Paste (types the clipboard)</label>
<label><input type="checkbox" class="fb-btn" data-key="add"> Add current window</label>
<label><input type="checkbox" class="fb-btn" data-key="search"> Search</label>
<label><input type="checkbox" class="fb-btn" data-key="open"> Open</label>
@@ -1225,10 +1234,18 @@
<div class="modal-backdrop" data-close></div>
<div class="modal-panel modal-panel-sm quick-search-panel">
<div class="quick-search-input">
<svg><use href="#i-search"/></svg>
<!-- Retour a la liste des entrees, visible dans une note de commandes. -->
<button type="button" class="icon-btn icon-btn-sm" id="qsBack" title="Back to entries (Esc)" hidden><svg class="so-hist-back"><use href="#i-chevron-down"/></svg></button>
<svg id="qsSearchIcon"><use href="#i-search"/></svg>
<input id="quickSearchInput" type="text"
placeholder="Search and press Enter to copy password…"
placeholder="Search…"
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>
</div>
<div class="quick-search-results" id="quickSearchResults"></div>
+16 -4
View File
@@ -51,7 +51,7 @@ function renderHotkeyConflicts() {
// Format a combo for human display: "Ctrl+Shift+L".
function autofillComboLabel(c) {
if (!c || !c.key) return '— not set —';
if (!c || !c.key) return 'Off';
const parts = [];
if (c.ctrl) parts.push('Ctrl');
if (c.alt) parts.push('Alt');
@@ -92,6 +92,9 @@ function autofillComboValid(c) {
function autofillCaptureFromEvent(e) {
const k = e.key;
if (k === 'Escape') return 'cancel';
// Suppr / Retour arriere pendant la capture = raccourci DESACTIVE : il
// n'est plus enregistre, la combinaison reste libre pour une autre appli.
if (k === 'Delete' || k === 'Backspace') return 'off';
// Ignore pure-modifier keydowns (user is still building the combo).
if (k === 'Control' || k === 'Shift' || k === 'Alt' ||
k === 'Meta' || k === 'OS') return null;
@@ -235,8 +238,10 @@ function autofillReportResult(ok) {
// Called by Bridge.onAutofillRequest when a hotkey fires.
// kind: 'full' = Ctrl+Shift+L (user + Tab + pwd) ; 'password' = Ctrl+Shift+P.
async function autofillHandleRequest(windowTitle, kind) {
if (!state.autofillEnabled) return;
async function autofillHandleRequest(windowTitle, kind, fromBar) {
// L'option ne coupe que les RACCOURCIS (« Autofill with global hotkey ») :
// la barrette est un geste explicite, elle remplit quand meme.
if (!state.autofillEnabled && !fromBar) return;
// A blocking operation owns the screen (export, sync, rotation). Covers all
// three hotkeys and both outcomes: the multi-match picker would open behind
@@ -268,7 +273,14 @@ async function autofillHandleRequest(windowTitle, kind) {
else pwd.focus();
}
}, 80);
toast('Vault is locked — unlock to autofill', 'warning');
// LA RAISON AVEC LE MESSAGE. Sans dernier verrouillage connu, c'est que
// cette page n'a jamais ete deverrouillee - rechargee entre-temps, ou
// jamais ouverte : l'heure de chargement le tranche.
toast('Vault is locked — unlock to autofill ('
+ (state.lastLockReason
? 'locked by: ' + state.lastLockReason
: 'never unlocked since the page loaded at ' + state.pageLoadedAt)
+ ')', 'warning');
return;
}
+225 -11
View File
@@ -95,7 +95,8 @@ const Bridge = (() => {
toast('System lock — vault kept unlocked (quick unlock active)');
return;
}
if (typeof lockVault === 'function') lockVault();
if (typeof lockVault === 'function')
lockVault('Windows session locked or computer asleep');
},
// Called by Delphi (ExecuteJavaScript) when the user restores the
@@ -116,8 +117,8 @@ const Bridge = (() => {
// Called by Delphi when a hotkey fires. windowTitle = foreground
// window title at hotkey time. kind = "full" (user+Tab+pwd) or
// "password" (password only — for step-2 forms, unlock screens).
onAutofillRequest(windowTitle, kind) {
autofillHandleRequest(windowTitle, kind || 'full');
onAutofillRequest(windowTitle, kind, fromBar) {
autofillHandleRequest(windowTitle, kind || 'full', !!fromBar);
},
// Called by Delphi after cmd://autofill/execute with whether the
@@ -142,14 +143,14 @@ const Bridge = (() => {
// back to the tray AFTER SendInput completes — necessary for the
// Ctrl+Shift+Q-from-tray flow (we cannot hide before SendInput or
// Win10/11 anti-focus-stealing rules block the target).
executeAutofill(username, password, hideAfter, field) {
executeAutofill(username, password, hideAfter, field, noClear) {
if (!active) return;
// field='user' → type only the username (no Tab / password).
cmd('cmd://autofill/execute?username=' + encodeURIComponent(username) +
'&password=' + encodeURIComponent(password) +
(hideAfter ? '&hide_after=1' : '') +
(field === 'user' ? '&field=user' : '') +
(state.autofillClearField ? '' : '&clear=0') +
(state.autofillClearField && !noClear ? '' : '&clear=0') +
(state.autofillFailBalloon ? '' : '&notify=0'));
},
@@ -732,6 +733,15 @@ const state = {
selectedId: null,
theme: localStorage.getItem('theme') || 'dark',
locked: false, // true after user clicks Lock (token still valid server-side)
// Entrees ouvertes, pour Precedent / Suivant (fleches du panneau, Alt+fleches).
// En memoire seulement, videe au verrouillage.
entryHistory: { ids: [], pos: -1 },
// POURQUOI LE COFFRE S'EST FERME, et depuis quand la page vit. Un
// verrouillage automatique etait muet ; quand le remplissage repondait
// "Vault is locked", rien ne disait si c'etait l'inactivite, une veille de
// Windows, ou une page rechargee qui avait tout perdu.
lastLockReason: '',
pageLoadedAt: new Date().toLocaleTimeString(),
autoLock: parseInt(localStorage.getItem('autoLockMin') || '5'),
askBeforeDelete: localStorage.getItem('askBeforeDelete') !== '0', // default true
maskUsernames: localStorage.getItem('maskUsernames') === '1', // default false
@@ -1424,11 +1434,13 @@ async function confirmDiscardForSessionExit(action) {
});
}
function lockVault() {
function lockVault(reason) {
state.lastLockReason = (reason || 'manual lock') + ' at ' + new Date().toLocaleTimeString();
sessionStorage.removeItem('cryptoKey');
state.cryptoKey = null;
state.entries = [];
state.trashed = [];
state.entryHistory = { ids: [], pos: -1 };
state.locked = true;
state.justRecovered = false;
state.avatarDataUri = ''; // reloaded from server on next unlock
@@ -2886,6 +2898,7 @@ function templateLabel(tpl) {
case 'ssh-key': return 'SSH key';
case 'server': return 'Server';
case 'recovery-codes': return 'Recovery codes';
case 'commands': return 'Commands';
default: return '';
}
}
@@ -4003,6 +4016,14 @@ const ENTRY_TEMPLATES = {
{ label: 'Root password', value: '', is_secret: true },
],
},
// NOTE DE COMMANDES : le corps reste un texte (deja chiffre, synchronise,
// exporte, lu par l'APK) ; seul l'affichage le decoupe (parseCommandNote).
'commands': {
kind: 'note',
title: 'Commands',
body: '# Group\nssh user@host\n',
customFields: [],
},
'recovery-codes': {
kind: 'note',
title: 'Recovery codes',
@@ -4014,6 +4035,85 @@ const ENTRY_TEMPLATES = {
},
};
// DECOUPE D'UNE NOTE DE COMMANDES. "# titre" ouvre un groupe, toute autre ligne
// non vide est une commande, les lignes vides sont ignorees. Rend
// [{group, cmd}] dans l'ordre du texte. Pur : tests/commands.test.js.
// LIGNE CONTINUEE : une ligne finie par \ (bash), ` (PowerShell) ou ^ (cmd)
// se recolle a la suivante, sans le caractere ni le retour a la ligne. Le
// resultat tient sur UNE ligne : tapee dans un terminal, une fin de ligne vaut
// Entree et lancerait une commande a moitie ecrite. Une ligne vide ou la fin du
// texte termine la commande en cours.
function parseCommandNote(text) {
const out = [];
let group = '';
let pending = '';
const flush = () => { if (pending) out.push({ group, cmd: pending }); pending = ''; };
for (const raw of String(text || '').split(/\r?\n/)) {
const line = raw.trim();
if (!line) { flush(); continue; }
if (!pending && line.startsWith('#')) { group = line.replace(/^#+\s*/, ''); continue; }
const cont = /[\\`^]$/.test(line);
const part = cont ? line.slice(0, -1).trimEnd() : line;
pending = pending ? pending + ' ' + part : part;
if (!cont) flush();
}
flush();
return out;
}
// AFFICHAGE MASQUE d'une commande (partage d'ecran), sur l'oeil "mask notes" :
// le premier mot reste, puis le milieu masque, puis les 3 derniers caracteres.
// "ssh admin@10.0.0.11" -> "ssh ••••••••••••.11". Une fin trop courte pour
// en garder 3 sans tout montrer est masquee en entier. Affichage seulement :
// filtre, copie et remplissage travaillent sur le vrai texte.
function maskCommand(cmd) {
const s = String(cmd || '');
const sp = s.indexOf(' ');
const head = sp < 0 ? '' : s.slice(0, sp + 1);
const rest = sp < 0 ? s : s.slice(sp + 1);
if (rest.length <= 6) return head + '•'.repeat(rest.length);
return head + '•'.repeat(rest.length - 3) + rest.slice(-3);
}
// HISTORIQUE DES ENTREES OUVERTES, comme celui d'un navigateur. Fonctions
// pures sur {ids, pos} : testables sans DOM (tests/history.test.js).
const ENTRY_HISTORY_MAX = 50;
function entryHistoryPush(h, id) {
if (h.ids[h.pos] === id) return; // meme entree : pas de doublon
h.ids = h.ids.slice(0, h.pos + 1); // revenir puis ouvrir ailleurs oublie les suivants
h.ids.push(id);
if (h.ids.length > ENTRY_HISTORY_MAX) h.ids.shift();
h.pos = h.ids.length - 1;
}
// Indice de la prochaine entree qui existe encore dans le sens dir (-1 / +1),
// ou -1. Ne bouge rien : l'ouverture peut etre refusee (modifications non
// enregistrees), la position ne change qu'une fois l'entree vraiment ouverte.
function entryHistoryTarget(h, dir, exists) {
let p = h.pos + dir;
while (p >= 0 && p < h.ids.length && !exists(h.ids[p])) p += dir;
return (p >= 0 && p < h.ids.length) ? p : -1;
}
async function entryHistoryGo(dir) {
const h = state.entryHistory;
const exists = id => state.entries.some(x => x.id === id);
// Panneau ferme : Precedent rouvre d'abord l'entree qu'on vient de quitter.
let p = (dir < 0 && !$('#slideover').classList.contains('is-open') &&
exists(h.ids[h.pos])) ? h.pos : entryHistoryTarget(h, dir, exists);
if (p < 0) return;
if (await openSlideOver(h.ids[p], { fromHistory: true }) === false) return;
h.pos = p;
renderEntryHistoryNav();
}
function renderEntryHistoryNav() {
const h = state.entryHistory;
const exists = id => state.entries.some(x => x.id === id);
const back = $('#slideoverBack'), fwd = $('#slideoverFwd');
if (back) back.disabled = entryHistoryTarget(h, -1, exists) < 0;
if (fwd) fwd.disabled = entryHistoryTarget(h, +1, exists) < 0;
}
// Open the slideover for either an existing entry (id = number) or a
// brand-new one (id = null). opts: { presetTitle, presetSite, template }
// pre-fill the corresponding fields for the new-entry path.
@@ -4054,6 +4154,8 @@ async function openSlideOver(id, opts) {
if (!ok) return false; // caller must not act on a panel that didn't switch
}
if (!isNew && !e) return false;
if (!isNew && !opts.fromHistory) entryHistoryPush(state.entryHistory, id);
renderEntryHistoryNav();
// The editor must be visible when it opens: Settings sits ABOVE the
// slideover, so a hotkey (Ctrl+Shift+A) fired with Settings open left
@@ -4154,6 +4256,7 @@ async function openSlideOver(id, opts) {
// injected by the template picker on new. Sent back to the server
// on Save so the card/table can label it correctly.
template: isNew ? (opts.presetTemplate || '') : (e.template || ''),
originalTemplate: isNew ? '' : (e.template || ''),
// Attachments staged BEFORE the entry exists server-side. Empty on
// edit (existing attachments are fetched live via the dedicated
// /entries/:id/attachments endpoint). Flushed by soSave after the
@@ -4184,6 +4287,7 @@ async function openSlideOver(id, opts) {
// Notes: minimal layout — name + multiline body + folder + tags.
// No icon (covered by sidebar icon), no site/user/totp.
body.appendChild(soEditableField('Title', 'soTitle', soState.original.title));
body.appendChild(soCommandsField());
body.appendChild(soNoteBodyField(plain));
const hist = soHistoryButton();
if (hist) body.appendChild(hist);
@@ -4560,6 +4664,67 @@ function buildCustomFieldRow(field, idx, rerender) {
// Multiline note body. Maps to soState.original.password and the
// encrypted_password+iv columns (same crypto pipeline as login passwords).
// Liste des commandes d'une note : visible quand le modele est "commands",
// suit le texte au fil de la frappe. Une case bascule le modele (sauvegarde =
// enregistrement de l'entree). Copier seulement : ici la cible d'un
// remplissage serait l'application elle-meme - le remplissage passe par la
// recherche rapide (Ctrl+Shift+Q), qui connait la fenetre d'origine.
function soCommandsField() {
const wrap = el('div', { class: 'slideover-field so-cmds' });
const lbl = el('label', { class: 'so-cmds-toggle' });
const cb = el('input', { type: 'checkbox' });
cb.checked = soState.template === 'commands';
lbl.appendChild(cb);
lbl.appendChild(document.createTextNode(' Command list (# Group, one command per line)'));
wrap.appendChild(lbl);
const list = el('div', { class: 'so-cmds-list' });
wrap.appendChild(list);
const draw = () => {
list.innerHTML = '';
list.hidden = soState.template !== 'commands';
if (list.hidden) return;
const ta = document.getElementById('soNoteBody');
const cmds = parseCommandNote(ta ? ta.value : '');
if (!cmds.length) {
list.appendChild(el('div', { class: 'so-cmds-empty' }, 'No command yet - type them below.'));
return;
}
let last = null;
cmds.forEach(c => {
if (c.group !== last) {
last = c.group;
if (c.group) list.appendChild(el('div', { class: 'so-cmds-group' }, c.group));
}
const row = el('div', { class: 'so-cmds-row' });
row.appendChild(el('code', { class: 'so-cmds-cmd' },
state.maskNotes ? maskCommand(c.cmd) : c.cmd));
const b = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Copy command' });
b.appendChild(icon('i-copy'));
b.addEventListener('click', async ev => {
ev.stopPropagation();
if (Bridge.active) Bridge.copySecure(c.cmd, 30000);
else { try { await navigator.clipboard.writeText(c.cmd); } catch (_) {} }
toast('Command copied');
});
row.appendChild(b);
list.appendChild(row);
});
};
cb.addEventListener('change', ev => {
ev.stopPropagation();
soState.template = cb.checked ? 'commands' : '';
draw();
soDirtyCheck();
});
// Le textarea est ajoute juste apres : on s'y branche au tick suivant.
setTimeout(() => {
const ta = document.getElementById('soNoteBody');
if (ta) ta.addEventListener('input', draw);
draw();
}, 0);
return wrap;
}
function soNoteBodyField(value) {
const wrap = el('div', { class: 'slideover-field' });
const lbl = el('div', { class: 'slideover-field-label so-note-label' }, 'Note');
@@ -4582,6 +4747,8 @@ function soNoteBodyField(value) {
state.maskNotes = !state.maskNotes;
localStorage.setItem('maskNotes', state.maskNotes ? '1' : '0');
ta.classList.toggle('is-masked', state.maskNotes);
// La liste des commandes suit l'oeil (elle ecoute 'input').
ta.dispatchEvent(new Event('input'));
saveServerSettings();
});
wrap.appendChild(ta);
@@ -4839,9 +5006,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' });
scan.appendChild(icon('i-qr'));
scan.addEventListener('click', () => {
Bridge.onScreenQr = (text) => {
Bridge.onScreenQr = (text, found) => {
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;
updateTotpDisplay();
soDirtyCheck();
@@ -5036,6 +5207,9 @@ function isSoDirty() {
tags: soState.tags.join(','),
};
const curCustom = JSON.stringify(soState.customFields || []);
// La case "Command list" change le modele sans toucher au texte.
if (soState.id != null && (soState.template || '') !== (soState.originalTemplate || ''))
return true;
if (soState.id == null) {
const orig = soState.original || {};
const staged = (soState.pendingAttachments || []).length > 0;
@@ -7006,7 +7180,7 @@ function resetAutoLock() {
autoLockTimer = setTimeout(() => {
hideIdleWarning();
toast('Auto-locked due to inactivity', 'warning');
lockVault();
lockVault('inactivity (' + state.autoLock + ' min)');
}, totalMs);
}
@@ -7181,13 +7355,18 @@ async function enterApp() {
if (!ok) {
const fresh = await bridgePinStatus();
state.pinConfigured = fresh;
lockVault();
lockVault('wrong PIN after the master password');
$('#authHint').textContent = fresh
? 'Wrong PIN. Try again.'
: 'Too many wrong PIN attempts — PIN has been removed. Re-set it from Settings after unlocking.';
return;
}
}
// OUVERT, QUEL QUE SOIT LE CHEMIN. Seul doUnlock remettait ce drapeau : un
// verrou Windows avant toute connexion (demarrage dans le tray) le posait,
// la connexion complete ne le levait pas, et l'autofill refusait un coffre
// affiche ouvert.
state.locked = false;
$('#authScreen').classList.add('is-hidden');
$('#appShell').classList.remove('is-hidden');
$('#userName').textContent = state.username;
@@ -7576,6 +7755,13 @@ async function init() {
if (mod && /^[uUjJhHsSpPtTnNrR]$/.test(e.key)) return e.preventDefault(); // View source / Downloads / History / Save / Print / New tab+win / Reload
if (mod && e.shiftKey && /^[nNwW]$/.test(e.key)) return e.preventDefault(); // New incognito / Close window
if (mod && e.shiftKey && e.key === 'Delete') return e.preventDefault(); // Clear browsing data
// Alt+fleches : Precedent / Suivant parmi les entrees ouvertes. Le
// preventDefault empeche aussi la page elle-meme de "reculer".
if (e.altKey && !mod && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
e.preventDefault();
if (!state.locked && state.cryptoKey && !document.querySelector('.modal:not(.is-hidden)'))
entryHistoryGo(e.key === 'ArrowLeft' ? -1 : +1);
}
});
document.addEventListener('keydown', handleCardCursorKey);
@@ -7781,6 +7967,8 @@ async function init() {
// Slide-over
$('#slideoverClose').addEventListener('click', () => requestCloseSlideOver());
$('#slideoverBack').addEventListener('click', () => entryHistoryGo(-1));
$('#slideoverFwd').addEventListener('click', () => entryHistoryGo(+1));
// Click outside the slide-over closes it. Clicks on cards re-open it for
// another entry (so we don't close in that case; the card's own handler
// will switch state.selectedId).
@@ -8026,6 +8214,9 @@ async function init() {
if (e.key !== 'Escape') return;
if (!$('#settingsPanel').classList.contains('is-open')) return;
if (document.querySelector('.modal:not(.is-hidden)')) return;
// Capture de raccourci armee : Esc l'annule (son propre ecouteur,
// enregistre APRES celui-ci), il ne ferme pas les reglages.
if (document.querySelector('[data-capturing="1"]')) return;
// Busy overlay up (sync/backup running) — don't close under it.
if ($('#busyOverlay') && !$('#busyOverlay').classList.contains('is-hidden')) return;
// When the search box has focus AND a query, the input's own
@@ -8373,7 +8564,7 @@ async function init() {
btn.dataset.capturing = '1';
btn.classList.add('is-capturing');
const original = btn.textContent;
btn.textContent = 'Press combo… (Esc to cancel)';
btn.textContent = 'Press combo… (Esc cancel · Del off)';
// Free our own combos so they reach this listener instead of
// firing their hotkey. Restored by finish() / the success path.
hotkeyPushSilent = true;
@@ -8428,6 +8619,20 @@ async function init() {
return;
}
if (captured === 'cancel') { finish(true); return; }
// Cle vide = vk 0 : Delphi n'enregistre pas ce raccourci.
if (captured === 'off') {
const off = { ctrl: false, shift: false, alt: false, win: false, key: '' };
if (kind === 'full') state.autofillHotkeyFull = off;
else if (kind === 'password') state.autofillHotkeyPwd = off;
else if (kind === 'totp') state.autofillHotkeyTotp = off;
else if (kind === 'quickSearch') state.quickSearchHotkey = off;
else /* newEntry */ state.newEntryHotkey = off;
btn.textContent = autofillComboLabel(off);
finish(false);
autofillPushHotkeys();
saveServerSettings();
return;
}
if (captured === 'invalid'){
toast('Unsupported key — use a letter, digit or F-key', 'warning');
finish(true);
@@ -8736,7 +8941,11 @@ async function init() {
// document-level handlers, which now see "no modal open"
// (we just closed it) and also close the dirty slideover.
e.stopPropagation();
if (qsCommands) { qsLeaveCommands(); return; } // liste -> entrees
closeQuickSearchModal();
} else if (e.key === 'Backspace' && qsCommands && !qsInput.value) {
e.preventDefault();
qsLeaveCommands(); // retour, comme Esc
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (rows.length) { quickSearchSelected++; quickSearchRender(); }
@@ -8747,6 +8956,11 @@ async function init() {
e.preventDefault();
const sel = rows[quickSearchSelected];
if (!sel) return;
if (qsCommands) {
qsPickCommand(qsCommands[parseInt(sel.dataset.cmd, 10)].cmd,
e.ctrlKey || e.metaKey);
return;
}
const id = parseInt(sel.dataset.id, 10);
const entry = state.entries.find(x => x.id === id);
// Shift+Enter → full (user + Tab + password), Ctrl+Enter →
+201 -7
View File
@@ -27,6 +27,135 @@ let quickSearchHideAfter = false;
// HWND and is waiting for cmd://autofill/execute. On pick we SendInput
// the password instead of copying to the clipboard.
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;
// NOTE DE COMMANDES ouverte dans la recherche rapide : la liste remplace les
// entrees, la saisie la filtre, Esc revient aux entrees. null = liste normale.
let qsCommands = null;
async function qsEnterCommands(entry) {
const body = await decryptPwd(entry.encrypted_password, entry.iv);
if (body === '[ERROR]') { toast('Decryption error', 'error'); return; }
qsCommands = parseCommandNote(body);
const input = document.getElementById('quickSearchInput');
input.value = '';
input.placeholder = entryDisplayName(entry) + ' - filter commands… (Esc = back)';
quickSearchSelected = 0;
qsApplyMode();
quickSearchRender();
input.focus();
}
function qsLeaveCommands() {
qsCommands = null;
const input = document.getElementById('quickSearchInput');
input.value = '';
input.placeholder = 'Search…';
quickSearchSelected = 0;
qsApplyMode();
quickSearchRender();
}
// Une commande, une ligne, SANS Entree : l'utilisateur valide lui-meme. Ni
// Ctrl+A ni Suppr avant (noClear) - dans un terminal Ctrl+A ramene en debut de
// ligne, ce ne serait pas "tout selectionner".
async function qsPickCommand(cmd, forceCopy) {
if (qsFills() && !forceCopy) {
autofillPendingToast = 'Command sent';
if (Bridge.active) Bridge.executeAutofill('', cmd, quickSearchHideAfter, undefined, true);
quickSearchFillMode = false;
quickSearchHideAfter = false;
qsCommands = null;
closeQuickSearchModal();
return;
}
if (Bridge.active) Bridge.copySecure(cmd, 30000);
else { try { await navigator.clipboard.writeText(cmd); } catch (_) {} }
toast('Command copied');
qsCommands = null;
closeQuickSearchModal(true);
}
function qsRenderCommands(box, q) {
const ql = q.toLowerCase();
const list = qsCommands
.map((c, i) => ({ c, i }))
.filter(x => !ql || x.c.cmd.toLowerCase().includes(ql) || x.c.group.toLowerCase().includes(ql));
if (!list.length) {
box.appendChild(el('div', { class: 'quick-search-empty' },
q ? 'No command matches "' + q + '"' : 'No command in this note'));
quickSearchSelected = 0;
return;
}
if (quickSearchSelected >= list.length) quickSearchSelected = 0;
if (quickSearchSelected < 0) quickSearchSelected = list.length - 1;
list.forEach((x, n) => {
const row = el('div', {
class: 'quick-search-row' + (n === quickSearchSelected ? ' is-selected' : ''),
'data-cmd': String(x.i),
});
const main = el('div', { class: 'quick-search-main' });
main.appendChild(el('div', { class: 'quick-search-name qs-cmd' },
state.maskNotes ? maskCommand(x.c.cmd) : x.c.cmd));
if (x.c.group) main.appendChild(el('div', { class: 'quick-search-sub' }, x.c.group));
row.appendChild(main);
const acts = el('div', { class: 'quick-search-acts' });
const b = el('button', { class: 'icon-btn icon-btn-sm', type: 'button',
title: qsFills() ? 'Copy instead of typing' : 'Copy command' });
b.appendChild(icon('i-copy'));
b.addEventListener('click', ev => { ev.stopPropagation(); qsPickCommand(x.c.cmd, true); });
acts.appendChild(b);
row.appendChild(acts);
row.addEventListener('click', ev => qsPickCommand(x.c.cmd, ev.ctrlKey || ev.metaKey));
box.appendChild(row);
});
}
/// 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 back = document.getElementById('qsBack');
if (back) back.hidden = !qsCommands;
const ico = document.getElementById('qsSearchIcon');
if (ico) ico.style.display = qsCommands ? 'none' : '';
const hintEl = document.querySelector('#quickSearchModal .quick-search-hint');
if (hintEl && qsCommands) {
hintEl.textContent = qsFills()
? 'Enter / click = type the command (no Enter sent) · Ctrl+Enter / Ctrl+click = copy · Esc = back'
: 'Enter / click = copy the command · Esc = back';
} else 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 => {
if (ev.target.closest && ev.target.closest('#qsBack')) {
ev.stopPropagation();
qsLeaveCommands();
document.getElementById('quickSearchInput').focus();
return;
}
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) {
if (!q) return 1; // empty query → all entries pass, ordering preserved
@@ -47,6 +176,12 @@ function quickSearchScoreEntry(e, q) {
function quickSearchRender() {
const q = document.getElementById('quickSearchInput').value.trim();
if (qsCommands) {
const box = document.getElementById('quickSearchResults');
box.innerHTML = '';
qsRenderCommands(box, q);
return;
}
const list = state.entries
.map(e => ({ e, s: quickSearchScoreEntry(e, q) }))
.filter(x => x.s > 0)
@@ -88,6 +223,26 @@ function quickSearchRender() {
main.appendChild(el('div', { class: 'quick-search-sub' }, e.username));
row.appendChild(avatar);
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.template === 'commands')
act('i-note', 'Open the command list', 'cmds');
else 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
// (step-2 forms / unlock screens).
row.addEventListener('click', ev =>
@@ -106,12 +261,47 @@ function quickSearchRender() {
// mode: 'full' (user + Tab + password), 'user' (username only) or 'pwd'
// (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.
// '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) {
mode = mode || 'full';
// Note de commandes : on choisit UNE commande, jamais la note entiere.
if (entry.template === 'commands' && mode !== 'user') return qsEnterCommands(entry);
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
// Delphi saved when the hotkey fired. No clipboard touch.
if (quickSearchFillMode) {
if (qsFills()) {
if (mode === 'user') {
const u = entry.username || '';
if (!u) { toast('No username on this entry', 'warning'); return; }
@@ -475,16 +665,20 @@ function openQuickSearchModal(hideAfter, forFill) {
const modal = document.getElementById('quickSearchModal');
const input = document.getElementById('quickSearchInput');
modal.classList.remove('is-hidden');
if (qsCommands) qsLeaveCommands();
input.value = '';
quickSearchSelected = 0;
quickSearchHideAfter = !!hideAfter;
quickSearchFillMode = !!forFill;
// Subtle hint to the user about what Enter will do.
const hintEl = modal.querySelector('.quick-search-hint');
if (hintEl) {
hintEl.textContent = forFill
? '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';
qsApplyMode();
// La preference arrive de Delphi : on affiche tout de suite, et on
// rectifie des qu'elle est la (une fraction de seconde).
if (forFill && Bridge.active) {
Bridge.getPref('qsCopy').then(v => {
quickSearchPreferCopy = v === '1';
qsApplyMode();
quickSearchRender();
}).catch(() => {});
}
quickSearchRender();
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.
// 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
// 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
// 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) {
u = (u || '').replace(/\s+/g, '');
if (!u || syncIsGitea(u)) return u;
const g = u.match(/^https?:\/\/(www\.)?github\.com\/([^\/]+)\/([^\/]+?)(?:\.git)?(?:\/blob\/[^\/]+\/(.+?))?\/?$/i);
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);
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
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
@@ -839,7 +846,11 @@ async function runSyncNow(_attempt) {
// 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.
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)
};
if (remoteEtag) env.sha = remoteEtag;
+71
View File
@@ -0,0 +1,71 @@
// Decoupe d'une note de commandes (js/app.js parseCommandNote).
const test = require('node:test');
const assert = require('node:assert/strict');
const { loadApp } = require('./harness.js');
const T = loadApp().__test;
const plain = (x) => JSON.parse(JSON.stringify(x));
test('groupes "# titre", une commande par ligne, lignes vides ignorees', () => {
const r = T.parseCommandNote('# Prod\r\nssh a@1\n\n ssh a@2 \n# Base\npsql -h x\n');
assert.deepEqual(plain(r), [
{ group: 'Prod', cmd: 'ssh a@1' },
{ group: 'Prod', cmd: 'ssh a@2' },
{ group: 'Base', cmd: 'psql -h x' },
]);
});
test('commandes avant tout titre : groupe vide', () => {
assert.equal(T.parseCommandNote('ls -la\n# G\npwd')[0].group, '');
});
test('"## titre" et "#" seul', () => {
const r = T.parseCommandNote('## Deux\na\n#\nb');
assert.equal(r[0].group, 'Deux');
assert.equal(r[1].group, '');
});
test('masque : premier mot, milieu masque, 3 derniers caracteres', () => {
assert.equal(T.maskCommand('ssh admin@10.0.0.11'), 'ssh ' + '•'.repeat(12) + '.11');
assert.equal(T.maskCommand('psql -h 10.0.0.20'), 'psql ' + '•'.repeat(9) + '.20');
});
test('masque : fin courte masquee en entier, pas d\'espace', () => {
assert.equal(T.maskCommand('ls -la'), 'ls •••');
assert.equal(T.maskCommand('hostname'), '•••••ame');
assert.equal(T.maskCommand(''), '');
});
test('lignes continuees (\\, `, ^) : une seule commande, sur une ligne', () => {
const note = [
'# D',
'docker run -d \\',
' -p 8080:80 \\',
' nginx',
'Get-Item a `',
' -Force',
'dir ^',
' /s',
].join('\n');
const r = T.parseCommandNote(note);
assert.deepEqual(plain(r).map(x => x.cmd), [
'docker run -d -p 8080:80 nginx',
'Get-Item a -Force',
'dir /s',
]);
assert.equal(r[0].group, 'D');
});
test('continuation coupee par une ligne vide ou la fin du texte', () => {
assert.deepEqual(plain(T.parseCommandNote('a \\\n\nb')).map(x => x.cmd), ['a', 'b']);
assert.deepEqual(plain(T.parseCommandNote('a \\')).map(x => x.cmd), ['a']);
});
test('un "#" apres une continuation fait partie de la commande', () => {
assert.equal(T.parseCommandNote('echo \\\n#x')[0].cmd, 'echo #x');
});
test('texte vide ou absent', () => {
assert.equal(T.parseCommandNote('').length, 0);
assert.equal(T.parseCommandNote(null).length, 0);
});
+4
View File
@@ -156,6 +156,10 @@ function loadApp(overrides = {}) {
syncIsGitea, giteaUnwrap, syncSnapshotsEqual,
// metadata-at-rest
withEncryptedMeta, decryptEntryMeta, ENCRYPTED_META_FIELDS,
// historique des entrees ouvertes
entryHistoryPush, entryHistoryTarget, ENTRY_HISTORY_MAX,
// note de commandes
parseCommandNote, maskCommand,
};
`;
+46
View File
@@ -0,0 +1,46 @@
// Historique Precedent / Suivant des entrees ouvertes (js/app.js).
const test = require('node:test');
const assert = require('node:assert/strict');
const { loadApp } = require('./harness.js');
const T = loadApp().__test;
const fresh = () => ({ ids: [], pos: -1 });
const all = () => true;
test('ouvrir A puis B : Precedent vise A, Suivant rien', () => {
const h = fresh();
T.entryHistoryPush(h, 1); T.entryHistoryPush(h, 2);
assert.deepEqual(h.ids, [1, 2]);
assert.equal(h.ids[T.entryHistoryTarget(h, -1, all)], 1);
assert.equal(T.entryHistoryTarget(h, +1, all), -1);
});
test('meme entree deux fois : pas de doublon', () => {
const h = fresh();
T.entryHistoryPush(h, 1); T.entryHistoryPush(h, 1);
assert.deepEqual(h.ids, [1]);
});
test('revenir puis ouvrir ailleurs oublie les suivants', () => {
const h = fresh();
[1, 2, 3].forEach(id => T.entryHistoryPush(h, id));
h.pos = 0; // revenu sur 1
T.entryHistoryPush(h, 4);
assert.deepEqual(h.ids, [1, 4]);
assert.equal(h.pos, 1);
});
test('une entree supprimee est sautee', () => {
const h = fresh();
[1, 2, 3].forEach(id => T.entryHistoryPush(h, id));
const exists = id => id !== 2;
assert.equal(h.ids[T.entryHistoryTarget(h, -1, exists)], 1);
assert.equal(T.entryHistoryTarget(h, -1, id => id === 3), -1);
});
test('historique borne', () => {
const h = fresh();
for (let i = 0; i < T.ENTRY_HISTORY_MAX + 10; i++) T.entryHistoryPush(h, i);
assert.equal(h.ids.length, T.ENTRY_HISTORY_MAX);
assert.equal(h.pos, T.ENTRY_HISTORY_MAX - 1);
});
+313 -23
View File
@@ -48,6 +48,7 @@ uses
FMX.Controls.Presentation, FMX.StdCtrls, FMX.Memo, FMX.Memo.Types,
FMX.ScrollBox, FMX.Layouts, FMX.Edit, FMX.ListBox, FMX.Objects,
FMX.WebBrowser, System.Messaging, FMX.Media,
FMX.MediaLibrary, FMX.MediaLibrary.Actions,
PMA.Vault;
const
@@ -340,6 +341,9 @@ type
/// Change UN champ de l'entree ouverte. Le nom du champ vient de la page,
/// et n'est donc pas cru sur parole.
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,
/// comme 'setfield' - meme regle, meme journalisation muette.
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).
var
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;
FScanLay: TLayout;
FScanImg: TImage;
@@ -401,6 +411,12 @@ type
function PwdName: string;
/// Va chercher l'icone du site de l'entree ouverte, ou l'efface.
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.
///
/// Ils ne l'etaient pas. FRemoteAhead etait remis a faux a trois endroits -
@@ -494,7 +510,10 @@ begin
// le filet.
FDeadline := TTimer.Create(Self);
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;
FTotpTimer := TTimer.Create(Self);
@@ -707,6 +726,15 @@ begin
FWebView := 'list';
Key := 0;
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
begin
WebCall('PM.back()');
@@ -1036,6 +1064,10 @@ end;
/// TIRER, sinon il ecraserait ce qu'un autre appareil a publie entre-temps.
///
/// 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;
const APatch: TFunc<string, string>);
begin
@@ -1247,13 +1279,53 @@ begin
end;
LUuid := FCurrent.Uuid;
LTitle := FCurrent.Display;
CommitChange('Saved: ' + LTitle, AField + ' change from phone', LUuid,
CommitChange('Saved: ' + LTitle, 'update from phone', LUuid,
function (APlain: string): string
begin
Result := PatchEntryField(APlain, LUuid, AField, ANewValue);
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.
///
/// 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
// ne manque que notre publication.
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
begin
LErr := Format('read failed (HTTP %d) %s', [LGet.HttpCode, LGet.Error]);
@@ -1329,7 +1407,7 @@ begin
end;
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
begin
FETag := LPut.ETag;
@@ -1469,7 +1547,7 @@ begin
// quelques secondes, et il n'arrive qu'une fois.
if FKey = nil then LBody := EncryptContainer(LMerged, FSyncPwd)
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
begin
FETag := LPut.ETag;
@@ -1574,7 +1652,7 @@ begin
// Pas de rang a rouvrir : les entrees n'existeront plus. AdoptVault ramene la
// liste, ce qui est exactement ou l'on veut se retrouver.
CommitChange(Format('Deleted %d', [Length(LUuids)]),
'entries deleted from phone', '',
'update from phone', '',
function (APlain: string): string
var
I: Integer;
@@ -1665,7 +1743,7 @@ begin
Exit;
end;
CommitChange(Format('Duplicated %d', [Length(LUuids)]),
'entries duplicated from phone', '',
'update from phone', '',
function (APlain: string): string
var
I: Integer;
@@ -1686,7 +1764,7 @@ begin
RefuseChange('Give it at least a name');
Exit;
end;
CommitChange('Added: ' + ATitle, 'new entry from phone', '',
CommitChange('Added: ' + ATitle, 'update from phone', '',
function (APlain: string): string
begin
Result := AddEntry(APlain, ATitle, ASite, AUser, APwd, AKind, AFolder,
@@ -1950,7 +2028,10 @@ begin
end
else if LAction = 'icon' then
SetEntryIcon(Param('a') = 'fetch')
begin
if Param('a') = 'pick' then PickEntryIcon
else SetEntryIcon(Param('a') = 'fetch');
end
{$IFDEF DEBUG}
// 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
// le lien a une recherche web ou demandait avec quoi l'ouvrir.
else if LAction = 'scanqr' then
StartScan
begin
FScanTotp := Param('for') = 'totp';
StartScan;
end
else if LAction = 'pinclear' then
ClearUnlockCode(TNetEncoding.URL.Decode(Param('c')))
@@ -2002,6 +2086,21 @@ begin
SaveEntryField(TNetEncoding.URL.Decode(Param('f')),
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.
else if LAction = 'kbd' then
ShowSoftKeyboard
@@ -2025,6 +2124,14 @@ begin
if DetailRowAt(FCurrent, StrToIntDef(Param('k'), -1), LCaption, LValue) then
CopyToClipboard(LValue, LCaption);
end
// Une commande d'une note de commandes, par son rang dans la liste.
else if Param('f') = 'cmd' then
begin
var LCmds := ParseCommandNote(FCurrent.Password);
var LK := StrToIntDef(Param('k'), -1);
if (LK >= 0) and (LK <= High(LCmds)) then
CopyToClipboard(LCmds[LK].Cmd, 'Command');
end
else
begin
LIdx := StrToIntDef(Param('i'), -1);
@@ -2478,8 +2585,12 @@ begin
try
LSw := TStopwatch.StartNew;
AdoptVault(LPlain);
if FUiKbdOnSearch then
WebCall('PM.focusSearch()'); // on ouvre pour chercher : clavier pret
// ON OUVRE POUR CHERCHER - sauf quand l'ouverture n'est qu'une etape.
// 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');
// Afficher d'abord, synchroniser ensuite : le coffre est a l'ecran, le
// reseau se debrouille derriere. Sans mot de passe range, on ne peut
@@ -2497,8 +2608,15 @@ begin
[MinutesBetween(Now, FLastRefresh), REFRESH_MIN_MINUTES]))
else
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;
FetchAndDecrypt(FUrl, FToken, FSyncPwd, True);
TThread.ForceQueue(nil, procedure begin ProbeRemote; end);
end;
except
on E: Exception do
@@ -2907,6 +3025,30 @@ begin
begin
// Le serveur repond pareil dans les deux cas, par conception.
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
begin
// TRANCHER L'AMBIGUITE : fichier absent, depot ou utilisateur
@@ -3342,7 +3484,7 @@ begin
if not AFetch then
begin
CommitChange('Icon removed: ' + LTitle, 'icon removed from phone', LUuid,
CommitChange('Icon removed: ' + LTitle, 'update from phone', LUuid,
function (APlain: string): string
begin
Result := PatchEntryIcon(APlain, LUuid, '', False);
@@ -3383,7 +3525,7 @@ begin
// Provenance a FAUX : elle vient d'une recherche automatique, pas
// d'un choix. Le poste ne remplace jamais une icone choisie lors
// 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
begin
Result := PatchEntryIcon(APlain, LUuid, LUri, False);
@@ -3392,6 +3534,100 @@ begin
end).Start;
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.
///
/// Le coffre doit etre ouvert : c'est la seule preuve que le code courant est
@@ -3566,7 +3802,10 @@ begin
except
// Pas d'autofocus continu : l'image restera nette a bonne distance.
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;
FScanWatch.Enabled := True;
Log('QR scan started');
@@ -3653,13 +3892,30 @@ begin
// CHERCHE, NE COMPARE PAS LE DEBUT : le texte decode est arrive
// avec un caractere invisible en tete (104 lus pour 103 emis),
// 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
StopScan;
ApplySyncLink(Trim(Copy(LText, Pos('pmserver://sync?', LText), MaxInt)));
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
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])));
end;
end);
end).Start;
end);
@@ -3773,10 +4029,26 @@ end;
procedure TForm8.ProbeRemote;
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.
if HasLocalContainer then Exit;
if FETag = '' then Exit;
if HasLocalContainer then
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(
procedure
@@ -3792,10 +4064,29 @@ begin
LGet := LClient.Fetch(FETag);
LAhead := (LGet.Status = ssOK) and (LGet.ETag <> '')
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
// Hors reseau : ce n'est pas une nouvelle du depot, c'est une absence
// de nouvelle. Le temoin ne change pas.
Exit;
on E: Exception do
begin
// 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;
finally
LClient.Free;
@@ -3805,7 +4096,6 @@ begin
procedure
begin
if FRemoteAhead then Exit; // deja dit
Log('remote has moved - sync to bring it in');
SetRemoteAhead(True);
end);
end).Start;