Compare commits
15 Commits
247771bfec
...
a0527eb806
| Author | SHA1 | Date | |
|---|---|---|---|
| a0527eb806 | |||
| 6ca1e4b2cf | |||
| bbd4e2ef06 | |||
| 63e532d5bb | |||
| 28f8a42846 | |||
| 27afc4e8ca | |||
| e8e93cba56 | |||
| 553d9db621 | |||
| 84a349e54d | |||
| 4d9cb909c4 | |||
| c78b80a073 | |||
| 0604344c60 | |||
| 3051c3c392 | |||
| 2cc8d1b62b | |||
| bcbbcebe16 |
@@ -198,6 +198,19 @@ Retour Delphi → JS via `WebBrowser.ExecuteJavaScript('Bridge.onX(...)')`.
|
||||
Combos autofill configurables depuis Settings (synced en DB
|
||||
via `settings_json`).
|
||||
|
||||
**Desactiver un raccourci (2026-10-05)** : clic sur le bouton, puis **Suppr**
|
||||
(ou Retour arriere) → `key: ''` → vk 0, que Delphi n'enregistre pas ; libelle
|
||||
*Off*. Les combinaisons sont aussi **retenues en pref DPAPI `hotkeys`** (la
|
||||
requete `autofill/hotkeys` telle quelle, hors suspension de capture) et
|
||||
**rejouees au demarrage** a la place des valeurs par defaut : `settings_json`
|
||||
n'arrive qu'apres la connexion, et un `Ctrl+Shift+A` desactive restait pris
|
||||
tant que le coffre etait ferme. Piege corrige au passage : l'Esc des reglages
|
||||
(ecouteur `document` en capture, enregistre avant) fermait Settings au lieu
|
||||
d'annuler la capture — il ignore Esc tant qu'un `[data-capturing="1"]` existe.
|
||||
**La barrette remplit meme raccourcis coupes** : `onAutofillRequest` recoit
|
||||
`fromBar`, l'option « Autofill with global hotkey » ne bloque que les
|
||||
raccourcis.
|
||||
|
||||
## Fill bar (remplir à la souris)
|
||||
|
||||
Menu du tray → **Fill bar** : une barrette de trois
|
||||
@@ -1150,6 +1163,11 @@ QR que l'application Android scanne elle-meme.
|
||||
- **Piege CSS rencontre** : `css/style.css` impose `svg { width: 16px }` a
|
||||
toutes les icones, ce qui reduisait le QR a la taille d'une icone. La taille
|
||||
est donc posee **en style direct** sur le `<svg>`.
|
||||
- **WebDAV (2026-10-02)** : le telephone n'a qu'un champ « jeton », donc pour une
|
||||
URL qui n'est ni Gitea ni GitHub le QR porte `syncUser:syncPwd` — le
|
||||
telephone en fait une authentification Basic (voir android-client/README.md).
|
||||
Le QR reprend l'URL telle quelle : une configuration en `127.0.0.1` designe
|
||||
le telephone lui-meme une fois scannee, il faut l'IP du poste.
|
||||
|
||||
### GitHub, et refus d'un depot public (2026-09-18)
|
||||
|
||||
@@ -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
|
||||
place**, jamais via `rerender()` : reconstruire la liste fait flasher le
|
||||
panneau et perd le focus + la hauteur auto du multiligne.
|
||||
- **Precedent / Suivant des entrees ouvertes** (2026-10-10) : fleches ‹ › de
|
||||
l'en-tete du panneau + **Alt+←/→** (le `preventDefault` empeche aussi la page
|
||||
de « reculer »). `state.entryHistory = {ids, pos}`, 50 au plus, memoire
|
||||
seulement, vide par `lockVault`. Alimente par `openSlideOver` (sauf
|
||||
`opts.fromHistory`) ; la navigation repasse par `openSlideOver`, donc par la
|
||||
confirmation « modifications non enregistrees » — la position ne bouge
|
||||
qu'une fois l'entree vraiment ouverte. Entree supprimee sautee ; panneau
|
||||
ferme, Alt+← rouvre celle qu'on vient de quitter. Logique pure
|
||||
(`entryHistoryPush` / `entryHistoryTarget`) testee dans
|
||||
`js/tests/history.test.js`. Raison : un clic par erreur dans une liste
|
||||
filtree obligeait a refaire la recherche.
|
||||
- **Notes de commandes** (2026-10-10, modele `commands`) : le corps reste du
|
||||
texte - `# titre` ouvre un groupe, chaque autre ligne non vide est une
|
||||
commande (`parseCommandNote`, teste dans `js/tests/commands.test.js`). Aucune
|
||||
colonne : le texte est deja chiffre, synchronise, exporte et lu par l'APK.
|
||||
Panneau : case *Command list* (bascule le modele, compte dans `isSoDirty` via
|
||||
`originalTemplate`) + liste avec **Copier** seulement - la fenetre au premier
|
||||
plan y serait l'app elle-meme. Recherche rapide : choisir la note ouvre sa
|
||||
liste (`qsCommands`), la saisie filtre, Entree **tape sans Entree** et
|
||||
**sans Ctrl+A + Suppr** (`executeAutofill(..., noClear)` : dans un terminal
|
||||
Ctrl+A ramene en debut de ligne), Ctrl+Entree copie, Esc revient aux entrees.
|
||||
**Masquage** (partage d'ecran) sur l'oeil existant des notes (`maskNotes`) :
|
||||
`maskCommand` garde le premier mot et les 3 derniers caracteres
|
||||
(`ssh ••••••••••••.11`), une fin de 6 caracteres ou moins est masquee en
|
||||
entier. Affichage seulement : filtre, copie et remplissage voient le vrai
|
||||
texte. L'oeil re-declenche `input` sur le textarea pour redessiner la liste.
|
||||
- **Settings search** : `#settingsSearch` filtre les `.setting-row` par
|
||||
texte ; match sur le label de section garde toute la section. Esc dans
|
||||
la search avec query = clear ; Esc vide = ferme Settings.
|
||||
@@ -1558,6 +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
|
||||
SQLite récupère au prochain open.
|
||||
|
||||
## Page tenue éveillée (autofill lent après le tray, 2026-10-02)
|
||||
|
||||
Premier autofill après des heures dans le tray ou une veille : `Autofill: page
|
||||
answered in 4907 ms` (8 ms coffre tout juste ouvert). Les drapeaux
|
||||
`--disable-renderer-backgrounding` & co **sont bien appliqués** (vérifié sur la
|
||||
ligne de commande de `msedgewebview2.exe`) : ce n'est pas Chromium qui gèle la
|
||||
page, c'est Windows qui laisse s'endormir un processus que personne ne sollicite.
|
||||
|
||||
- `FKeepWarmTimer` (UMainForm) : un `ExecuteJavaScript` trivial **toutes les
|
||||
5 min** (choix de l'utilisateur, pas 30 s).
|
||||
- `TPMBridge.OnSystemResume` : `PBT_APMRESUMEAUTOMATIC` et `WTS_SESSION_UNLOCK`
|
||||
réveillent la page **tout de suite** — le minuteur ne tourne pas pendant la
|
||||
veille. Journal : `Windows resumed / unlocked — waking the page`.
|
||||
|
||||
## WebView2 nav race (écran noir cold-start)
|
||||
|
||||
Le timer de navigation (1.5 s) ne consomme `FPendingURL` que si
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -56,6 +56,11 @@ type
|
||||
Error: string; // message brut, pour le journal
|
||||
end;
|
||||
|
||||
/// Vrai pour une adresse du reseau local (RFC 1918, boucle, .local, .lan,
|
||||
/// nom sans point) : le seul endroit ou le HTTP en clair est accepte.
|
||||
function IsLocalHost(const AHost: string): Boolean;
|
||||
|
||||
type
|
||||
TSyncClient = class
|
||||
private
|
||||
FBaseUrl: string;
|
||||
@@ -64,6 +69,7 @@ type
|
||||
/// Attente de la POIGNEE DE MAIN, distincte de celle de la reponse.
|
||||
FConnectMs: Integer;
|
||||
FPrivacyChecked: Boolean;
|
||||
function PublicRepoWarning: string;
|
||||
public
|
||||
constructor Create(const ABaseUrl, AToken: string);
|
||||
|
||||
@@ -76,11 +82,18 @@ type
|
||||
/// n'y ressemble pas est de toute facon inutilisable.
|
||||
function IsGitea: Boolean;
|
||||
|
||||
/// WEBDAV : toute autre URL dont le "jeton" s'ecrit utilisateur:mot-de-passe.
|
||||
/// Le fichier se lit et s'ecrit tel quel (le meme que publie le poste), et
|
||||
/// l'ETag HTTP joue le role du sha : If-None-Match a la lecture, If-Match a
|
||||
/// l'ecriture (412 = un autre appareil a publie entre-temps).
|
||||
function IsWebDav: Boolean;
|
||||
|
||||
/// Refus LOCAL, avant tout envoi : http:// hors reseau local, puis depot public.
|
||||
/// UN DEPOT PUBLIC EST REFUSE (Gitea comme GitHub) : chaque synchro est un
|
||||
/// commit, et l'historique garde tous les instantanes, attaquables hors
|
||||
/// ligne par quiconque peut les lire. Message a afficher, ou '' si le depot
|
||||
/// est prive ou si on n'a pas pu le savoir. Verifie une fois par instance.
|
||||
function PublicRepoWarning: string;
|
||||
function RefusalReason: string;
|
||||
|
||||
/// POURQUOI UN 404 OU UN 401, en clair pour l'utilisateur. Gitea repond 404
|
||||
/// aussi bien pour un fichier absent que pour un depot ou un proprietaire
|
||||
@@ -107,7 +120,7 @@ type
|
||||
implementation
|
||||
|
||||
uses
|
||||
System.JSON, System.NetEncoding;
|
||||
System.JSON, System.NetEncoding, System.StrUtils;
|
||||
|
||||
function TSyncClient.IsGitea: Boolean;
|
||||
begin
|
||||
@@ -116,6 +129,81 @@ begin
|
||||
FBaseUrl.ToLower.Contains('api.github.com/repos/');
|
||||
end;
|
||||
|
||||
function TSyncClient.IsWebDav: Boolean;
|
||||
begin
|
||||
Result := (not IsGitea) and FToken.Contains(':');
|
||||
end;
|
||||
|
||||
/// Basic sur WebDAV, Bearer pour l'endpoint PHP historique.
|
||||
function PlainAuthHeader(const AToken: string; AWebDav: Boolean): string;
|
||||
begin
|
||||
if AWebDav then
|
||||
Result := 'Basic ' + TNetEncoding.Base64.Encode(AToken).Replace(#13, '').Replace(#10, '')
|
||||
else
|
||||
Result := 'Bearer ' + AToken;
|
||||
end;
|
||||
|
||||
function PutWebDav(const AUrl, AToken, ABody, AETag: string;
|
||||
AConnectMs, ATimeoutMs: Integer): TSyncResult;
|
||||
var
|
||||
LHttp: THTTPClient;
|
||||
LResp: IHTTPResponse;
|
||||
LReq, LOut: TStringStream;
|
||||
begin
|
||||
Result := Default(TSyncResult);
|
||||
Result.Status := ssNetworkError;
|
||||
LHttp := THTTPClient.Create;
|
||||
LReq := TStringStream.Create(ABody, TEncoding.UTF8);
|
||||
LOut := TStringStream.Create('', TEncoding.UTF8);
|
||||
try
|
||||
LHttp.ConnectionTimeout := AConnectMs;
|
||||
LHttp.ResponseTimeout := ATimeoutMs;
|
||||
LHttp.HandleRedirects := False;
|
||||
LHttp.UserAgent := 'PMServer';
|
||||
LHttp.CustomHeaders['Connection'] := 'close';
|
||||
LHttp.CustomHeaders['Authorization'] := PlainAuthHeader(AToken, True);
|
||||
LHttp.CustomHeaders['Content-Type'] := 'application/json';
|
||||
// Sans ETag connu (premiere publication, serveur qui n'en rend pas) : pas
|
||||
// de condition, le dernier ecrit gagne - comme le poste dans ce cas.
|
||||
if AETag <> '' then
|
||||
LHttp.CustomHeaders['If-Match'] := AETag;
|
||||
LResp := nil;
|
||||
for var LTry := 1 to 3 do
|
||||
begin
|
||||
try
|
||||
LReq.Position := 0;
|
||||
LOut.Clear;
|
||||
LResp := LHttp.Put(AUrl, LReq, LOut);
|
||||
Break;
|
||||
except
|
||||
on E: Exception do
|
||||
begin
|
||||
Result.Error := E.ClassName + ': ' + E.Message;
|
||||
if LTry = 3 then Exit;
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
if LResp = nil then Exit;
|
||||
Result.HttpCode := LResp.StatusCode;
|
||||
case LResp.StatusCode of
|
||||
200, 201, 204:
|
||||
begin
|
||||
Result.Status := ssOK;
|
||||
Result.ETag := LResp.HeaderValue['ETag'];
|
||||
end;
|
||||
412: Result.Status := ssConflict;
|
||||
else
|
||||
Result.Status := ssHttpError;
|
||||
Result.Error := Format('HTTP %d %s - %s', [LResp.StatusCode,
|
||||
LResp.StatusText, Copy(LOut.DataString, 1, 300)]);
|
||||
end;
|
||||
finally
|
||||
LOut.Free;
|
||||
LReq.Free;
|
||||
LHttp.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
/// URL de l'API du depot (sans /contents/...), identique pour Gitea
|
||||
/// (https://hote/api/v1/repos/O/R) et GitHub (https://api.github.com/repos/O/R).
|
||||
function RepoApiUrl(const AContentsUrl: string): string;
|
||||
@@ -126,6 +214,49 @@ begin
|
||||
if LP > 0 then Result := Copy(AContentsUrl, 1, LP - 1) else Result := '';
|
||||
end;
|
||||
|
||||
/// HTTP EN CLAIR : RESEAU LOCAL SEULEMENT. Android le permet partout
|
||||
/// (network_security_config) pour que chacun pointe vers SON serveur WebDAV,
|
||||
/// a une adresse qu'on ne connait pas a la compilation ; le tri se fait donc
|
||||
/// ici. Vers Internet, le jeton ne part qu'en HTTPS.
|
||||
/// ponytail: un Wi-Fi etranger qui reutilise la meme plage recevrait le jeton
|
||||
/// en clair - le coffre, lui, reste chiffre. HTTPS chez soi supprime le risque.
|
||||
function IsLocalHost(const AHost: string): Boolean;
|
||||
var
|
||||
LParts: TArray<string>;
|
||||
A, B: Integer;
|
||||
begin
|
||||
var LHost := AHost.ToLower;
|
||||
if (LHost = 'localhost') or LHost.EndsWith('.local') or LHost.EndsWith('.lan') or
|
||||
(LHost.IndexOf('.') < 0) and (LHost <> '') then
|
||||
Exit(True);
|
||||
LParts := LHost.Split(['.']);
|
||||
if (Length(LParts) <> 4) or not TryStrToInt(LParts[0], A) or
|
||||
not TryStrToInt(LParts[1], B) then
|
||||
Exit(False);
|
||||
for var LPart in LParts do
|
||||
if not TryStrToInt(LPart, A) then Exit(False);
|
||||
A := StrToInt(LParts[0]);
|
||||
Result := (A = 10) or (A = 127) or ((A = 192) and (B = 168)) or
|
||||
((A = 172) and (B >= 16) and (B <= 31));
|
||||
end;
|
||||
|
||||
function TSyncClient.RefusalReason: string;
|
||||
var
|
||||
LHost: string;
|
||||
begin
|
||||
if StartsText('http://', FBaseUrl) then
|
||||
begin
|
||||
try
|
||||
LHost := TURI.Create(FBaseUrl).Host;
|
||||
except
|
||||
LHost := '';
|
||||
end;
|
||||
if not IsLocalHost(LHost) then
|
||||
Exit('http:// is only allowed on the local network (192.168.x.x, 10.x.x.x, .local, .lan) - use https:// for ' + LHost);
|
||||
end;
|
||||
Result := PublicRepoWarning;
|
||||
end;
|
||||
|
||||
function TSyncClient.PublicRepoWarning: string;
|
||||
var
|
||||
LHttp: THTTPClient;
|
||||
@@ -295,18 +426,20 @@ begin
|
||||
Result := Default(TSyncResult);
|
||||
Result.Status := ssNetworkError;
|
||||
|
||||
if not IsGitea then
|
||||
begin
|
||||
Result.Status := ssHttpError;
|
||||
Result.Error := 'Ecriture disponible seulement vers un depot Gitea ou GitHub';
|
||||
Exit;
|
||||
end;
|
||||
if (FBaseUrl = '') or (FToken = '') then
|
||||
begin
|
||||
Result.Error := 'URL ou jeton non configure';
|
||||
Exit;
|
||||
end;
|
||||
Result.Error := PublicRepoWarning;
|
||||
if IsWebDav then
|
||||
Exit(PutWebDav(FBaseUrl, FToken, AContainerJson, ASha, FConnectMs, FTimeoutMs));
|
||||
if not IsGitea then
|
||||
begin
|
||||
Result.Status := ssHttpError;
|
||||
Result.Error := 'Ecriture disponible seulement vers Gitea, GitHub ou WebDAV (jeton utilisateur:mot-de-passe)';
|
||||
Exit;
|
||||
end;
|
||||
Result.Error := RefusalReason;
|
||||
if Result.Error <> '' then
|
||||
begin
|
||||
Result.Status := ssRefused; // HttpCode 0 : refus local, pas du serveur
|
||||
@@ -439,7 +572,7 @@ begin
|
||||
Result.Error := 'URL ou jeton non configure';
|
||||
Exit;
|
||||
end;
|
||||
Result.Error := PublicRepoWarning;
|
||||
Result.Error := RefusalReason;
|
||||
if Result.Error <> '' then
|
||||
begin
|
||||
Result.Status := ssRefused; // HttpCode 0 : refus local, pas du serveur
|
||||
@@ -466,7 +599,7 @@ begin
|
||||
LHttp.CustomHeaders['Authorization'] := 'token ' + FToken
|
||||
else
|
||||
begin
|
||||
LHttp.CustomHeaders['Authorization'] := 'Bearer ' + FToken;
|
||||
LHttp.CustomHeaders['Authorization'] := PlainAuthHeader(FToken, IsWebDav);
|
||||
// Pas sur Gitea : le sha n'est pas un ETag HTTP, et l'envoyer ferait
|
||||
// repondre 200 avec un corps complet de toute facon.
|
||||
if AKnownETag <> '' then
|
||||
|
||||
@@ -416,6 +416,37 @@ gestionnaire ne s'exécutait pas (dont « Tap again to delete »). Une vraie
|
||||
détail reste dans le journal local.
|
||||
- **Nom par défaut `data.json`** au lieu de `vault-sync.json` — voir CLAUDE.md.
|
||||
|
||||
## WebDAV — 2026-10-02
|
||||
|
||||
Troisième transport, à côté de Gitea et GitHub, éprouvé contre le **WsgiDAV
|
||||
local du poste** (`E:\webdav.json`, `0.0.0.0:8080`) : lecture, 304 à
|
||||
l'ouverture, écriture 204, puis synchro du poste qui reprend la modification.
|
||||
|
||||
- **Reconnu au jeton, pas à l'URL** : une URL ni Gitea ni GitHub dont le jeton
|
||||
s'écrit `utilisateur:mot-de-passe` → `IsWebDav`, authentification Basic.
|
||||
Pas de champ « utilisateur » de plus dans l'écran : le jeton en tient lieu
|
||||
(sans `:`, c'est toujours l'ancien endpoint PHP, en Bearer).
|
||||
- **Le fichier tel quel**, le même que publie le poste : GET / PUT sans
|
||||
enveloppe base64. **L'ETag HTTP fait le sha** : `If-None-Match` à la lecture
|
||||
(304 = rien à faire), `If-Match` à l'écriture, **412 → `ssConflict`**, donc la
|
||||
même reprise relire-fusionner-republier que le 409/422 de Gitea. Sans ETag
|
||||
connu, pas de condition : le dernier écrit gagne, comme le poste.
|
||||
- **Une URL qui finit en `.json` n'est plus réécrite** par `normalizeRepoUrl`
|
||||
(elle en faisait une URL d'API Gitea) — sauf les formes longues d'un dépôt,
|
||||
`/src/branch/…` et `/blob/…`.
|
||||
- **`http://` : réseau local seulement**, pour que chacun (les amis compris)
|
||||
pointe vers son propre serveur sans adresse connue à la compilation. Android
|
||||
ne sait autoriser ni plage ni masque : `network_security_config.xml` permet
|
||||
donc le clair partout, et `TSyncClient.RefusalReason` refuse **avant tout
|
||||
envoi** un `http://` dont l'hôte n'est pas local (`IsLocalHost` : RFC 1918,
|
||||
`127.x`, `.local`, `.lan`, nom sans point). Vers Internet, HTTPS obligatoire,
|
||||
et le refus s'affiche dans les réglages. Risque assumé : un Wi-Fi étranger
|
||||
qui réutilise la même plage recevrait le mot de passe WebDAV en clair — le
|
||||
coffre, lui, reste chiffré.
|
||||
- **Côté poste** : pare-feu Windows (règle `WebDAV 8080`, `LocalSubnet`) **et**
|
||||
Portmaster (entrant autorisé pour Python) — sans le second, le port restait
|
||||
fermé malgré la règle.
|
||||
|
||||
## Saisie : ce qui a résisté
|
||||
|
||||
- **`user-select: none` du `body`** était hérité par les champs : le curseur ne
|
||||
|
||||
@@ -1,26 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Le trafic en clair n'est permis que vers la BOUCLE LOCALE, et interdit
|
||||
partout ailleurs.
|
||||
Le clair est PERMIS ICI, et trie dans le code (PMA.SyncClient.IsLocalHost).
|
||||
|
||||
Il l'etait auparavant partout, via usesCleartextTraffic="true". C'etait pour
|
||||
une seule raison : l'essai local passe par "adb reverse", qui fait voir le
|
||||
poste comme 127.0.0.1 et parle HTTP. Mais l'attribut ne distingue pas la
|
||||
boucle locale du reste du monde - une URL mal saisie, ou un serveur repassant
|
||||
en HTTP, envoyait le jeton en clair sur le reseau sans que rien ne le signale.
|
||||
|
||||
Ici la permission est nominative. Le jeton ne peut plus sortir qu'en TLS, et
|
||||
une URL "http://" vers un vrai hote echoue tout de suite, ce qui est
|
||||
exactement le comportement voulu : bruyant, pas silencieux.
|
||||
|
||||
cleartextTrafficPermitted="false" a la racine n'est pas redondant avec le
|
||||
defaut d'Android 9+ : il vaut aussi pour les versions anterieures, et il dit
|
||||
l'intention noir sur blanc.
|
||||
Chacun pointe vers SON serveur WebDAV, souvent en http:// sur son reseau
|
||||
local, a une adresse qu'on ne connait pas a la compilation - et Android ne
|
||||
sait autoriser ni une plage ni un masque, seulement des noms un par un.
|
||||
Avant tout envoi, la synchro refuse donc tout http:// qui ne vise pas le
|
||||
reseau local (RFC 1918, boucle, .local, .lan, nom sans point) : vers
|
||||
Internet, le jeton ne part qu'en HTTPS, et le refus s'affiche au lieu de
|
||||
laisser passer en silence.
|
||||
-->
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="false" />
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="false">127.0.0.1</domain>
|
||||
<domain includeSubdomains="false">localhost</domain>
|
||||
</domain-config>
|
||||
<base-config cleartextTrafficPermitted="true" />
|
||||
</network-security-config>
|
||||
|
||||
@@ -528,7 +528,7 @@
|
||||
champ. Une adresse d'API Gitea en fait un jeton d'acces personnel
|
||||
envoye en "token" ; l'endpoint PHP en fait un "Bearer". Le
|
||||
nommer d'apres un seul des deux le rend faux pour l'autre. -->
|
||||
<input class="field-in" id="tok" placeholder="access token"
|
||||
<input class="field-in" id="tok" placeholder="access token (WebDAV: user:password)"
|
||||
type="password" autocomplete="off">
|
||||
<div class="in-x" data-clear="tok">×</div>
|
||||
</div>
|
||||
@@ -615,13 +615,13 @@
|
||||
entier. Un input d'une ligne ne defilait pas sous le doigt dans la
|
||||
WebView de l'app, et la fin d'une URL d'API restait inatteignable. -->
|
||||
<textarea class="field-in multi" id="sUrl" rows="1"
|
||||
placeholder="repository URL - Gitea or github.com/owner/repo (private)"
|
||||
placeholder="repository URL - Gitea, github.com/owner/repo (private), or WebDAV file URL"
|
||||
autocomplete="off" autocorrect="off" autocapitalize="none"
|
||||
spellcheck="false" inputmode="url"></textarea>
|
||||
<div class="in-x" data-clear="sUrl">×</div>
|
||||
</div>
|
||||
<div class="in-wrap">
|
||||
<input class="field-in" id="sTok" placeholder="access token"
|
||||
<input class="field-in" id="sTok" placeholder="access token (WebDAV: user:password)"
|
||||
type="password" autocomplete="off">
|
||||
<div class="in-x" data-clear="sTok">×</div>
|
||||
</div>
|
||||
@@ -1903,6 +1903,9 @@ function normalizeRepoUrl(u) {
|
||||
u = u.replace(/\s+/g, '');
|
||||
if (!u || /\/api\/v1\/repos\//i.test(u) || /api\.github\.com\/repos\//i.test(u) ||
|
||||
/\.php(\?|$)/i.test(u)) return u;
|
||||
// WEBDAV : l'URL du fichier lui-meme, a garder telle quelle. Seules les
|
||||
// formes longues d'un depot (/src/branch/, /blob/) finissent aussi en .json.
|
||||
if (/\.json$/i.test(u) && !/\/(src\/branch|blob)\//i.test(u)) return u;
|
||||
// GITHUB : l'adresse du depot est sur github.com, l'API sur api.github.com.
|
||||
var g = u.match(/^https?:\/\/(www\.)?github\.com\/([^\/]+)\/([^\/]+?)(?:\.git)?(?:\/blob\/[^\/]+\/(.+?))?\/?$/i);
|
||||
if (g) return 'https://api.github.com/repos/' + g[2] + '/' + g[3] +
|
||||
|
||||
@@ -1979,6 +1979,15 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
.so-note-body.is-masked,
|
||||
.so-custom-multiline.is-masked { -webkit-text-security: disc; }
|
||||
.so-note-label { display: flex; align-items: center; justify-content: space-between; }
|
||||
/* Note de commandes : une ligne par commande, groupees sous leur titre. */
|
||||
.so-cmds-toggle { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-dim); cursor: pointer; }
|
||||
.so-cmds-list { margin-top: 8px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.so-cmds-group { font-size: 11px; font-weight: 600; text-transform: uppercase; color: var(--text-dim); margin-top: 8px; }
|
||||
.so-cmds-row { display: flex; align-items: center; gap: 8px; padding: 4px 8px; border: 1px solid var(--border); border-radius: 6px; }
|
||||
.so-cmds-cmd { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
|
||||
.so-cmds-empty { font-size: 12px; color: var(--text-dim); }
|
||||
#qsBack { flex-shrink: 0; }
|
||||
.qs-cmd { font-family: ui-monospace, Consolas, monospace; font-size: 13px; }
|
||||
|
||||
/* ---- + New dropdown ---------------------------------- */
|
||||
.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;
|
||||
}
|
||||
.slideover-body { padding: 20px; overflow-y: auto; flex: 1; }
|
||||
/* Precedent / Suivant : le chevron du sprite, tourne. Le titre prend la place. */
|
||||
#slideover .slideover-header h3 { flex: 1; min-width: 0; margin-left: 6px; }
|
||||
.so-hist:disabled { opacity: .3; cursor: default; }
|
||||
.so-hist-back { transform: rotate(90deg); }
|
||||
.so-hist-fwd { transform: rotate(-90deg); }
|
||||
.slideover-field { margin-bottom: 16px; }
|
||||
/* Settings panel search — sits below the header, above the body. Only
|
||||
present inside #settingsPanel. */
|
||||
|
||||
@@ -109,6 +109,7 @@ type
|
||||
// WinEvent hook that watches every foreground change in the session.
|
||||
FForegroundHook: THandle;
|
||||
FOnSystemLock: TProc;
|
||||
FOnSystemResume: TProc;
|
||||
// Set TRUE the moment Windows tells us the session is ending
|
||||
// (WM_QUERYENDSESSION / WM_ENDSESSION). FormCloseQuery checks this
|
||||
// to bypass the "minimize to tray" intercept so the form closes
|
||||
@@ -296,6 +297,8 @@ type
|
||||
property AutofillTotpActive: Boolean read FAutofillTotpActive;
|
||||
// Fired on main thread when Windows locks the session (WTS_SESSION_LOCK).
|
||||
property OnSystemLock: TProc read FOnSystemLock write FOnSystemLock;
|
||||
// Sortie de veille ou session deverrouillee : de quoi reveiller la page.
|
||||
property OnSystemResume: TProc read FOnSystemResume write FOnSystemResume;
|
||||
// True once WM_QUERYENDSESSION (or WM_ENDSESSION) has been received.
|
||||
// FormCloseQuery uses this to allow normal close during shutdown.
|
||||
property ShutdownPending: Boolean read FShutdownPending;
|
||||
@@ -406,6 +409,7 @@ const
|
||||
const
|
||||
WM_WTSSESSION_CHANGE = $02B1;
|
||||
WTS_SESSION_LOCK = 7;
|
||||
WTS_SESSION_UNLOCK = 8;
|
||||
NOTIFY_FOR_THIS_SESSION = 0;
|
||||
|
||||
// Power management broadcast — sent to all top-level windows when the
|
||||
@@ -1755,6 +1759,8 @@ begin
|
||||
begin
|
||||
if AMsg.WParam = WTS_SESSION_LOCK then
|
||||
if Assigned(FOnSystemLock) then FOnSystemLock();
|
||||
if AMsg.WParam = WTS_SESSION_UNLOCK then
|
||||
if Assigned(FOnSystemResume) then FOnSystemResume();
|
||||
end
|
||||
|
||||
else if AMsg.Msg = WM_POWERBROADCAST then
|
||||
@@ -1768,6 +1774,8 @@ begin
|
||||
// suspends — fast handler required (no UI prompts, no network).
|
||||
if AMsg.WParam = PBT_APMSUSPEND then
|
||||
if Assigned(FOnSystemLock) then FOnSystemLock();
|
||||
if AMsg.WParam = PBT_APMRESUMEAUTOMATIC then
|
||||
if Assigned(FOnSystemResume) then FOnSystemResume();
|
||||
end
|
||||
|
||||
else if (AMsg.Msg = WM_QUERYENDSESSION) or (AMsg.Msg = WM_ENDSESSION) then
|
||||
|
||||
@@ -98,6 +98,13 @@ type
|
||||
FFileSaveChunks: TDictionary<string, TStringBuilder>;
|
||||
FPendingURL: string;
|
||||
FNavTimer: TTimer;
|
||||
// REVEIL DE LA PAGE pendant que l'application dort dans le tray. Les
|
||||
// drapeaux --disable-renderer-backgrounding & co sont bien appliques, et
|
||||
// pourtant le premier autofill apres des heures cachee attendait 3 a 5 s
|
||||
// la reponse de la page (mesure : 8 ms coffre tout juste ouvert, 4907 ms
|
||||
// apres une veille). Windows laisse s'endormir un processus que personne
|
||||
// ne sollicite ; une instruction JS triviale toutes les 5 min l'en empeche.
|
||||
FKeepWarmTimer: TTimer;
|
||||
// Set once WebView2 fires OnInitialized. The nav timer only consumes
|
||||
// FPendingURL when this is True — otherwise a timer tick that lands
|
||||
// before the engine is ready would Navigate() into the void AND clear
|
||||
@@ -163,6 +170,7 @@ type
|
||||
procedure UpdateButtons;
|
||||
procedure NavigateToVault;
|
||||
procedure NavTimerTick(Sender: TObject);
|
||||
procedure KeepWarmTick(Sender: TObject);
|
||||
// JS↔Delphi bridge
|
||||
procedure WebBrowserBeforeNavigate(Sender: TObject;
|
||||
var Params: TTMSFNCCustomWebBrowserBeforeNavigateParams);
|
||||
@@ -287,7 +295,20 @@ begin
|
||||
// USE_EDGE_BROWSER} directive can swap the concrete class without a
|
||||
// second .fmx variant. Align Client to fill the area below PanelTop
|
||||
// and above the (collapsible) PanelLog + Splitter.
|
||||
// CACHE EFFACE AU DEMARRAGE, PAS A LA FERMETURE. TMS l'effacait dans son
|
||||
// destructeur, pendant que les processus msedgewebview2 supprimaient encore
|
||||
// leurs propres fichiers : un fichier disparu en cours de parcours
|
||||
// (EBWebView\Default\DIPS-wal) levait EDirectoryNotFoundException, non
|
||||
// rattrapee, a la fermeture. Ici aucun moteur ne tourne encore - la garde
|
||||
// d'instance unique est passee dans le .dpr. Meme dossier que TMS :
|
||||
// %TEMP%\<exe>\EdgeCache. Echec ignore : un cache reste un cache.
|
||||
try
|
||||
TDirectory.Delete(TPath.Combine(TPath.Combine(TPath.GetTempPath,
|
||||
ChangeFileExt(ExtractFileName(ParamStr(0)), '')), 'EdgeCache'), True);
|
||||
except
|
||||
end;
|
||||
WebBrowser := TWebBrowserClass.Create(Self);
|
||||
WebBrowser.AutoClearCache := False;
|
||||
// Wire events BEFORE Parent assignment: setting Parent triggers the
|
||||
// TMS browser's async WebView2 init, which fires OnInitialized when
|
||||
// Edge Chromium is ready. If we assigned the handlers afterwards we'd
|
||||
@@ -313,6 +334,14 @@ begin
|
||||
FBridge := TPMBridge.Create(Self);
|
||||
FFileSaveChunks := TDictionary<string, TStringBuilder>.Create;
|
||||
FBridge.OnSystemLock := BridgeSystemLock;
|
||||
// Le reveil des 5 min ne tourne pas pendant la veille : on le donne tout de
|
||||
// suite au retour, avant le premier autofill.
|
||||
FBridge.OnSystemResume :=
|
||||
procedure
|
||||
begin
|
||||
LogLine('Windows resumed / unlocked — waking the page');
|
||||
KeepWarmTick(nil);
|
||||
end;
|
||||
FBridge.OnTrayRestore := BridgeTrayRestore;
|
||||
// Changement fait sur la barrette elle-meme : l'onglet se relit (fillbar/get).
|
||||
FBridge.OnFillBarChanged :=
|
||||
@@ -335,7 +364,13 @@ begin
|
||||
FBridge.OnNewEntryHotkey := BridgeNewEntryHotkey;
|
||||
FBridge.OnQuickSearchRequest := BridgeQuickSearchRequest;
|
||||
FBridge.OnQuickSearchHotkey := BridgeQuickSearchHotkey;
|
||||
FBridge.RegisterAutofillHotkey; // Ctrl+Shift+L active from startup
|
||||
// Les combinaisons de la derniere session (dont les raccourcis desactives),
|
||||
// sinon les valeurs par defaut.
|
||||
var LSavedHotkeys := PM.UserPrefs.GetPref('hotkeys');
|
||||
if LSavedHotkeys <> '' then
|
||||
HandleBridgeCommand('autofill/hotkeys', LSavedHotkeys)
|
||||
else
|
||||
FBridge.RegisterAutofillHotkey; // Ctrl+Shift+L active from startup
|
||||
FBridge.ApplyTitleBarTheme(True); // dark by default, JS may toggle later
|
||||
// Apres le theme et apres OnTrayRestore : la barrette se peint elle-meme, et
|
||||
// son bouton Open passe par cet evenement.
|
||||
@@ -351,6 +386,10 @@ begin
|
||||
FNavTimer.Enabled := False;
|
||||
FNavTimer.OnTimer := NavTimerTick;
|
||||
|
||||
FKeepWarmTimer := TTimer.Create(Self);
|
||||
FKeepWarmTimer.Interval := 5 * 60 * 1000;
|
||||
FKeepWarmTimer.OnTimer := KeepWarmTick;
|
||||
|
||||
UpdateButtons;
|
||||
LogLine('Password Manager - d backend ready.');
|
||||
LogLine('Click Start to launch server + embedded web vault.');
|
||||
@@ -805,6 +844,12 @@ begin
|
||||
FNavTimer.Enabled := True;
|
||||
end;
|
||||
|
||||
procedure TMainForm.KeepWarmTick(Sender: TObject);
|
||||
begin
|
||||
if FBrowserInitialized then
|
||||
WebBrowser.ExecuteJavaScript('window.Bridge&&Bridge.active');
|
||||
end;
|
||||
|
||||
procedure TMainForm.NavTimerTick(Sender: TObject);
|
||||
begin
|
||||
FNavTimer.Enabled := False;
|
||||
@@ -1107,6 +1152,13 @@ begin
|
||||
// Ids, not labels: JS owns the wording AND uses them to flag the exact
|
||||
// row in Settings. A combo the user cleared (vk=0) is never listed.
|
||||
var LFailed := '';
|
||||
// RETENU EN PREF DPAPI pour etre rejoue au demarrage : les combinaisons
|
||||
// vivent dans settings_json, lu seulement apres la connexion - un raccourci
|
||||
// desactive restait donc pris par les valeurs par defaut tant que le coffre
|
||||
// etait ferme. Pas la suspension de la capture (sans full_vk). Rien de
|
||||
// secret dedans.
|
||||
if GetParam('full_vk') <> '' then
|
||||
PM.UserPrefs.SetPref('hotkeys', AParams);
|
||||
// Quick-search combo is set unconditionally (independent of the
|
||||
// autofill enabled flag) so the picker stays armed even when the
|
||||
// autofill hotkeys are turned off.
|
||||
@@ -1159,9 +1211,10 @@ begin
|
||||
// One notification for the whole batch, naming the combos that failed —
|
||||
// empty string = everything registered.
|
||||
LFailed := LFailed.TrimRight([',', ' ']);
|
||||
WebBrowser.ExecuteJavaScript(
|
||||
'if(window.Bridge&&Bridge.onAutofillHotkeysResult)' +
|
||||
'Bridge.onAutofillHotkeysResult("' + LFailed.Replace('"', '') + '")');
|
||||
if FBrowserInitialized then
|
||||
WebBrowser.ExecuteJavaScript(
|
||||
'if(window.Bridge&&Bridge.onAutofillHotkeysResult)' +
|
||||
'Bridge.onAutofillHotkeysResult("' + LFailed.Replace('"', '') + '")');
|
||||
end
|
||||
|
||||
// execute: JS has matched an entry, decrypted the password, and is
|
||||
@@ -1438,6 +1491,12 @@ begin
|
||||
begin
|
||||
var LQrUrl := PM.UserPrefs.GetPref('syncUrl');
|
||||
var LQrTok := PM.UserPrefs.GetPref('syncPwd');
|
||||
// WEBDAV : le telephone attend "utilisateur:mot-de-passe" dans le jeton
|
||||
// (il en fait une authentification Basic).
|
||||
if (Pos('/api/v1/repos/', LQrUrl) = 0) and
|
||||
not ContainsText(LQrUrl, 'api.github.com/repos/') and
|
||||
(PM.UserPrefs.GetPref('syncUser') <> '') and (LQrTok <> '') then
|
||||
LQrTok := PM.UserPrefs.GetPref('syncUser') + ':' + LQrTok;
|
||||
var LBits := '';
|
||||
var LSize := 0;
|
||||
if (LQrUrl <> '') and (LQrTok <> '') then
|
||||
@@ -1725,6 +1784,13 @@ begin
|
||||
end
|
||||
else if (LUser <> '') then
|
||||
begin
|
||||
// BASIC DES LA PREMIERE REQUETE, comme le telephone. Laisse a
|
||||
// Windows, l'authentification partait d'abord en anonyme : 401,
|
||||
// puis la meme requete - et un PUT envoyait le coffre deux fois.
|
||||
// Les identifiants restent aussi en reserve pour un serveur Digest.
|
||||
LHttp.CustomHeaders['Authorization'] := 'Basic ' +
|
||||
TNetEncoding.Base64.Encode(LUser + ':' + LPwd)
|
||||
.Replace(#13, '').Replace(#10, '');
|
||||
LHttp.CredentialsStorage.AddCredential(
|
||||
System.Net.URLClient.TCredentialsStorage.TCredential.Create(
|
||||
System.Net.URLClient.TAuthTargetType.Server, '', '', LUser, LPwd));
|
||||
@@ -2000,7 +2066,7 @@ begin
|
||||
LogLine('Windows session locked / suspend — notifying JS');
|
||||
WebBrowser.ExecuteJavaScript(
|
||||
'if(window.Bridge&&typeof Bridge.onSystemLock==="function")Bridge.onSystemLock();' +
|
||||
'else if(typeof lockVault==="function")lockVault()');
|
||||
'else if(typeof lockVault==="function")lockVault("Windows session locked or computer asleep")');
|
||||
end;
|
||||
|
||||
procedure TMainForm.BridgeTrayRestore;
|
||||
@@ -2018,7 +2084,7 @@ begin
|
||||
// User picked "Lock vault" from the tray menu. Trigger lockVault() in
|
||||
// JS — same path as the WTS_SESSION_LOCK auto-lock.
|
||||
LogLine('Lock requested from tray menu');
|
||||
WebBrowser.ExecuteJavaScript('if(typeof lockVault==="function")lockVault()');
|
||||
WebBrowser.ExecuteJavaScript('if(typeof lockVault==="function")lockVault("tray menu")');
|
||||
end;
|
||||
|
||||
procedure TMainForm.BridgeQuit;
|
||||
@@ -2228,7 +2294,8 @@ begin
|
||||
FAutofillClock := TStopwatch.StartNew;
|
||||
WebBrowser.ExecuteJavaScript(
|
||||
'if(window.Bridge&&typeof Bridge.onAutofillRequest==="function")' +
|
||||
'Bridge.onAutofillRequest("' + LTitle + '","' + LKind + '")');
|
||||
'Bridge.onAutofillRequest("' + LTitle + '","' + LKind + '",' +
|
||||
BoolToStr(FBridge.LastRequestFromBar, True).ToLower + ')');
|
||||
LogLine('Autofill ' + IfThen(FBridge.LastRequestFromBar, 'fill bar', 'hotkey') +
|
||||
' (' + LKind + ') — foreground: "' + ATitle + '"');
|
||||
end;
|
||||
|
||||
+10
-2
@@ -371,6 +371,10 @@
|
||||
<svg><use href="#i-globe"/></svg>
|
||||
<span>Server credentials</span>
|
||||
</button>
|
||||
<button class="dropdown-item" data-new-template="commands">
|
||||
<svg><use href="#i-edit"/></svg>
|
||||
<span>Commands (note)</span>
|
||||
</button>
|
||||
<button class="dropdown-item" data-new-template="recovery-codes">
|
||||
<svg><use href="#i-shield"/></svg>
|
||||
<span>Recovery codes (note)</span>
|
||||
@@ -421,6 +425,8 @@
|
||||
<!-- ---- SLIDE-OVER DETAIL ------------------------------- -->
|
||||
<aside class="slideover" id="slideover">
|
||||
<header class="slideover-header">
|
||||
<button class="icon-btn so-hist" id="slideoverBack" title="Previous entry (Alt+Left)" disabled><svg class="so-hist-back"><use href="#i-chevron-down"/></svg></button>
|
||||
<button class="icon-btn so-hist" id="slideoverFwd" title="Next entry (Alt+Right)" disabled><svg class="so-hist-fwd"><use href="#i-chevron-down"/></svg></button>
|
||||
<h3 id="slideoverTitle">Entry</h3>
|
||||
<button class="icon-btn" id="slideoverClose"><svg><use href="#i-x"/></svg></button>
|
||||
</header>
|
||||
@@ -734,7 +740,7 @@
|
||||
<button class="btn btn-ghost btn-sm hotkey-capture-btn" id="settingNewEntryCombo">Ctrl+Shift+A</button>
|
||||
</div>
|
||||
<p style="font-size:11px;color:var(--text-faint);margin:6px 0 0;line-height:1.4">
|
||||
Click a button, then press your new combo. Needs Ctrl, Alt or Win + a letter / digit / F-key.
|
||||
Click a button, then press your new combo. Needs Ctrl, Alt or Win + a letter / digit / F-key. Press Delete instead to turn that hotkey off.
|
||||
</p>
|
||||
<button class="btn btn-ghost btn-sm" id="settingAutofillResetHotkeys" style="margin-top:6px">
|
||||
Reset to defaults
|
||||
@@ -1227,7 +1233,9 @@
|
||||
<div class="modal-backdrop" data-close></div>
|
||||
<div class="modal-panel modal-panel-sm quick-search-panel">
|
||||
<div class="quick-search-input">
|
||||
<svg><use href="#i-search"/></svg>
|
||||
<!-- Retour a la liste des entrees, visible dans une note de commandes. -->
|
||||
<button type="button" class="icon-btn icon-btn-sm" id="qsBack" title="Back to entries (Esc)" hidden><svg class="so-hist-back"><use href="#i-chevron-down"/></svg></button>
|
||||
<svg id="qsSearchIcon"><use href="#i-search"/></svg>
|
||||
<input id="quickSearchInput" type="text"
|
||||
placeholder="Search…"
|
||||
autocomplete="off">
|
||||
|
||||
+16
-4
@@ -51,7 +51,7 @@ function renderHotkeyConflicts() {
|
||||
|
||||
// Format a combo for human display: "Ctrl+Shift+L".
|
||||
function autofillComboLabel(c) {
|
||||
if (!c || !c.key) return '— not set —';
|
||||
if (!c || !c.key) return 'Off';
|
||||
const parts = [];
|
||||
if (c.ctrl) parts.push('Ctrl');
|
||||
if (c.alt) parts.push('Alt');
|
||||
@@ -92,6 +92,9 @@ function autofillComboValid(c) {
|
||||
function autofillCaptureFromEvent(e) {
|
||||
const k = e.key;
|
||||
if (k === 'Escape') return 'cancel';
|
||||
// Suppr / Retour arriere pendant la capture = raccourci DESACTIVE : il
|
||||
// n'est plus enregistre, la combinaison reste libre pour une autre appli.
|
||||
if (k === 'Delete' || k === 'Backspace') return 'off';
|
||||
// Ignore pure-modifier keydowns (user is still building the combo).
|
||||
if (k === 'Control' || k === 'Shift' || k === 'Alt' ||
|
||||
k === 'Meta' || k === 'OS') return null;
|
||||
@@ -235,8 +238,10 @@ function autofillReportResult(ok) {
|
||||
|
||||
// Called by Bridge.onAutofillRequest when a hotkey fires.
|
||||
// kind: 'full' = Ctrl+Shift+L (user + Tab + pwd) ; 'password' = Ctrl+Shift+P.
|
||||
async function autofillHandleRequest(windowTitle, kind) {
|
||||
if (!state.autofillEnabled) return;
|
||||
async function autofillHandleRequest(windowTitle, kind, fromBar) {
|
||||
// L'option ne coupe que les RACCOURCIS (« Autofill with global hotkey ») :
|
||||
// la barrette est un geste explicite, elle remplit quand meme.
|
||||
if (!state.autofillEnabled && !fromBar) return;
|
||||
|
||||
// A blocking operation owns the screen (export, sync, rotation). Covers all
|
||||
// three hotkeys and both outcomes: the multi-match picker would open behind
|
||||
@@ -268,7 +273,14 @@ async function autofillHandleRequest(windowTitle, kind) {
|
||||
else pwd.focus();
|
||||
}
|
||||
}, 80);
|
||||
toast('Vault is locked — unlock to autofill', 'warning');
|
||||
// LA RAISON AVEC LE MESSAGE. Sans dernier verrouillage connu, c'est que
|
||||
// cette page n'a jamais ete deverrouillee - rechargee entre-temps, ou
|
||||
// jamais ouverte : l'heure de chargement le tranche.
|
||||
toast('Vault is locked — unlock to autofill ('
|
||||
+ (state.lastLockReason
|
||||
? 'locked by: ' + state.lastLockReason
|
||||
: 'never unlocked since the page loaded at ' + state.pageLoadedAt)
|
||||
+ ')', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -95,7 +95,8 @@ const Bridge = (() => {
|
||||
toast('System lock — vault kept unlocked (quick unlock active)');
|
||||
return;
|
||||
}
|
||||
if (typeof lockVault === 'function') lockVault();
|
||||
if (typeof lockVault === 'function')
|
||||
lockVault('Windows session locked or computer asleep');
|
||||
},
|
||||
|
||||
// Called by Delphi (ExecuteJavaScript) when the user restores the
|
||||
@@ -116,8 +117,8 @@ const Bridge = (() => {
|
||||
// Called by Delphi when a hotkey fires. windowTitle = foreground
|
||||
// window title at hotkey time. kind = "full" (user+Tab+pwd) or
|
||||
// "password" (password only — for step-2 forms, unlock screens).
|
||||
onAutofillRequest(windowTitle, kind) {
|
||||
autofillHandleRequest(windowTitle, kind || 'full');
|
||||
onAutofillRequest(windowTitle, kind, fromBar) {
|
||||
autofillHandleRequest(windowTitle, kind || 'full', !!fromBar);
|
||||
},
|
||||
|
||||
// Called by Delphi after cmd://autofill/execute with whether the
|
||||
@@ -142,14 +143,14 @@ const Bridge = (() => {
|
||||
// back to the tray AFTER SendInput completes — necessary for the
|
||||
// Ctrl+Shift+Q-from-tray flow (we cannot hide before SendInput or
|
||||
// Win10/11 anti-focus-stealing rules block the target).
|
||||
executeAutofill(username, password, hideAfter, field) {
|
||||
executeAutofill(username, password, hideAfter, field, noClear) {
|
||||
if (!active) return;
|
||||
// field='user' → type only the username (no Tab / password).
|
||||
cmd('cmd://autofill/execute?username=' + encodeURIComponent(username) +
|
||||
'&password=' + encodeURIComponent(password) +
|
||||
(hideAfter ? '&hide_after=1' : '') +
|
||||
(field === 'user' ? '&field=user' : '') +
|
||||
(state.autofillClearField ? '' : '&clear=0') +
|
||||
(state.autofillClearField && !noClear ? '' : '&clear=0') +
|
||||
(state.autofillFailBalloon ? '' : '¬ify=0'));
|
||||
},
|
||||
|
||||
@@ -732,6 +733,15 @@ const state = {
|
||||
selectedId: null,
|
||||
theme: localStorage.getItem('theme') || 'dark',
|
||||
locked: false, // true after user clicks Lock (token still valid server-side)
|
||||
// Entrees ouvertes, pour Precedent / Suivant (fleches du panneau, Alt+fleches).
|
||||
// En memoire seulement, videe au verrouillage.
|
||||
entryHistory: { ids: [], pos: -1 },
|
||||
// POURQUOI LE COFFRE S'EST FERME, et depuis quand la page vit. Un
|
||||
// verrouillage automatique etait muet ; quand le remplissage repondait
|
||||
// "Vault is locked", rien ne disait si c'etait l'inactivite, une veille de
|
||||
// Windows, ou une page rechargee qui avait tout perdu.
|
||||
lastLockReason: '',
|
||||
pageLoadedAt: new Date().toLocaleTimeString(),
|
||||
autoLock: parseInt(localStorage.getItem('autoLockMin') || '5'),
|
||||
askBeforeDelete: localStorage.getItem('askBeforeDelete') !== '0', // default true
|
||||
maskUsernames: localStorage.getItem('maskUsernames') === '1', // default false
|
||||
@@ -1424,11 +1434,13 @@ async function confirmDiscardForSessionExit(action) {
|
||||
});
|
||||
}
|
||||
|
||||
function lockVault() {
|
||||
function lockVault(reason) {
|
||||
state.lastLockReason = (reason || 'manual lock') + ' at ' + new Date().toLocaleTimeString();
|
||||
sessionStorage.removeItem('cryptoKey');
|
||||
state.cryptoKey = null;
|
||||
state.entries = [];
|
||||
state.trashed = [];
|
||||
state.entryHistory = { ids: [], pos: -1 };
|
||||
state.locked = true;
|
||||
state.justRecovered = false;
|
||||
state.avatarDataUri = ''; // reloaded from server on next unlock
|
||||
@@ -2886,6 +2898,7 @@ function templateLabel(tpl) {
|
||||
case 'ssh-key': return 'SSH key';
|
||||
case 'server': return 'Server';
|
||||
case 'recovery-codes': return 'Recovery codes';
|
||||
case 'commands': return 'Commands';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
@@ -4003,6 +4016,14 @@ const ENTRY_TEMPLATES = {
|
||||
{ label: 'Root password', value: '', is_secret: true },
|
||||
],
|
||||
},
|
||||
// NOTE DE COMMANDES : le corps reste un texte (deja chiffre, synchronise,
|
||||
// exporte, lu par l'APK) ; seul l'affichage le decoupe (parseCommandNote).
|
||||
'commands': {
|
||||
kind: 'note',
|
||||
title: 'Commands',
|
||||
body: '# Group\nssh user@host\n',
|
||||
customFields: [],
|
||||
},
|
||||
'recovery-codes': {
|
||||
kind: 'note',
|
||||
title: 'Recovery codes',
|
||||
@@ -4014,6 +4035,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
|
||||
// brand-new one (id = null). opts: { presetTitle, presetSite, template }
|
||||
// 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 (!isNew && !e) return false;
|
||||
if (!isNew && !opts.fromHistory) entryHistoryPush(state.entryHistory, id);
|
||||
renderEntryHistoryNav();
|
||||
|
||||
// The editor must be visible when it opens: Settings sits ABOVE the
|
||||
// slideover, so a hotkey (Ctrl+Shift+A) fired with Settings open left
|
||||
@@ -4154,6 +4245,7 @@ async function openSlideOver(id, opts) {
|
||||
// injected by the template picker on new. Sent back to the server
|
||||
// on Save so the card/table can label it correctly.
|
||||
template: isNew ? (opts.presetTemplate || '') : (e.template || ''),
|
||||
originalTemplate: isNew ? '' : (e.template || ''),
|
||||
// Attachments staged BEFORE the entry exists server-side. Empty on
|
||||
// edit (existing attachments are fetched live via the dedicated
|
||||
// /entries/:id/attachments endpoint). Flushed by soSave after the
|
||||
@@ -4184,6 +4276,7 @@ async function openSlideOver(id, opts) {
|
||||
// Notes: minimal layout — name + multiline body + folder + tags.
|
||||
// No icon (covered by sidebar icon), no site/user/totp.
|
||||
body.appendChild(soEditableField('Title', 'soTitle', soState.original.title));
|
||||
body.appendChild(soCommandsField());
|
||||
body.appendChild(soNoteBodyField(plain));
|
||||
const hist = soHistoryButton();
|
||||
if (hist) body.appendChild(hist);
|
||||
@@ -4560,6 +4653,67 @@ function buildCustomFieldRow(field, idx, rerender) {
|
||||
|
||||
// Multiline note body. Maps to soState.original.password and the
|
||||
// encrypted_password+iv columns (same crypto pipeline as login passwords).
|
||||
// Liste des commandes d'une note : visible quand le modele est "commands",
|
||||
// suit le texte au fil de la frappe. Une case bascule le modele (sauvegarde =
|
||||
// enregistrement de l'entree). Copier seulement : ici la cible d'un
|
||||
// remplissage serait l'application elle-meme - le remplissage passe par la
|
||||
// recherche rapide (Ctrl+Shift+Q), qui connait la fenetre d'origine.
|
||||
function soCommandsField() {
|
||||
const wrap = el('div', { class: 'slideover-field so-cmds' });
|
||||
const lbl = el('label', { class: 'so-cmds-toggle' });
|
||||
const cb = el('input', { type: 'checkbox' });
|
||||
cb.checked = soState.template === 'commands';
|
||||
lbl.appendChild(cb);
|
||||
lbl.appendChild(document.createTextNode(' Command list (# Group, one command per line)'));
|
||||
wrap.appendChild(lbl);
|
||||
const list = el('div', { class: 'so-cmds-list' });
|
||||
wrap.appendChild(list);
|
||||
const draw = () => {
|
||||
list.innerHTML = '';
|
||||
list.hidden = soState.template !== 'commands';
|
||||
if (list.hidden) return;
|
||||
const ta = document.getElementById('soNoteBody');
|
||||
const cmds = parseCommandNote(ta ? ta.value : '');
|
||||
if (!cmds.length) {
|
||||
list.appendChild(el('div', { class: 'so-cmds-empty' }, 'No command yet - type them below.'));
|
||||
return;
|
||||
}
|
||||
let last = null;
|
||||
cmds.forEach(c => {
|
||||
if (c.group !== last) {
|
||||
last = c.group;
|
||||
if (c.group) list.appendChild(el('div', { class: 'so-cmds-group' }, c.group));
|
||||
}
|
||||
const row = el('div', { class: 'so-cmds-row' });
|
||||
row.appendChild(el('code', { class: 'so-cmds-cmd' },
|
||||
state.maskNotes ? maskCommand(c.cmd) : c.cmd));
|
||||
const b = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Copy command' });
|
||||
b.appendChild(icon('i-copy'));
|
||||
b.addEventListener('click', async ev => {
|
||||
ev.stopPropagation();
|
||||
if (Bridge.active) Bridge.copySecure(c.cmd, 30000);
|
||||
else { try { await navigator.clipboard.writeText(c.cmd); } catch (_) {} }
|
||||
toast('Command copied');
|
||||
});
|
||||
row.appendChild(b);
|
||||
list.appendChild(row);
|
||||
});
|
||||
};
|
||||
cb.addEventListener('change', ev => {
|
||||
ev.stopPropagation();
|
||||
soState.template = cb.checked ? 'commands' : '';
|
||||
draw();
|
||||
soDirtyCheck();
|
||||
});
|
||||
// Le textarea est ajoute juste apres : on s'y branche au tick suivant.
|
||||
setTimeout(() => {
|
||||
const ta = document.getElementById('soNoteBody');
|
||||
if (ta) ta.addEventListener('input', draw);
|
||||
draw();
|
||||
}, 0);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function soNoteBodyField(value) {
|
||||
const wrap = el('div', { class: 'slideover-field' });
|
||||
const lbl = el('div', { class: 'slideover-field-label so-note-label' }, 'Note');
|
||||
@@ -4582,6 +4736,8 @@ function soNoteBodyField(value) {
|
||||
state.maskNotes = !state.maskNotes;
|
||||
localStorage.setItem('maskNotes', state.maskNotes ? '1' : '0');
|
||||
ta.classList.toggle('is-masked', state.maskNotes);
|
||||
// La liste des commandes suit l'oeil (elle ecoute 'input').
|
||||
ta.dispatchEvent(new Event('input'));
|
||||
saveServerSettings();
|
||||
});
|
||||
wrap.appendChild(ta);
|
||||
@@ -5040,6 +5196,9 @@ function isSoDirty() {
|
||||
tags: soState.tags.join(','),
|
||||
};
|
||||
const curCustom = JSON.stringify(soState.customFields || []);
|
||||
// La case "Command list" change le modele sans toucher au texte.
|
||||
if (soState.id != null && (soState.template || '') !== (soState.originalTemplate || ''))
|
||||
return true;
|
||||
if (soState.id == null) {
|
||||
const orig = soState.original || {};
|
||||
const staged = (soState.pendingAttachments || []).length > 0;
|
||||
@@ -7010,7 +7169,7 @@ function resetAutoLock() {
|
||||
autoLockTimer = setTimeout(() => {
|
||||
hideIdleWarning();
|
||||
toast('Auto-locked due to inactivity', 'warning');
|
||||
lockVault();
|
||||
lockVault('inactivity (' + state.autoLock + ' min)');
|
||||
}, totalMs);
|
||||
}
|
||||
|
||||
@@ -7185,13 +7344,18 @@ async function enterApp() {
|
||||
if (!ok) {
|
||||
const fresh = await bridgePinStatus();
|
||||
state.pinConfigured = fresh;
|
||||
lockVault();
|
||||
lockVault('wrong PIN after the master password');
|
||||
$('#authHint').textContent = fresh
|
||||
? 'Wrong PIN. Try again.'
|
||||
: 'Too many wrong PIN attempts — PIN has been removed. Re-set it from Settings after unlocking.';
|
||||
return;
|
||||
}
|
||||
}
|
||||
// OUVERT, QUEL QUE SOIT LE CHEMIN. Seul doUnlock remettait ce drapeau : un
|
||||
// verrou Windows avant toute connexion (demarrage dans le tray) le posait,
|
||||
// la connexion complete ne le levait pas, et l'autofill refusait un coffre
|
||||
// affiche ouvert.
|
||||
state.locked = false;
|
||||
$('#authScreen').classList.add('is-hidden');
|
||||
$('#appShell').classList.remove('is-hidden');
|
||||
$('#userName').textContent = state.username;
|
||||
@@ -7580,6 +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 && e.shiftKey && /^[nNwW]$/.test(e.key)) return e.preventDefault(); // New incognito / Close window
|
||||
if (mod && e.shiftKey && e.key === 'Delete') return e.preventDefault(); // Clear browsing data
|
||||
// Alt+fleches : Precedent / Suivant parmi les entrees ouvertes. Le
|
||||
// preventDefault empeche aussi la page elle-meme de "reculer".
|
||||
if (e.altKey && !mod && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
|
||||
e.preventDefault();
|
||||
if (!state.locked && state.cryptoKey && !document.querySelector('.modal:not(.is-hidden)'))
|
||||
entryHistoryGo(e.key === 'ArrowLeft' ? -1 : +1);
|
||||
}
|
||||
});
|
||||
document.addEventListener('keydown', handleCardCursorKey);
|
||||
|
||||
@@ -7785,6 +7956,8 @@ async function init() {
|
||||
|
||||
// Slide-over
|
||||
$('#slideoverClose').addEventListener('click', () => requestCloseSlideOver());
|
||||
$('#slideoverBack').addEventListener('click', () => entryHistoryGo(-1));
|
||||
$('#slideoverFwd').addEventListener('click', () => entryHistoryGo(+1));
|
||||
// Click outside the slide-over closes it. Clicks on cards re-open it for
|
||||
// another entry (so we don't close in that case; the card's own handler
|
||||
// will switch state.selectedId).
|
||||
@@ -8030,6 +8203,9 @@ async function init() {
|
||||
if (e.key !== 'Escape') return;
|
||||
if (!$('#settingsPanel').classList.contains('is-open')) return;
|
||||
if (document.querySelector('.modal:not(.is-hidden)')) return;
|
||||
// Capture de raccourci armee : Esc l'annule (son propre ecouteur,
|
||||
// enregistre APRES celui-ci), il ne ferme pas les reglages.
|
||||
if (document.querySelector('[data-capturing="1"]')) return;
|
||||
// Busy overlay up (sync/backup running) — don't close under it.
|
||||
if ($('#busyOverlay') && !$('#busyOverlay').classList.contains('is-hidden')) return;
|
||||
// When the search box has focus AND a query, the input's own
|
||||
@@ -8377,7 +8553,7 @@ async function init() {
|
||||
btn.dataset.capturing = '1';
|
||||
btn.classList.add('is-capturing');
|
||||
const original = btn.textContent;
|
||||
btn.textContent = 'Press combo… (Esc to cancel)';
|
||||
btn.textContent = 'Press combo… (Esc cancel · Del off)';
|
||||
// Free our own combos so they reach this listener instead of
|
||||
// firing their hotkey. Restored by finish() / the success path.
|
||||
hotkeyPushSilent = true;
|
||||
@@ -8432,6 +8608,20 @@ async function init() {
|
||||
return;
|
||||
}
|
||||
if (captured === 'cancel') { finish(true); return; }
|
||||
// Cle vide = vk 0 : Delphi n'enregistre pas ce raccourci.
|
||||
if (captured === 'off') {
|
||||
const off = { ctrl: false, shift: false, alt: false, win: false, key: '' };
|
||||
if (kind === 'full') state.autofillHotkeyFull = off;
|
||||
else if (kind === 'password') state.autofillHotkeyPwd = off;
|
||||
else if (kind === 'totp') state.autofillHotkeyTotp = off;
|
||||
else if (kind === 'quickSearch') state.quickSearchHotkey = off;
|
||||
else /* newEntry */ state.newEntryHotkey = off;
|
||||
btn.textContent = autofillComboLabel(off);
|
||||
finish(false);
|
||||
autofillPushHotkeys();
|
||||
saveServerSettings();
|
||||
return;
|
||||
}
|
||||
if (captured === 'invalid'){
|
||||
toast('Unsupported key — use a letter, digit or F-key', 'warning');
|
||||
finish(true);
|
||||
@@ -8740,7 +8930,11 @@ async function init() {
|
||||
// document-level handlers, which now see "no modal open"
|
||||
// (we just closed it) and also close the dirty slideover.
|
||||
e.stopPropagation();
|
||||
if (qsCommands) { qsLeaveCommands(); return; } // liste -> entrees
|
||||
closeQuickSearchModal();
|
||||
} else if (e.key === 'Backspace' && qsCommands && !qsInput.value) {
|
||||
e.preventDefault();
|
||||
qsLeaveCommands(); // retour, comme Esc
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (rows.length) { quickSearchSelected++; quickSearchRender(); }
|
||||
@@ -8751,6 +8945,11 @@ async function init() {
|
||||
e.preventDefault();
|
||||
const sel = rows[quickSearchSelected];
|
||||
if (!sel) return;
|
||||
if (qsCommands) {
|
||||
qsPickCommand(qsCommands[parseInt(sel.dataset.cmd, 10)].cmd,
|
||||
e.ctrlKey || e.metaKey);
|
||||
return;
|
||||
}
|
||||
const id = parseInt(sel.dataset.id, 10);
|
||||
const entry = state.entries.find(x => x.id === id);
|
||||
// Shift+Enter → full (user + Tab + password), Ctrl+Enter →
|
||||
|
||||
+109
-2
@@ -33,6 +33,88 @@ let quickSearchFillMode = false;
|
||||
// DPAPI : localStorage se vide a chaque lancement (port ephemere).
|
||||
let quickSearchPreferCopy = false;
|
||||
|
||||
// NOTE DE COMMANDES ouverte dans la recherche rapide : la liste remplace les
|
||||
// entrees, la saisie la filtre, Esc revient aux entrees. null = liste normale.
|
||||
let qsCommands = null;
|
||||
|
||||
async function qsEnterCommands(entry) {
|
||||
const body = await decryptPwd(entry.encrypted_password, entry.iv);
|
||||
if (body === '[ERROR]') { toast('Decryption error', 'error'); return; }
|
||||
qsCommands = parseCommandNote(body);
|
||||
const input = document.getElementById('quickSearchInput');
|
||||
input.value = '';
|
||||
input.placeholder = entryDisplayName(entry) + ' - filter commands… (Esc = back)';
|
||||
quickSearchSelected = 0;
|
||||
qsApplyMode();
|
||||
quickSearchRender();
|
||||
input.focus();
|
||||
}
|
||||
|
||||
function qsLeaveCommands() {
|
||||
qsCommands = null;
|
||||
const input = document.getElementById('quickSearchInput');
|
||||
input.value = '';
|
||||
input.placeholder = 'Search…';
|
||||
quickSearchSelected = 0;
|
||||
qsApplyMode();
|
||||
quickSearchRender();
|
||||
}
|
||||
|
||||
// Une commande, une ligne, SANS Entree : l'utilisateur valide lui-meme. Ni
|
||||
// Ctrl+A ni Suppr avant (noClear) - dans un terminal Ctrl+A ramene en debut de
|
||||
// ligne, ce ne serait pas "tout selectionner".
|
||||
async function qsPickCommand(cmd, forceCopy) {
|
||||
if (qsFills() && !forceCopy) {
|
||||
autofillPendingToast = 'Command sent';
|
||||
if (Bridge.active) Bridge.executeAutofill('', cmd, quickSearchHideAfter, undefined, true);
|
||||
quickSearchFillMode = false;
|
||||
quickSearchHideAfter = false;
|
||||
qsCommands = null;
|
||||
closeQuickSearchModal();
|
||||
return;
|
||||
}
|
||||
if (Bridge.active) Bridge.copySecure(cmd, 30000);
|
||||
else { try { await navigator.clipboard.writeText(cmd); } catch (_) {} }
|
||||
toast('Command copied');
|
||||
qsCommands = null;
|
||||
closeQuickSearchModal(true);
|
||||
}
|
||||
|
||||
function qsRenderCommands(box, q) {
|
||||
const ql = q.toLowerCase();
|
||||
const list = qsCommands
|
||||
.map((c, i) => ({ c, i }))
|
||||
.filter(x => !ql || x.c.cmd.toLowerCase().includes(ql) || x.c.group.toLowerCase().includes(ql));
|
||||
if (!list.length) {
|
||||
box.appendChild(el('div', { class: 'quick-search-empty' },
|
||||
q ? 'No command matches "' + q + '"' : 'No command in this note'));
|
||||
quickSearchSelected = 0;
|
||||
return;
|
||||
}
|
||||
if (quickSearchSelected >= list.length) quickSearchSelected = 0;
|
||||
if (quickSearchSelected < 0) quickSearchSelected = list.length - 1;
|
||||
list.forEach((x, n) => {
|
||||
const row = el('div', {
|
||||
class: 'quick-search-row' + (n === quickSearchSelected ? ' is-selected' : ''),
|
||||
'data-cmd': String(x.i),
|
||||
});
|
||||
const main = el('div', { class: 'quick-search-main' });
|
||||
main.appendChild(el('div', { class: 'quick-search-name qs-cmd' },
|
||||
state.maskNotes ? maskCommand(x.c.cmd) : x.c.cmd));
|
||||
if (x.c.group) main.appendChild(el('div', { class: 'quick-search-sub' }, x.c.group));
|
||||
row.appendChild(main);
|
||||
const acts = el('div', { class: 'quick-search-acts' });
|
||||
const b = el('button', { class: 'icon-btn icon-btn-sm', type: 'button',
|
||||
title: qsFills() ? 'Copy instead of typing' : 'Copy command' });
|
||||
b.appendChild(icon('i-copy'));
|
||||
b.addEventListener('click', ev => { ev.stopPropagation(); qsPickCommand(x.c.cmd, true); });
|
||||
acts.appendChild(b);
|
||||
row.appendChild(acts);
|
||||
row.addEventListener('click', ev => qsPickCommand(x.c.cmd, ev.ctrlKey || ev.metaKey));
|
||||
box.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
/// Remplit-on vraiment ? Il faut une cible ET ne pas avoir choisi Copy.
|
||||
function qsFills() { return quickSearchFillMode && !quickSearchPreferCopy; }
|
||||
|
||||
@@ -43,8 +125,16 @@ function qsApplyMode() {
|
||||
box.querySelectorAll('[data-qsmode]').forEach(b =>
|
||||
b.classList.toggle('is-on', (b.dataset.qsmode === 'fill') === qsFills()));
|
||||
}
|
||||
const back = document.getElementById('qsBack');
|
||||
if (back) back.hidden = !qsCommands;
|
||||
const ico = document.getElementById('qsSearchIcon');
|
||||
if (ico) ico.style.display = qsCommands ? 'none' : '';
|
||||
const hintEl = document.querySelector('#quickSearchModal .quick-search-hint');
|
||||
if (hintEl) {
|
||||
if (hintEl && qsCommands) {
|
||||
hintEl.textContent = qsFills()
|
||||
? 'Enter / click = type the command (no Enter sent) · Ctrl+Enter / Ctrl+click = copy · Esc = back'
|
||||
: 'Enter / click = copy the command · Esc = back';
|
||||
} else if (hintEl) {
|
||||
hintEl.textContent = qsFills()
|
||||
? 'Enter / click = password · Shift+Enter / right-click = user+password · Ctrl+Enter / Ctrl+click = username · Esc = cancel'
|
||||
: 'Enter / click = copy password · Ctrl+Enter / Ctrl+click = copy username · Esc = close';
|
||||
@@ -52,6 +142,12 @@ function qsApplyMode() {
|
||||
}
|
||||
|
||||
document.addEventListener('click', ev => {
|
||||
if (ev.target.closest && ev.target.closest('#qsBack')) {
|
||||
ev.stopPropagation();
|
||||
qsLeaveCommands();
|
||||
document.getElementById('quickSearchInput').focus();
|
||||
return;
|
||||
}
|
||||
const b = ev.target.closest && ev.target.closest('[data-qsmode]');
|
||||
if (!b) return;
|
||||
quickSearchPreferCopy = b.dataset.qsmode === 'copy';
|
||||
@@ -80,6 +176,12 @@ function quickSearchScoreEntry(e, q) {
|
||||
|
||||
function quickSearchRender() {
|
||||
const q = document.getElementById('quickSearchInput').value.trim();
|
||||
if (qsCommands) {
|
||||
const box = document.getElementById('quickSearchResults');
|
||||
box.innerHTML = '';
|
||||
qsRenderCommands(box, q);
|
||||
return;
|
||||
}
|
||||
const list = state.entries
|
||||
.map(e => ({ e, s: quickSearchScoreEntry(e, q) }))
|
||||
.filter(x => x.s > 0)
|
||||
@@ -134,7 +236,9 @@ function quickSearchRender() {
|
||||
if (e.username) act('i-user', verb + 'username', 'user');
|
||||
// UNE NOTE SE COPIE TOUJOURS, meme en Fill : tapee, chacun de ses
|
||||
// retours a la ligne partirait comme un Entree dans le formulaire.
|
||||
if (e.kind === 'note')
|
||||
if (e.template === 'commands')
|
||||
act('i-note', 'Open the command list', 'cmds');
|
||||
else if (e.kind === 'note')
|
||||
act('i-note', qsFills() ? 'Copy note (a note is never typed)' : 'Copy note', 'note');
|
||||
else act('i-key', verb + 'password', 'pwd');
|
||||
if (e.totp_secret && e.totp_iv) act('i-clock', verb + '2FA code', 'totp');
|
||||
@@ -162,6 +266,8 @@ function quickSearchRender() {
|
||||
// comme des Entree dans le formulaire cible.
|
||||
async function quickSearchPickEntry(entry, mode) {
|
||||
mode = mode || 'full';
|
||||
// Note de commandes : on choisit UNE commande, jamais la note entiere.
|
||||
if (entry.template === 'commands' && mode !== 'user') return qsEnterCommands(entry);
|
||||
|
||||
if (mode === 'totp') {
|
||||
const secret = await decryptTotpSecret(entry.totp_secret, entry.totp_iv);
|
||||
@@ -559,6 +665,7 @@ function openQuickSearchModal(hideAfter, forFill) {
|
||||
const modal = document.getElementById('quickSearchModal');
|
||||
const input = document.getElementById('quickSearchInput');
|
||||
modal.classList.remove('is-hidden');
|
||||
if (qsCommands) qsLeaveCommands();
|
||||
input.value = '';
|
||||
quickSearchSelected = 0;
|
||||
quickSearchHideAfter = !!hideAfter;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -156,6 +156,10 @@ function loadApp(overrides = {}) {
|
||||
syncIsGitea, giteaUnwrap, syncSnapshotsEqual,
|
||||
// metadata-at-rest
|
||||
withEncryptedMeta, decryptEntryMeta, ENCRYPTED_META_FIELDS,
|
||||
// historique des entrees ouvertes
|
||||
entryHistoryPush, entryHistoryTarget, ENTRY_HISTORY_MAX,
|
||||
// note de commandes
|
||||
parseCommandNote, maskCommand,
|
||||
};
|
||||
`;
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user