Compare commits

..

15 Commits

Author SHA1 Message Date
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
17 changed files with 1041 additions and 58 deletions
+58
View File
@@ -198,6 +198,19 @@ Retour Delphi → JS via `WebBrowser.ExecuteJavaScript('Bridge.onX(...)')`.
Combos autofill configurables depuis Settings (synced en DB Combos autofill configurables depuis Settings (synced en DB
via `settings_json`). 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) ## Fill bar (remplir à la souris)
Menu du tray → **Fill bar** : une barrette de trois Menu du tray → **Fill bar** : une barrette de trois
@@ -1150,6 +1163,11 @@ QR que l'application Android scanne elle-meme.
- **Piege CSS rencontre** : `css/style.css` impose `svg { width: 16px }` a - **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 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>`. 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) ### GitHub, et refus d'un depot public (2026-09-18)
@@ -1543,6 +1561,32 @@ 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 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 place**, jamais via `rerender()` : reconstruire la liste fait flasher le
panneau et perd le focus + la hauteur auto du multiligne. 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.
- **Settings search** : `#settingsSearch` filtre les `.setting-row` par - **Settings search** : `#settingsSearch` filtre les `.setting-row` par
texte ; match sur le label de section garde toute la section. Esc dans texte ; match sur le label de section garde toute la section. Esc dans
la search avec query = clear ; Esc vide = ferme Settings. la search avec query = clear ; Esc vide = ferme Settings.
@@ -1558,6 +1602,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 résidus `-shm`/`-wal`). Kill brutal (Task Manager) laisse les résidus, mais
SQLite récupère au prochain open. 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) ## WebView2 nav race (écran noir cold-start)
Le timer de navigation (1.5 s) ne consomme `FPendingURL` que si 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*.
+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 Error: string; // message brut, pour le journal
end; 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 TSyncClient = class
private private
FBaseUrl: string; FBaseUrl: string;
@@ -64,6 +69,7 @@ type
/// Attente de la POIGNEE DE MAIN, distincte de celle de la reponse. /// Attente de la POIGNEE DE MAIN, distincte de celle de la reponse.
FConnectMs: Integer; FConnectMs: Integer;
FPrivacyChecked: Boolean; FPrivacyChecked: Boolean;
function PublicRepoWarning: string;
public public
constructor Create(const ABaseUrl, AToken: string); constructor Create(const ABaseUrl, AToken: string);
@@ -76,11 +82,18 @@ type
/// n'y ressemble pas est de toute facon inutilisable. /// n'y ressemble pas est de toute facon inutilisable.
function IsGitea: Boolean; 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 /// UN DEPOT PUBLIC EST REFUSE (Gitea comme GitHub) : chaque synchro est un
/// commit, et l'historique garde tous les instantanes, attaquables hors /// commit, et l'historique garde tous les instantanes, attaquables hors
/// ligne par quiconque peut les lire. Message a afficher, ou '' si le depot /// 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. /// 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 /// 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 /// aussi bien pour un fichier absent que pour un depot ou un proprietaire
@@ -107,7 +120,7 @@ type
implementation implementation
uses uses
System.JSON, System.NetEncoding; System.JSON, System.NetEncoding, System.StrUtils;
function TSyncClient.IsGitea: Boolean; function TSyncClient.IsGitea: Boolean;
begin begin
@@ -116,6 +129,81 @@ begin
FBaseUrl.ToLower.Contains('api.github.com/repos/'); FBaseUrl.ToLower.Contains('api.github.com/repos/');
end; 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 /// 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). /// (https://hote/api/v1/repos/O/R) et GitHub (https://api.github.com/repos/O/R).
function RepoApiUrl(const AContentsUrl: string): string; function RepoApiUrl(const AContentsUrl: string): string;
@@ -126,6 +214,49 @@ begin
if LP > 0 then Result := Copy(AContentsUrl, 1, LP - 1) else Result := ''; if LP > 0 then Result := Copy(AContentsUrl, 1, LP - 1) else Result := '';
end; 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; function TSyncClient.PublicRepoWarning: string;
var var
LHttp: THTTPClient; LHttp: THTTPClient;
@@ -295,18 +426,20 @@ begin
Result := Default(TSyncResult); Result := Default(TSyncResult);
Result.Status := ssNetworkError; 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 if (FBaseUrl = '') or (FToken = '') then
begin begin
Result.Error := 'URL ou jeton non configure'; Result.Error := 'URL ou jeton non configure';
Exit; Exit;
end; 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 if Result.Error <> '' then
begin begin
Result.Status := ssRefused; // HttpCode 0 : refus local, pas du serveur Result.Status := ssRefused; // HttpCode 0 : refus local, pas du serveur
@@ -439,7 +572,7 @@ begin
Result.Error := 'URL ou jeton non configure'; Result.Error := 'URL ou jeton non configure';
Exit; Exit;
end; end;
Result.Error := PublicRepoWarning; Result.Error := RefusalReason;
if Result.Error <> '' then if Result.Error <> '' then
begin begin
Result.Status := ssRefused; // HttpCode 0 : refus local, pas du serveur Result.Status := ssRefused; // HttpCode 0 : refus local, pas du serveur
@@ -466,7 +599,7 @@ begin
LHttp.CustomHeaders['Authorization'] := 'token ' + FToken LHttp.CustomHeaders['Authorization'] := 'token ' + FToken
else else
begin 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 // Pas sur Gitea : le sha n'est pas un ETag HTTP, et l'envoyer ferait
// repondre 200 avec un corps complet de toute facon. // repondre 200 avec un corps complet de toute facon.
if AKnownETag <> '' then if AKnownETag <> '' then
+31
View File
@@ -416,6 +416,37 @@ gestionnaire ne s'exécutait pas (dont « Tap again to delete »). Une vraie
détail reste dans le journal local. détail reste dans le journal local.
- **Nom par défaut `data.json`** au lieu de `vault-sync.json` — voir CLAUDE.md. - **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.
## Saisie : ce qui a résisté ## Saisie : ce qui a résisté
- **`user-select: none` du `body`** était hérité par les champs : le curseur ne - **`user-select: none` du `body`** était hérité par les champs : le curseur ne
@@ -1,26 +1,15 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- <!--
Le trafic en clair n'est permis que vers la BOUCLE LOCALE, et interdit Le clair est PERMIS ICI, et trie dans le code (PMA.SyncClient.IsLocalHost).
partout ailleurs.
Il l'etait auparavant partout, via usesCleartextTraffic="true". C'etait pour Chacun pointe vers SON serveur WebDAV, souvent en http:// sur son reseau
une seule raison : l'essai local passe par "adb reverse", qui fait voir le local, a une adresse qu'on ne connait pas a la compilation - et Android ne
poste comme 127.0.0.1 et parle HTTP. Mais l'attribut ne distingue pas la sait autoriser ni une plage ni un masque, seulement des noms un par un.
boucle locale du reste du monde - une URL mal saisie, ou un serveur repassant Avant tout envoi, la synchro refuse donc tout http:// qui ne vise pas le
en HTTP, envoyait le jeton en clair sur le reseau sans que rien ne le signale. 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
Ici la permission est nominative. Le jeton ne peut plus sortir qu'en TLS, et laisser passer en silence.
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.
--> -->
<network-security-config> <network-security-config>
<base-config cleartextTrafficPermitted="false" /> <base-config cleartextTrafficPermitted="true" />
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">127.0.0.1</domain>
<domain includeSubdomains="false">localhost</domain>
</domain-config>
</network-security-config> </network-security-config>
+6 -3
View File
@@ -528,7 +528,7 @@
champ. Une adresse d'API Gitea en fait un jeton d'acces personnel champ. Une adresse d'API Gitea en fait un jeton d'acces personnel
envoye en "token" ; l'endpoint PHP en fait un "Bearer". Le envoye en "token" ; l'endpoint PHP en fait un "Bearer". Le
nommer d'apres un seul des deux le rend faux pour l'autre. --> 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"> type="password" autocomplete="off">
<div class="in-x" data-clear="tok">&#215;</div> <div class="in-x" data-clear="tok">&#215;</div>
</div> </div>
@@ -615,13 +615,13 @@
entier. Un input d'une ligne ne defilait pas sous le doigt dans la 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. --> WebView de l'app, et la fin d'une URL d'API restait inatteignable. -->
<textarea class="field-in multi" id="sUrl" rows="1" <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" autocomplete="off" autocorrect="off" autocapitalize="none"
spellcheck="false" inputmode="url"></textarea> spellcheck="false" inputmode="url"></textarea>
<div class="in-x" data-clear="sUrl">&#215;</div> <div class="in-x" data-clear="sUrl">&#215;</div>
</div> </div>
<div class="in-wrap"> <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"> type="password" autocomplete="off">
<div class="in-x" data-clear="sTok">&#215;</div> <div class="in-x" data-clear="sTok">&#215;</div>
</div> </div>
@@ -1903,6 +1903,9 @@ function normalizeRepoUrl(u) {
u = u.replace(/\s+/g, ''); u = u.replace(/\s+/g, '');
if (!u || /\/api\/v1\/repos\//i.test(u) || /api\.github\.com\/repos\//i.test(u) || if (!u || /\/api\/v1\/repos\//i.test(u) || /api\.github\.com\/repos\//i.test(u) ||
/\.php(\?|$)/i.test(u)) return 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. // GITHUB : l'adresse du depot est sur github.com, l'API sur api.github.com.
var g = u.match(/^https?:\/\/(www\.)?github\.com\/([^\/]+)\/([^\/]+?)(?:\.git)?(?:\/blob\/[^\/]+\/(.+?))?\/?$/i); var g = u.match(/^https?:\/\/(www\.)?github\.com\/([^\/]+)\/([^\/]+?)(?:\.git)?(?:\/blob\/[^\/]+\/(.+?))?\/?$/i);
if (g) return 'https://api.github.com/repos/' + g[2] + '/' + g[3] + if (g) return 'https://api.github.com/repos/' + g[2] + '/' + g[3] +
+14
View File
@@ -1979,6 +1979,15 @@ input[type="range"]::-webkit-slider-thumb {
.so-note-body.is-masked, .so-note-body.is-masked,
.so-custom-multiline.is-masked { -webkit-text-security: disc; } .so-custom-multiline.is-masked { -webkit-text-security: disc; }
.so-note-label { display: flex; align-items: center; justify-content: space-between; } .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, monospace; font-size: 13px; }
/* ---- + New dropdown ---------------------------------- */ /* ---- + New dropdown ---------------------------------- */
.new-entry-wrap { .new-entry-wrap {
@@ -2285,6 +2294,11 @@ body[data-editor-position="center"]:has(#settingsPanel.is-open)::before {
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
} }
.slideover-body { padding: 20px; overflow-y: auto; flex: 1; } .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; } .slideover-field { margin-bottom: 16px; }
/* Settings panel search — sits below the header, above the body. Only /* Settings panel search — sits below the header, above the body. Only
present inside #settingsPanel. */ present inside #settingsPanel. */
+8
View File
@@ -109,6 +109,7 @@ type
// WinEvent hook that watches every foreground change in the session. // WinEvent hook that watches every foreground change in the session.
FForegroundHook: THandle; FForegroundHook: THandle;
FOnSystemLock: TProc; FOnSystemLock: TProc;
FOnSystemResume: TProc;
// Set TRUE the moment Windows tells us the session is ending // Set TRUE the moment Windows tells us the session is ending
// (WM_QUERYENDSESSION / WM_ENDSESSION). FormCloseQuery checks this // (WM_QUERYENDSESSION / WM_ENDSESSION). FormCloseQuery checks this
// to bypass the "minimize to tray" intercept so the form closes // to bypass the "minimize to tray" intercept so the form closes
@@ -296,6 +297,8 @@ type
property AutofillTotpActive: Boolean read FAutofillTotpActive; property AutofillTotpActive: Boolean read FAutofillTotpActive;
// Fired on main thread when Windows locks the session (WTS_SESSION_LOCK). // Fired on main thread when Windows locks the session (WTS_SESSION_LOCK).
property OnSystemLock: TProc read FOnSystemLock write FOnSystemLock; 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. // True once WM_QUERYENDSESSION (or WM_ENDSESSION) has been received.
// FormCloseQuery uses this to allow normal close during shutdown. // FormCloseQuery uses this to allow normal close during shutdown.
property ShutdownPending: Boolean read FShutdownPending; property ShutdownPending: Boolean read FShutdownPending;
@@ -406,6 +409,7 @@ const
const const
WM_WTSSESSION_CHANGE = $02B1; WM_WTSSESSION_CHANGE = $02B1;
WTS_SESSION_LOCK = 7; WTS_SESSION_LOCK = 7;
WTS_SESSION_UNLOCK = 8;
NOTIFY_FOR_THIS_SESSION = 0; NOTIFY_FOR_THIS_SESSION = 0;
// Power management broadcast — sent to all top-level windows when the // Power management broadcast — sent to all top-level windows when the
@@ -1755,6 +1759,8 @@ begin
begin begin
if AMsg.WParam = WTS_SESSION_LOCK then if AMsg.WParam = WTS_SESSION_LOCK then
if Assigned(FOnSystemLock) then FOnSystemLock(); if Assigned(FOnSystemLock) then FOnSystemLock();
if AMsg.WParam = WTS_SESSION_UNLOCK then
if Assigned(FOnSystemResume) then FOnSystemResume();
end end
else if AMsg.Msg = WM_POWERBROADCAST then else if AMsg.Msg = WM_POWERBROADCAST then
@@ -1768,6 +1774,8 @@ begin
// suspends — fast handler required (no UI prompts, no network). // suspends — fast handler required (no UI prompts, no network).
if AMsg.WParam = PBT_APMSUSPEND then if AMsg.WParam = PBT_APMSUSPEND then
if Assigned(FOnSystemLock) then FOnSystemLock(); if Assigned(FOnSystemLock) then FOnSystemLock();
if AMsg.WParam = PBT_APMRESUMEAUTOMATIC then
if Assigned(FOnSystemResume) then FOnSystemResume();
end end
else if (AMsg.Msg = WM_QUERYENDSESSION) or (AMsg.Msg = WM_ENDSESSION) then else if (AMsg.Msg = WM_QUERYENDSESSION) or (AMsg.Msg = WM_ENDSESSION) then
+70 -3
View File
@@ -98,6 +98,13 @@ type
FFileSaveChunks: TDictionary<string, TStringBuilder>; FFileSaveChunks: TDictionary<string, TStringBuilder>;
FPendingURL: string; FPendingURL: string;
FNavTimer: TTimer; 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 // Set once WebView2 fires OnInitialized. The nav timer only consumes
// FPendingURL when this is True — otherwise a timer tick that lands // FPendingURL when this is True — otherwise a timer tick that lands
// before the engine is ready would Navigate() into the void AND clear // before the engine is ready would Navigate() into the void AND clear
@@ -163,6 +170,7 @@ type
procedure UpdateButtons; procedure UpdateButtons;
procedure NavigateToVault; procedure NavigateToVault;
procedure NavTimerTick(Sender: TObject); procedure NavTimerTick(Sender: TObject);
procedure KeepWarmTick(Sender: TObject);
// JS↔Delphi bridge // JS↔Delphi bridge
procedure WebBrowserBeforeNavigate(Sender: TObject; procedure WebBrowserBeforeNavigate(Sender: TObject;
var Params: TTMSFNCCustomWebBrowserBeforeNavigateParams); var Params: TTMSFNCCustomWebBrowserBeforeNavigateParams);
@@ -287,7 +295,20 @@ begin
// USE_EDGE_BROWSER} directive can swap the concrete class without a // USE_EDGE_BROWSER} directive can swap the concrete class without a
// second .fmx variant. Align Client to fill the area below PanelTop // second .fmx variant. Align Client to fill the area below PanelTop
// and above the (collapsible) PanelLog + Splitter. // 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 := TWebBrowserClass.Create(Self);
WebBrowser.AutoClearCache := False;
// Wire events BEFORE Parent assignment: setting Parent triggers the // Wire events BEFORE Parent assignment: setting Parent triggers the
// TMS browser's async WebView2 init, which fires OnInitialized when // TMS browser's async WebView2 init, which fires OnInitialized when
// Edge Chromium is ready. If we assigned the handlers afterwards we'd // Edge Chromium is ready. If we assigned the handlers afterwards we'd
@@ -313,6 +334,14 @@ begin
FBridge := TPMBridge.Create(Self); FBridge := TPMBridge.Create(Self);
FFileSaveChunks := TDictionary<string, TStringBuilder>.Create; FFileSaveChunks := TDictionary<string, TStringBuilder>.Create;
FBridge.OnSystemLock := BridgeSystemLock; 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; FBridge.OnTrayRestore := BridgeTrayRestore;
// Changement fait sur la barrette elle-meme : l'onglet se relit (fillbar/get). // Changement fait sur la barrette elle-meme : l'onglet se relit (fillbar/get).
FBridge.OnFillBarChanged := FBridge.OnFillBarChanged :=
@@ -335,6 +364,12 @@ begin
FBridge.OnNewEntryHotkey := BridgeNewEntryHotkey; FBridge.OnNewEntryHotkey := BridgeNewEntryHotkey;
FBridge.OnQuickSearchRequest := BridgeQuickSearchRequest; FBridge.OnQuickSearchRequest := BridgeQuickSearchRequest;
FBridge.OnQuickSearchHotkey := BridgeQuickSearchHotkey; 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.RegisterAutofillHotkey; // Ctrl+Shift+L active from startup
FBridge.ApplyTitleBarTheme(True); // dark by default, JS may toggle later FBridge.ApplyTitleBarTheme(True); // dark by default, JS may toggle later
// Apres le theme et apres OnTrayRestore : la barrette se peint elle-meme, et // Apres le theme et apres OnTrayRestore : la barrette se peint elle-meme, et
@@ -351,6 +386,10 @@ begin
FNavTimer.Enabled := False; FNavTimer.Enabled := False;
FNavTimer.OnTimer := NavTimerTick; FNavTimer.OnTimer := NavTimerTick;
FKeepWarmTimer := TTimer.Create(Self);
FKeepWarmTimer.Interval := 5 * 60 * 1000;
FKeepWarmTimer.OnTimer := KeepWarmTick;
UpdateButtons; UpdateButtons;
LogLine('Password Manager - d backend ready.'); LogLine('Password Manager - d backend ready.');
LogLine('Click Start to launch server + embedded web vault.'); LogLine('Click Start to launch server + embedded web vault.');
@@ -805,6 +844,12 @@ begin
FNavTimer.Enabled := True; FNavTimer.Enabled := True;
end; end;
procedure TMainForm.KeepWarmTick(Sender: TObject);
begin
if FBrowserInitialized then
WebBrowser.ExecuteJavaScript('window.Bridge&&Bridge.active');
end;
procedure TMainForm.NavTimerTick(Sender: TObject); procedure TMainForm.NavTimerTick(Sender: TObject);
begin begin
FNavTimer.Enabled := False; FNavTimer.Enabled := False;
@@ -1107,6 +1152,13 @@ begin
// Ids, not labels: JS owns the wording AND uses them to flag the exact // 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. // row in Settings. A combo the user cleared (vk=0) is never listed.
var LFailed := ''; 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 // Quick-search combo is set unconditionally (independent of the
// autofill enabled flag) so the picker stays armed even when the // autofill enabled flag) so the picker stays armed even when the
// autofill hotkeys are turned off. // autofill hotkeys are turned off.
@@ -1159,6 +1211,7 @@ begin
// One notification for the whole batch, naming the combos that failed — // One notification for the whole batch, naming the combos that failed —
// empty string = everything registered. // empty string = everything registered.
LFailed := LFailed.TrimRight([',', ' ']); LFailed := LFailed.TrimRight([',', ' ']);
if FBrowserInitialized then
WebBrowser.ExecuteJavaScript( WebBrowser.ExecuteJavaScript(
'if(window.Bridge&&Bridge.onAutofillHotkeysResult)' + 'if(window.Bridge&&Bridge.onAutofillHotkeysResult)' +
'Bridge.onAutofillHotkeysResult("' + LFailed.Replace('"', '') + '")'); 'Bridge.onAutofillHotkeysResult("' + LFailed.Replace('"', '') + '")');
@@ -1438,6 +1491,12 @@ begin
begin begin
var LQrUrl := PM.UserPrefs.GetPref('syncUrl'); var LQrUrl := PM.UserPrefs.GetPref('syncUrl');
var LQrTok := PM.UserPrefs.GetPref('syncPwd'); 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 LBits := '';
var LSize := 0; var LSize := 0;
if (LQrUrl <> '') and (LQrTok <> '') then if (LQrUrl <> '') and (LQrTok <> '') then
@@ -1725,6 +1784,13 @@ begin
end end
else if (LUser <> '') then else if (LUser <> '') then
begin 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( LHttp.CredentialsStorage.AddCredential(
System.Net.URLClient.TCredentialsStorage.TCredential.Create( System.Net.URLClient.TCredentialsStorage.TCredential.Create(
System.Net.URLClient.TAuthTargetType.Server, '', '', LUser, LPwd)); System.Net.URLClient.TAuthTargetType.Server, '', '', LUser, LPwd));
@@ -2000,7 +2066,7 @@ begin
LogLine('Windows session locked / suspend — notifying JS'); LogLine('Windows session locked / suspend — notifying JS');
WebBrowser.ExecuteJavaScript( WebBrowser.ExecuteJavaScript(
'if(window.Bridge&&typeof Bridge.onSystemLock==="function")Bridge.onSystemLock();' + '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; end;
procedure TMainForm.BridgeTrayRestore; procedure TMainForm.BridgeTrayRestore;
@@ -2018,7 +2084,7 @@ begin
// User picked "Lock vault" from the tray menu. Trigger lockVault() in // User picked "Lock vault" from the tray menu. Trigger lockVault() in
// JS — same path as the WTS_SESSION_LOCK auto-lock. // JS — same path as the WTS_SESSION_LOCK auto-lock.
LogLine('Lock requested from tray menu'); LogLine('Lock requested from tray menu');
WebBrowser.ExecuteJavaScript('if(typeof lockVault==="function")lockVault()'); WebBrowser.ExecuteJavaScript('if(typeof lockVault==="function")lockVault("tray menu")');
end; end;
procedure TMainForm.BridgeQuit; procedure TMainForm.BridgeQuit;
@@ -2228,7 +2294,8 @@ begin
FAutofillClock := TStopwatch.StartNew; FAutofillClock := TStopwatch.StartNew;
WebBrowser.ExecuteJavaScript( WebBrowser.ExecuteJavaScript(
'if(window.Bridge&&typeof Bridge.onAutofillRequest==="function")' + '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') + LogLine('Autofill ' + IfThen(FBridge.LastRequestFromBar, 'fill bar', 'hotkey') +
' (' + LKind + ') — foreground: "' + ATitle + '"'); ' (' + LKind + ') — foreground: "' + ATitle + '"');
end; end;
+10 -2
View File
@@ -371,6 +371,10 @@
<svg><use href="#i-globe"/></svg> <svg><use href="#i-globe"/></svg>
<span>Server credentials</span> <span>Server credentials</span>
</button> </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"> <button class="dropdown-item" data-new-template="recovery-codes">
<svg><use href="#i-shield"/></svg> <svg><use href="#i-shield"/></svg>
<span>Recovery codes (note)</span> <span>Recovery codes (note)</span>
@@ -421,6 +425,8 @@
<!-- ---- SLIDE-OVER DETAIL ------------------------------- --> <!-- ---- SLIDE-OVER DETAIL ------------------------------- -->
<aside class="slideover" id="slideover"> <aside class="slideover" id="slideover">
<header class="slideover-header"> <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> <h3 id="slideoverTitle">Entry</h3>
<button class="icon-btn" id="slideoverClose"><svg><use href="#i-x"/></svg></button> <button class="icon-btn" id="slideoverClose"><svg><use href="#i-x"/></svg></button>
</header> </header>
@@ -734,7 +740,7 @@
<button class="btn btn-ghost btn-sm hotkey-capture-btn" id="settingNewEntryCombo">Ctrl+Shift+A</button> <button class="btn btn-ghost btn-sm hotkey-capture-btn" id="settingNewEntryCombo">Ctrl+Shift+A</button>
</div> </div>
<p style="font-size:11px;color:var(--text-faint);margin:6px 0 0;line-height:1.4"> <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> </p>
<button class="btn btn-ghost btn-sm" id="settingAutofillResetHotkeys" style="margin-top:6px"> <button class="btn btn-ghost btn-sm" id="settingAutofillResetHotkeys" style="margin-top:6px">
Reset to defaults Reset to defaults
@@ -1227,7 +1233,9 @@
<div class="modal-backdrop" data-close></div> <div class="modal-backdrop" data-close></div>
<div class="modal-panel modal-panel-sm quick-search-panel"> <div class="modal-panel modal-panel-sm quick-search-panel">
<div class="quick-search-input"> <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" <input id="quickSearchInput" type="text"
placeholder="Search…" placeholder="Search…"
autocomplete="off"> autocomplete="off">
+16 -4
View File
@@ -51,7 +51,7 @@ function renderHotkeyConflicts() {
// Format a combo for human display: "Ctrl+Shift+L". // Format a combo for human display: "Ctrl+Shift+L".
function autofillComboLabel(c) { function autofillComboLabel(c) {
if (!c || !c.key) return '— not set —'; if (!c || !c.key) return 'Off';
const parts = []; const parts = [];
if (c.ctrl) parts.push('Ctrl'); if (c.ctrl) parts.push('Ctrl');
if (c.alt) parts.push('Alt'); if (c.alt) parts.push('Alt');
@@ -92,6 +92,9 @@ function autofillComboValid(c) {
function autofillCaptureFromEvent(e) { function autofillCaptureFromEvent(e) {
const k = e.key; const k = e.key;
if (k === 'Escape') return 'cancel'; 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). // Ignore pure-modifier keydowns (user is still building the combo).
if (k === 'Control' || k === 'Shift' || k === 'Alt' || if (k === 'Control' || k === 'Shift' || k === 'Alt' ||
k === 'Meta' || k === 'OS') return null; k === 'Meta' || k === 'OS') return null;
@@ -235,8 +238,10 @@ function autofillReportResult(ok) {
// Called by Bridge.onAutofillRequest when a hotkey fires. // Called by Bridge.onAutofillRequest when a hotkey fires.
// kind: 'full' = Ctrl+Shift+L (user + Tab + pwd) ; 'password' = Ctrl+Shift+P. // kind: 'full' = Ctrl+Shift+L (user + Tab + pwd) ; 'password' = Ctrl+Shift+P.
async function autofillHandleRequest(windowTitle, kind) { async function autofillHandleRequest(windowTitle, kind, fromBar) {
if (!state.autofillEnabled) return; // 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 // A blocking operation owns the screen (export, sync, rotation). Covers all
// three hotkeys and both outcomes: the multi-match picker would open behind // three hotkeys and both outcomes: the multi-match picker would open behind
@@ -268,7 +273,14 @@ async function autofillHandleRequest(windowTitle, kind) {
else pwd.focus(); else pwd.focus();
} }
}, 80); }, 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; return;
} }
+208 -9
View File
@@ -95,7 +95,8 @@ const Bridge = (() => {
toast('System lock — vault kept unlocked (quick unlock active)'); toast('System lock — vault kept unlocked (quick unlock active)');
return; 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 // Called by Delphi (ExecuteJavaScript) when the user restores the
@@ -116,8 +117,8 @@ const Bridge = (() => {
// Called by Delphi when a hotkey fires. windowTitle = foreground // Called by Delphi when a hotkey fires. windowTitle = foreground
// window title at hotkey time. kind = "full" (user+Tab+pwd) or // window title at hotkey time. kind = "full" (user+Tab+pwd) or
// "password" (password only — for step-2 forms, unlock screens). // "password" (password only — for step-2 forms, unlock screens).
onAutofillRequest(windowTitle, kind) { onAutofillRequest(windowTitle, kind, fromBar) {
autofillHandleRequest(windowTitle, kind || 'full'); autofillHandleRequest(windowTitle, kind || 'full', !!fromBar);
}, },
// Called by Delphi after cmd://autofill/execute with whether the // 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 // back to the tray AFTER SendInput completes — necessary for the
// Ctrl+Shift+Q-from-tray flow (we cannot hide before SendInput or // Ctrl+Shift+Q-from-tray flow (we cannot hide before SendInput or
// Win10/11 anti-focus-stealing rules block the target). // Win10/11 anti-focus-stealing rules block the target).
executeAutofill(username, password, hideAfter, field) { executeAutofill(username, password, hideAfter, field, noClear) {
if (!active) return; if (!active) return;
// field='user' → type only the username (no Tab / password). // field='user' → type only the username (no Tab / password).
cmd('cmd://autofill/execute?username=' + encodeURIComponent(username) + cmd('cmd://autofill/execute?username=' + encodeURIComponent(username) +
'&password=' + encodeURIComponent(password) + '&password=' + encodeURIComponent(password) +
(hideAfter ? '&hide_after=1' : '') + (hideAfter ? '&hide_after=1' : '') +
(field === 'user' ? '&field=user' : '') + (field === 'user' ? '&field=user' : '') +
(state.autofillClearField ? '' : '&clear=0') + (state.autofillClearField && !noClear ? '' : '&clear=0') +
(state.autofillFailBalloon ? '' : '&notify=0')); (state.autofillFailBalloon ? '' : '&notify=0'));
}, },
@@ -732,6 +733,15 @@ const state = {
selectedId: null, selectedId: null,
theme: localStorage.getItem('theme') || 'dark', theme: localStorage.getItem('theme') || 'dark',
locked: false, // true after user clicks Lock (token still valid server-side) 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'), autoLock: parseInt(localStorage.getItem('autoLockMin') || '5'),
askBeforeDelete: localStorage.getItem('askBeforeDelete') !== '0', // default true askBeforeDelete: localStorage.getItem('askBeforeDelete') !== '0', // default true
maskUsernames: localStorage.getItem('maskUsernames') === '1', // default false 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'); sessionStorage.removeItem('cryptoKey');
state.cryptoKey = null; state.cryptoKey = null;
state.entries = []; state.entries = [];
state.trashed = []; state.trashed = [];
state.entryHistory = { ids: [], pos: -1 };
state.locked = true; state.locked = true;
state.justRecovered = false; state.justRecovered = false;
state.avatarDataUri = ''; // reloaded from server on next unlock state.avatarDataUri = ''; // reloaded from server on next unlock
@@ -2886,6 +2898,7 @@ function templateLabel(tpl) {
case 'ssh-key': return 'SSH key'; case 'ssh-key': return 'SSH key';
case 'server': return 'Server'; case 'server': return 'Server';
case 'recovery-codes': return 'Recovery codes'; case 'recovery-codes': return 'Recovery codes';
case 'commands': return 'Commands';
default: return ''; default: return '';
} }
} }
@@ -4003,6 +4016,14 @@ const ENTRY_TEMPLATES = {
{ label: 'Root password', value: '', is_secret: true }, { 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': { 'recovery-codes': {
kind: 'note', kind: 'note',
title: 'Recovery codes', title: 'Recovery codes',
@@ -4014,6 +4035,74 @@ 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.
function parseCommandNote(text) {
const out = [];
let group = '';
for (const raw of String(text || '').split(/\r?\n/)) {
const line = raw.trim();
if (!line) continue;
if (line.startsWith('#')) { group = line.replace(/^#+\s*/, ''); continue; }
out.push({ group, cmd: line });
}
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 // Open the slideover for either an existing entry (id = number) or a
// brand-new one (id = null). opts: { presetTitle, presetSite, template } // brand-new one (id = null). opts: { presetTitle, presetSite, template }
// pre-fill the corresponding fields for the new-entry path. // pre-fill the corresponding fields for the new-entry path.
@@ -4054,6 +4143,8 @@ async function openSlideOver(id, opts) {
if (!ok) return false; // caller must not act on a panel that didn't switch if (!ok) return false; // caller must not act on a panel that didn't switch
} }
if (!isNew && !e) return false; 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 // The editor must be visible when it opens: Settings sits ABOVE the
// slideover, so a hotkey (Ctrl+Shift+A) fired with Settings open left // slideover, so a hotkey (Ctrl+Shift+A) fired with Settings open left
@@ -4154,6 +4245,7 @@ async function openSlideOver(id, opts) {
// injected by the template picker on new. Sent back to the server // injected by the template picker on new. Sent back to the server
// on Save so the card/table can label it correctly. // on Save so the card/table can label it correctly.
template: isNew ? (opts.presetTemplate || '') : (e.template || ''), template: isNew ? (opts.presetTemplate || '') : (e.template || ''),
originalTemplate: isNew ? '' : (e.template || ''),
// Attachments staged BEFORE the entry exists server-side. Empty on // Attachments staged BEFORE the entry exists server-side. Empty on
// edit (existing attachments are fetched live via the dedicated // edit (existing attachments are fetched live via the dedicated
// /entries/:id/attachments endpoint). Flushed by soSave after the // /entries/:id/attachments endpoint). Flushed by soSave after the
@@ -4184,6 +4276,7 @@ async function openSlideOver(id, opts) {
// Notes: minimal layout — name + multiline body + folder + tags. // Notes: minimal layout — name + multiline body + folder + tags.
// No icon (covered by sidebar icon), no site/user/totp. // No icon (covered by sidebar icon), no site/user/totp.
body.appendChild(soEditableField('Title', 'soTitle', soState.original.title)); body.appendChild(soEditableField('Title', 'soTitle', soState.original.title));
body.appendChild(soCommandsField());
body.appendChild(soNoteBodyField(plain)); body.appendChild(soNoteBodyField(plain));
const hist = soHistoryButton(); const hist = soHistoryButton();
if (hist) body.appendChild(hist); if (hist) body.appendChild(hist);
@@ -4560,6 +4653,67 @@ function buildCustomFieldRow(field, idx, rerender) {
// Multiline note body. Maps to soState.original.password and the // Multiline note body. Maps to soState.original.password and the
// encrypted_password+iv columns (same crypto pipeline as login passwords). // 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) { function soNoteBodyField(value) {
const wrap = el('div', { class: 'slideover-field' }); const wrap = el('div', { class: 'slideover-field' });
const lbl = el('div', { class: 'slideover-field-label so-note-label' }, 'Note'); const lbl = el('div', { class: 'slideover-field-label so-note-label' }, 'Note');
@@ -4582,6 +4736,8 @@ function soNoteBodyField(value) {
state.maskNotes = !state.maskNotes; state.maskNotes = !state.maskNotes;
localStorage.setItem('maskNotes', state.maskNotes ? '1' : '0'); localStorage.setItem('maskNotes', state.maskNotes ? '1' : '0');
ta.classList.toggle('is-masked', state.maskNotes); ta.classList.toggle('is-masked', state.maskNotes);
// La liste des commandes suit l'oeil (elle ecoute 'input').
ta.dispatchEvent(new Event('input'));
saveServerSettings(); saveServerSettings();
}); });
wrap.appendChild(ta); wrap.appendChild(ta);
@@ -5040,6 +5196,9 @@ function isSoDirty() {
tags: soState.tags.join(','), tags: soState.tags.join(','),
}; };
const curCustom = JSON.stringify(soState.customFields || []); 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) { if (soState.id == null) {
const orig = soState.original || {}; const orig = soState.original || {};
const staged = (soState.pendingAttachments || []).length > 0; const staged = (soState.pendingAttachments || []).length > 0;
@@ -7010,7 +7169,7 @@ function resetAutoLock() {
autoLockTimer = setTimeout(() => { autoLockTimer = setTimeout(() => {
hideIdleWarning(); hideIdleWarning();
toast('Auto-locked due to inactivity', 'warning'); toast('Auto-locked due to inactivity', 'warning');
lockVault(); lockVault('inactivity (' + state.autoLock + ' min)');
}, totalMs); }, totalMs);
} }
@@ -7185,13 +7344,18 @@ async function enterApp() {
if (!ok) { if (!ok) {
const fresh = await bridgePinStatus(); const fresh = await bridgePinStatus();
state.pinConfigured = fresh; state.pinConfigured = fresh;
lockVault(); lockVault('wrong PIN after the master password');
$('#authHint').textContent = fresh $('#authHint').textContent = fresh
? 'Wrong PIN. Try again.' ? 'Wrong PIN. Try again.'
: 'Too many wrong PIN attempts — PIN has been removed. Re-set it from Settings after unlocking.'; : 'Too many wrong PIN attempts — PIN has been removed. Re-set it from Settings after unlocking.';
return; 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'); $('#authScreen').classList.add('is-hidden');
$('#appShell').classList.remove('is-hidden'); $('#appShell').classList.remove('is-hidden');
$('#userName').textContent = state.username; $('#userName').textContent = state.username;
@@ -7580,6 +7744,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 && /^[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 && /^[nNwW]$/.test(e.key)) return e.preventDefault(); // New incognito / Close window
if (mod && e.shiftKey && e.key === 'Delete') return e.preventDefault(); // Clear browsing data 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); document.addEventListener('keydown', handleCardCursorKey);
@@ -7785,6 +7956,8 @@ async function init() {
// Slide-over // Slide-over
$('#slideoverClose').addEventListener('click', () => requestCloseSlideOver()); $('#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 // 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 // another entry (so we don't close in that case; the card's own handler
// will switch state.selectedId). // will switch state.selectedId).
@@ -8030,6 +8203,9 @@ async function init() {
if (e.key !== 'Escape') return; if (e.key !== 'Escape') return;
if (!$('#settingsPanel').classList.contains('is-open')) return; if (!$('#settingsPanel').classList.contains('is-open')) return;
if (document.querySelector('.modal:not(.is-hidden)')) 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. // Busy overlay up (sync/backup running) — don't close under it.
if ($('#busyOverlay') && !$('#busyOverlay').classList.contains('is-hidden')) return; if ($('#busyOverlay') && !$('#busyOverlay').classList.contains('is-hidden')) return;
// When the search box has focus AND a query, the input's own // When the search box has focus AND a query, the input's own
@@ -8377,7 +8553,7 @@ async function init() {
btn.dataset.capturing = '1'; btn.dataset.capturing = '1';
btn.classList.add('is-capturing'); btn.classList.add('is-capturing');
const original = btn.textContent; 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 // Free our own combos so they reach this listener instead of
// firing their hotkey. Restored by finish() / the success path. // firing their hotkey. Restored by finish() / the success path.
hotkeyPushSilent = true; hotkeyPushSilent = true;
@@ -8432,6 +8608,20 @@ async function init() {
return; return;
} }
if (captured === 'cancel') { finish(true); 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'){ if (captured === 'invalid'){
toast('Unsupported key — use a letter, digit or F-key', 'warning'); toast('Unsupported key — use a letter, digit or F-key', 'warning');
finish(true); finish(true);
@@ -8740,7 +8930,11 @@ async function init() {
// document-level handlers, which now see "no modal open" // document-level handlers, which now see "no modal open"
// (we just closed it) and also close the dirty slideover. // (we just closed it) and also close the dirty slideover.
e.stopPropagation(); e.stopPropagation();
if (qsCommands) { qsLeaveCommands(); return; } // liste -> entrees
closeQuickSearchModal(); closeQuickSearchModal();
} else if (e.key === 'Backspace' && qsCommands && !qsInput.value) {
e.preventDefault();
qsLeaveCommands(); // retour, comme Esc
} else if (e.key === 'ArrowDown') { } else if (e.key === 'ArrowDown') {
e.preventDefault(); e.preventDefault();
if (rows.length) { quickSearchSelected++; quickSearchRender(); } if (rows.length) { quickSearchSelected++; quickSearchRender(); }
@@ -8751,6 +8945,11 @@ async function init() {
e.preventDefault(); e.preventDefault();
const sel = rows[quickSearchSelected]; const sel = rows[quickSearchSelected];
if (!sel) return; 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 id = parseInt(sel.dataset.id, 10);
const entry = state.entries.find(x => x.id === id); const entry = state.entries.find(x => x.id === id);
// Shift+Enter → full (user + Tab + password), Ctrl+Enter → // 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). // DPAPI : localStorage se vide a chaque lancement (port ephemere).
let quickSearchPreferCopy = false; 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. /// Remplit-on vraiment ? Il faut une cible ET ne pas avoir choisi Copy.
function qsFills() { return quickSearchFillMode && !quickSearchPreferCopy; } function qsFills() { return quickSearchFillMode && !quickSearchPreferCopy; }
@@ -43,8 +125,16 @@ function qsApplyMode() {
box.querySelectorAll('[data-qsmode]').forEach(b => box.querySelectorAll('[data-qsmode]').forEach(b =>
b.classList.toggle('is-on', (b.dataset.qsmode === 'fill') === qsFills())); 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'); 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() hintEl.textContent = qsFills()
? 'Enter / click = password · Shift+Enter / right-click = user+password · Ctrl+Enter / Ctrl+click = username · Esc = cancel' ? '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'; : 'Enter / click = copy password · Ctrl+Enter / Ctrl+click = copy username · Esc = close';
@@ -52,6 +142,12 @@ function qsApplyMode() {
} }
document.addEventListener('click', ev => { 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]'); const b = ev.target.closest && ev.target.closest('[data-qsmode]');
if (!b) return; if (!b) return;
quickSearchPreferCopy = b.dataset.qsmode === 'copy'; quickSearchPreferCopy = b.dataset.qsmode === 'copy';
@@ -80,6 +176,12 @@ function quickSearchScoreEntry(e, q) {
function quickSearchRender() { function quickSearchRender() {
const q = document.getElementById('quickSearchInput').value.trim(); const q = document.getElementById('quickSearchInput').value.trim();
if (qsCommands) {
const box = document.getElementById('quickSearchResults');
box.innerHTML = '';
qsRenderCommands(box, q);
return;
}
const list = state.entries const list = state.entries
.map(e => ({ e, s: quickSearchScoreEntry(e, q) })) .map(e => ({ e, s: quickSearchScoreEntry(e, q) }))
.filter(x => x.s > 0) .filter(x => x.s > 0)
@@ -134,7 +236,9 @@ function quickSearchRender() {
if (e.username) act('i-user', verb + 'username', 'user'); if (e.username) act('i-user', verb + 'username', 'user');
// UNE NOTE SE COPIE TOUJOURS, meme en Fill : tapee, chacun de ses // UNE NOTE SE COPIE TOUJOURS, meme en Fill : tapee, chacun de ses
// retours a la ligne partirait comme un Entree dans le formulaire. // 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'); act('i-note', qsFills() ? 'Copy note (a note is never typed)' : 'Copy note', 'note');
else act('i-key', verb + 'password', 'pwd'); else act('i-key', verb + 'password', 'pwd');
if (e.totp_secret && e.totp_iv) act('i-clock', verb + '2FA code', 'totp'); 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. // comme des Entree dans le formulaire cible.
async function quickSearchPickEntry(entry, mode) { async function quickSearchPickEntry(entry, mode) {
mode = mode || 'full'; 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') { if (mode === 'totp') {
const secret = await decryptTotpSecret(entry.totp_secret, entry.totp_iv); const secret = await decryptTotpSecret(entry.totp_secret, entry.totp_iv);
@@ -559,6 +665,7 @@ function openQuickSearchModal(hideAfter, forFill) {
const modal = document.getElementById('quickSearchModal'); const modal = document.getElementById('quickSearchModal');
const input = document.getElementById('quickSearchInput'); const input = document.getElementById('quickSearchInput');
modal.classList.remove('is-hidden'); modal.classList.remove('is-hidden');
if (qsCommands) qsLeaveCommands();
input.value = ''; input.value = '';
quickSearchSelected = 0; quickSearchSelected = 0;
quickSearchHideAfter = !!hideAfter; quickSearchHideAfter = !!hideAfter;
+42
View File
@@ -0,0 +1,42 @@
// 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('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, syncIsGitea, giteaUnwrap, syncSnapshotsEqual,
// metadata-at-rest // metadata-at-rest
withEncryptedMeta, decryptEntryMeta, ENCRYPTED_META_FIELDS, 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);
});