Compare commits

..

22 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
20 changed files with 1673 additions and 78 deletions
+97
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
@@ -303,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
@@ -1150,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)
@@ -1425,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
@@ -1543,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.
@@ -1558,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
+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.
+144 -11
View File
@@ -56,6 +56,11 @@ 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;
@@ -64,6 +69,7 @@ type
/// 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);
@@ -76,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
@@ -107,7 +120,7 @@ type
implementation
uses
System.JSON, System.NetEncoding;
System.JSON, System.NetEncoding, System.StrUtils;
function TSyncClient.IsGitea: Boolean;
begin
@@ -116,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;
@@ -126,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;
@@ -295,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
@@ -439,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
@@ -466,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
+71
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;
@@ -278,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;
+47
View File
@@ -416,6 +416,53 @@ gestionnaire ne s'exécutait pas (dont « Tap again to delete »). Une vraie
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>
+29 -3
View File
@@ -279,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);
@@ -528,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>
@@ -615,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>
@@ -1089,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>'
@@ -1170,6 +1192,7 @@ document.getElementById('fields').addEventListener('click', function (ev) {
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; }
});
@@ -1903,6 +1926,9 @@ 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] +
+34 -16
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 {
@@ -2285,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. */
@@ -2529,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;
@@ -2766,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;
@@ -3226,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;
+229 -4
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
@@ -1298,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;
@@ -1501,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();
@@ -1665,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
@@ -1755,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
@@ -1768,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
@@ -2068,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
@@ -2282,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,
@@ -2307,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.
@@ -2331,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
+70 -3
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,6 +364,12 @@ begin
FBridge.OnNewEntryHotkey := BridgeNewEntryHotkey;
FBridge.OnQuickSearchRequest := BridgeQuickSearchRequest;
FBridge.OnQuickSearchHotkey := BridgeQuickSearchHotkey;
// 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
@@ -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,6 +1211,7 @@ begin
// One notification for the whole batch, naming the combos that failed —
// empty string = everything registered.
LFailed := LFailed.TrimRight([',', ' ']);
if FBrowserInitialized then
WebBrowser.ExecuteJavaScript(
'if(window.Bridge&&Bridge.onAutofillHotkeysResult)' +
'Bridge.onAutofillHotkeysResult("' + LFailed.Replace('"', '') + '")');
@@ -1438,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
@@ -1725,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));
@@ -2000,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;
@@ -2018,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;
@@ -2228,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;
+11 -2
View File
@@ -371,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>
@@ -421,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>
@@ -734,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
@@ -762,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>
@@ -1227,7 +1234,9 @@
<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…"
autocomplete="off">
+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;
}
+219 -9
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);
@@ -5040,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;
@@ -7010,7 +7180,7 @@ function resetAutoLock() {
autoLockTimer = setTimeout(() => {
hideIdleWarning();
toast('Auto-locked due to inactivity', 'warning');
lockVault();
lockVault('inactivity (' + state.autoLock + ' min)');
}, totalMs);
}
@@ -7185,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;
@@ -7580,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);
@@ -7785,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).
@@ -8030,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
@@ -8377,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;
@@ -8432,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);
@@ -8740,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(); }
@@ -8751,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 →
+109 -2
View File
@@ -33,6 +33,88 @@ let quickSearchFillMode = false;
// 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; }
@@ -43,8 +125,16 @@ function qsApplyMode() {
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) {
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';
@@ -52,6 +142,12 @@ function qsApplyMode() {
}
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';
@@ -80,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)
@@ -134,7 +236,9 @@ function quickSearchRender() {
if (e.username) act('i-user', verb + 'username', 'user');
// UNE NOTE SE COPIE TOUJOURS, meme en Fill : tapee, chacun de ses
// retours a la ligne partirait comme un Entree dans le formulaire.
if (e.kind === 'note')
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');
@@ -162,6 +266,8 @@ function quickSearchRender() {
// 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);
@@ -559,6 +665,7 @@ 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;
+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);
});
+8
View File
@@ -2124,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);