Compare commits
52 Commits
dcb1ac63ce
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 092eb2f73a | |||
| bf7a8f4d38 | |||
| 9999dd113d | |||
| a3e63ee8f1 | |||
| 6397ed008a | |||
| b1c0c1e11b | |||
| f112de508b | |||
| a0527eb806 | |||
| 6ca1e4b2cf | |||
| bbd4e2ef06 | |||
| 63e532d5bb | |||
| 28f8a42846 | |||
| 27afc4e8ca | |||
| e8e93cba56 | |||
| 553d9db621 | |||
| 84a349e54d | |||
| 4d9cb909c4 | |||
| c78b80a073 | |||
| 0604344c60 | |||
| 3051c3c392 | |||
| 2cc8d1b62b | |||
| bcbbcebe16 | |||
| 247771bfec | |||
| 8d3e20b702 | |||
| 1309868f27 | |||
| e786d8fe14 | |||
| 28349c10c8 | |||
| 7c26716831 | |||
| 5e4fcfad0f | |||
| 3acf78b1ca | |||
| 22c418f933 | |||
| 64195fc641 | |||
| 074e6f92e7 | |||
| 75a1169dc9 | |||
| a89e227272 | |||
| fc7e89a5d5 | |||
| 24614d71ba | |||
| 329588dc1c | |||
| 9ae7b58767 | |||
| 2a461cad26 | |||
| e0f8bf441f | |||
| 49738d0b74 | |||
| 29f737b831 | |||
| 0f90b816fc | |||
| 0c12997ad4 | |||
| 5f1a98df7d | |||
| 6a98f113ee | |||
| 29d569c7ba | |||
| 1ae1d482f0 | |||
| c1c7d9cd59 | |||
| 652815ea98 | |||
| cfa2a798e5 |
@@ -34,6 +34,13 @@
|
||||
<!-- Scanner du QR des reglages de synchro, demandee au premier appui. -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
<!-- Choisir une icone dans la galerie. READ_MEDIA_IMAGES est le nom
|
||||
d'Android 13 et plus, READ_EXTERNAL_STORAGE celui d'avant ; la
|
||||
seconde est donc bornee aux anciennes versions pour ne pas demander
|
||||
un acces large la ou le selecteur recent n'exige rien. -->
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-feature android:glEsVersion="0x00020000" android:required="true"/>
|
||||
<queries>
|
||||
<%queries-child-elements%>
|
||||
|
||||
@@ -198,6 +198,19 @@ Retour Delphi → JS via `WebBrowser.ExecuteJavaScript('Bridge.onX(...)')`.
|
||||
Combos autofill configurables depuis Settings (synced en DB
|
||||
via `settings_json`).
|
||||
|
||||
**Desactiver un raccourci (2026-10-05)** : clic sur le bouton, puis **Suppr**
|
||||
(ou Retour arriere) → `key: ''` → vk 0, que Delphi n'enregistre pas ; libelle
|
||||
*Off*. Les combinaisons sont aussi **retenues en pref DPAPI `hotkeys`** (la
|
||||
requete `autofill/hotkeys` telle quelle, hors suspension de capture) et
|
||||
**rejouees au demarrage** a la place des valeurs par defaut : `settings_json`
|
||||
n'arrive qu'apres la connexion, et un `Ctrl+Shift+A` desactive restait pris
|
||||
tant que le coffre etait ferme. Piege corrige au passage : l'Esc des reglages
|
||||
(ecouteur `document` en capture, enregistre avant) fermait Settings au lieu
|
||||
d'annuler la capture — il ignore Esc tant qu'un `[data-capturing="1"]` existe.
|
||||
**La barrette remplit meme raccourcis coupes** : `onAutofillRequest` recoit
|
||||
`fromBar`, l'option « Autofill with global hotkey » ne bloque que les
|
||||
raccourcis.
|
||||
|
||||
## Fill bar (remplir à la souris)
|
||||
|
||||
Menu du tray → **Fill bar** : une barrette de trois
|
||||
@@ -262,6 +275,26 @@ Détails :
|
||||
`TrackPopupMenu`, qui est modal) : le menu déroulant est lui aussi dans la
|
||||
bande `TOPMOST`, et se faisait donc recouvrir par la barrette qui venait de
|
||||
l'ouvrir.
|
||||
- **Posée SUR la barre des tâches, elle disparaît quand celle-ci est active**
|
||||
(clic sur Démarrer, clic sur la barre) et revient au premier clic sur une
|
||||
autre fenêtre. Mesuré le 2026-09-22 : la barrette reste au bon endroit,
|
||||
au-dessus de `Shell_TrayWnd` dans l'ordre des fenêtres, et n'est pas masquée
|
||||
par DWM (`DWMWA_CLOAKED` = 0) — elle n'est simplement plus dessinée. La barre
|
||||
des tâches active occupe une **bande réservée au shell**, au-dessus de tout
|
||||
`TOPMOST` ordinaire. Deux contournements essayés, tous deux sans effet :
|
||||
`NOTOPMOST` puis `TOPMOST` (les appels rendent True), et le même avec
|
||||
`AttachThreadInput` sur le fil de la barre des tâches. Seul `uiAccess=true`
|
||||
(exe signé, dans `C:\Program Files\`) y accède. **Assumé** : l'utilisateur a
|
||||
choisi de garder sa position plutôt que d'être remonté automatiquement
|
||||
(2026-09-22). Le battement ne redemande donc `TOPMOST` que lorsque le centre
|
||||
de la barrette appartient à une autre fenêtre — utile contre les autres
|
||||
fenêtres `TOPMOST`, impuissant contre le shell. **Deux pièges corrigés le
|
||||
2026-09-28** : les fenêtres de **notre propre processus** ne comptent pas
|
||||
comme un recouvrement (l'info-bulle d'un bouton se pose par-dessus la
|
||||
barrette, et la remontée qu'elle déclenchait la refermait — boucle infinie
|
||||
info-bulle / barrette invisible) ; et la remontée est un simple `TOPMOST`,
|
||||
**sans détour par `NOTOPMOST`**, qui faisait passer la barrette sous les
|
||||
fenêtres ordinaires un instant — un flash à chaque battement.
|
||||
- **Traîner depuis un bouton** : les boutons sont subclassés (`GWLP_WNDPROC`,
|
||||
une seule proc d'origine — même classe). Sur `WM_LBUTTONDOWN`, **`DragDetect`**
|
||||
tranche entre clic et glissement en respectant le seuil réglé par
|
||||
@@ -283,6 +316,16 @@ Détails :
|
||||
Le cadre est peint dans `WM_ERASEBKGND` plutôt que par `WS_BORDER`, qui se
|
||||
sert dans les couleurs système.
|
||||
|
||||
**Bouton Paste (2026-10-14)** : `LAZY_ID_PASTE` / cle `paste` (desactive par
|
||||
defaut) → `PasteClipboardAsKeystrokes` lit le presse-papiers **Windows**
|
||||
(`CF_UNICODETEXT`) et le **tape** dans la fenetre en avant par
|
||||
`ExecuteAutofill(..., AClearFirst=False)` - donc vraies touches et bascule de
|
||||
disposition vers un client VNC (TigerVNC vers un Mac ne transmet pas le
|
||||
presse-papiers). Insere au curseur, saut de ligne final retire (un terminal
|
||||
lancerait la commande), sauts internes tapes en Entree. Marche coffre ferme :
|
||||
rien ne passe par la page. Pas de texte (image, fichier) → bulle « No text in
|
||||
the clipboard ».
|
||||
|
||||
### Réglages de la barrette (2026-09-18)
|
||||
|
||||
**Settings → onglet Fill bar**, et le **clic droit** sur la barrette pour les
|
||||
@@ -493,7 +536,10 @@ faire semblant couterait plus que de l'admettre.
|
||||
|
||||
**Le reste du travail Android est dans
|
||||
[android-client/README.md](android-client/README.md)**, tenu a jour le
|
||||
2026-09-17 : les trois facons d'ouvrir le coffre (verrouillage d'ecran, code
|
||||
2026-09-28 — dont le formulaire d'edition de l'entree entiere, le scan du QR
|
||||
2FA, la verification a l'ouverture au lieu du retelechargement, et deux pieges
|
||||
de la page (`view` qui defocalisait les champs, `toast` qui etait un `<div>`) ;
|
||||
et avant cela, le 2026-09-17 : les trois facons d'ouvrir le coffre (verrouillage d'ecran, code
|
||||
d'application, mot de passe), la copie de secours `vault-recover.bin` qui
|
||||
survit au schema retire, le scanner de QR (`ThirdParty/ZXing`), le define
|
||||
`AllowCreateLocal` — creer un coffre sur le telephone est reserve au debogage —
|
||||
@@ -774,7 +820,10 @@ bureau virtuel** en mémoire (GDI, octets bruts, jamais sur disque) et le décod
|
||||
par ZXing.Delphi — le même que l'APK, `android-client\ThirdParty\ZXing` dans le
|
||||
chemin de recherche du `.dproj`. Pas de sélection de zone : le décodeur trouve
|
||||
le QR où qu'il soit. **Seul un `otpauth://` repart vers la page**
|
||||
(`Bridge.onScreenQr`), le reste de l'écran ne quitte pas l'exe. Format `BGR32`
|
||||
(`Bridge.onScreenQr(texte, trouvé)`), le reste de l'écran ne quitte pas l'exe.
|
||||
Le second argument dit qu'**un QR a été lu** même s'il n'est pas un code 2FA :
|
||||
« This QR code is not a 2FA code » plutôt que « aucun QR » (cas vu : un QR
|
||||
**dynamique** de générateur en ligne encode un lien de suivi, pas le texte). Format `BGR32`
|
||||
et non `BGRA32` : l'octet alpha d'une capture GDI vaut 0, lu comme transparent
|
||||
→ image blanche.
|
||||
|
||||
@@ -1124,6 +1173,11 @@ QR que l'application Android scanne elle-meme.
|
||||
- **Piege CSS rencontre** : `css/style.css` impose `svg { width: 16px }` a
|
||||
toutes les icones, ce qui reduisait le QR a la taille d'une icone. La taille
|
||||
est donc posee **en style direct** sur le `<svg>`.
|
||||
- **WebDAV (2026-10-02)** : le telephone n'a qu'un champ « jeton », donc pour une
|
||||
URL qui n'est ni Gitea ni GitHub le QR porte `syncUser:syncPwd` — le
|
||||
telephone en fait une authentification Basic (voir android-client/README.md).
|
||||
Le QR reprend l'URL telle quelle : une configuration en `127.0.0.1` designe
|
||||
le telephone lui-meme une fois scannee, il faut l'IP du poste.
|
||||
|
||||
### GitHub, et refus d'un depot public (2026-09-18)
|
||||
|
||||
@@ -1143,6 +1197,25 @@ QR que l'application Android scanne elle-meme.
|
||||
- Telephone : `https://github.com/O/R` <-> URL d'API, comme pour Gitea. Le QR
|
||||
du PC porte aussi la forme courte `github.com/O/R`.
|
||||
|
||||
### Ce que le depot laisse voir, et le reseau (2026-09-26)
|
||||
|
||||
- **Nom par defaut `data.json`** (`SYNC_DEFAULT_FILE`, JS et page du
|
||||
telephone ; `SyncDefaultFile` dans le QR). `vault-sync.json` annoncait un
|
||||
coffre a qui parcourt la liste des depots. Les configurations existantes
|
||||
gardent leur nom : c'est l'URL complete qui est enregistree. **Le QR ne
|
||||
raccourcit que le nom par defaut courant** — un autre nom voyage en URL
|
||||
complete, sinon le telephone rallongerait vers `data.json` et les deux
|
||||
appareils ne liraient pas le meme fichier.
|
||||
- **Messages de commit neutres** : `update from desktop` / `update from phone`.
|
||||
Le poste ecrivait `sync from <utilisateur du coffre>`, le telephone decrivait
|
||||
ce qu'il faisait. Le detail reste dans le journal local.
|
||||
- **8 s pour se connecter, 30 s pour repondre, trois essais sur status 0**
|
||||
(poste et telephone). Mesure : `api.github.com` rend plusieurs adresses ;
|
||||
`140.82.121.5` n'est joignable depuis ce reseau ni par l'application ni par un
|
||||
navigateur, `140.82.121.6` repond en 0,22 s. Un navigateur passe a la
|
||||
suivante aussitot ; nous attendions le delai entier. Un 404 ou un 409 sont
|
||||
des reponses et ne relancent rien.
|
||||
|
||||
### Idee gardee : « Reinitialiser l'historique » du depot de synchro
|
||||
|
||||
**Rien n'est fait, et le code actuel reste tel quel** (decide le 2026-09-17).
|
||||
@@ -1380,6 +1453,25 @@ d'accord.
|
||||
|
||||
### Contraintes connues autofill
|
||||
|
||||
- **VNC (2026-10-14)** : les caracteres partent en `KEYEVENTF_UNICODE`
|
||||
(`VK_PACKET`), que TigerVNC & co ne savent pas retransmettre - un Mac
|
||||
recevait un `V` par caractere. Cible dont l'exe contient « vnc »
|
||||
(`TargetTypesByKeyCodes`) → **vraies touches** (`SendKeyCodeString` :
|
||||
`VkKeyScanEx` dans la disposition de la cible, + Maj/Ctrl/Alt, AltGr =
|
||||
Ctrl+Alt). **AltGr = `VK_LCONTROL` + `VK_RMENU` etendu**, comme le vrai
|
||||
clavier : un Ctrl+Alt generique etait lu par TigerVNC comme deux
|
||||
modificateurs, et `@` (AltGr+0 en AZERTY) arrivait en `a`. Chaque touche porte
|
||||
aussi son scancode (`MapVirtualKeyEx`). **Texte melange (arabe + latin)** :
|
||||
un caractere sans touche dans la disposition courante est cherche dans les
|
||||
autres dispositions installees (`GetKeyboardLayoutList`) ; la fenetre cible
|
||||
y est basculee (`WM_INPUTLANGCHANGEREQUEST`, attente que son fil l'ait
|
||||
prise - TigerVNC traduit chaque touche avec la disposition de SON fil), et
|
||||
la disposition d'origine est rendue a la fin. Seul un caractere qu'aucune
|
||||
disposition installee ne sait taper est **saute et compte** - journal
|
||||
`N character(s) have no key on this keyboard and were NOT typed - copy
|
||||
instead`. Eprouve vers un Mac : `تاسيب... jhbs... @ #` complet, clavier
|
||||
francais comme arabe actif.
|
||||
|
||||
- **UIPI** : SendInput vers process élevé bloqué. Notepad++ admin,
|
||||
regedit → autofill silencieusement no-op. Tester avec apps non-élevées.
|
||||
- **Chrome password manager** : si Chrome a des credentials sauvegardés
|
||||
@@ -1464,6 +1556,15 @@ après chaque `DELETE /attachments/{id}`. Un petit vault sain ne paie rien.
|
||||
→ retombe sur pwd).
|
||||
Fix clipboard : `MinimizeToTray(AClearClipboard=False)` via `keepclip=1`
|
||||
pour ne pas effacer le pwd juste copié quand on re-minimise.
|
||||
**Boutons par ligne** (2026-09-18) : utilisateur, mot de passe ou note, 2FA —
|
||||
seulement ceux qui existent. Une **note est toujours copiée**, même en Fill :
|
||||
tapée, chacun de ses retours à la ligne partirait comme un Entrée.
|
||||
**Interrupteur Copy / Fill** (2026-09-28, `qsFills()`) : visible seulement
|
||||
quand une fenêtre cible existe (Ctrl+Shift+Q, bouton Search de la barrette).
|
||||
Depuis le tray, pas de cible — cliquer le tray donne le premier plan à la
|
||||
barre des tâches — donc copie seule ; suivre « la dernière fenêtre active »
|
||||
a été écarté : les frappes partiraient au mauvais endroit si l'utilisateur a
|
||||
changé d'application entre-temps. Choix retenu en pref DPAPI `qsCopy`.
|
||||
- **Custom fields combobox éditable** : les champs avec `options` (card
|
||||
brand, expiry…) rendent un `<input>` + menu custom `.so-combo` (flèche =
|
||||
toutes les options, input = frappe libre). PAS un `<datalist>` natif
|
||||
@@ -1489,6 +1590,42 @@ après chaque `DELETE /attachments/{id}`. Un petit vault sain ne paie rien.
|
||||
clic extérieur est déjà câblée). Basculer le flag secret se fait **en
|
||||
place**, jamais via `rerender()` : reconstruire la liste fait flasher le
|
||||
panneau et perd le focus + la hauteur auto du multiligne.
|
||||
- **Precedent / Suivant des entrees ouvertes** (2026-10-10) : fleches ‹ › de
|
||||
l'en-tete du panneau + **Alt+←/→** (le `preventDefault` empeche aussi la page
|
||||
de « reculer »). `state.entryHistory = {ids, pos}`, 50 au plus, memoire
|
||||
seulement, vide par `lockVault`. Alimente par `openSlideOver` (sauf
|
||||
`opts.fromHistory`) ; la navigation repasse par `openSlideOver`, donc par la
|
||||
confirmation « modifications non enregistrees » — la position ne bouge
|
||||
qu'une fois l'entree vraiment ouverte. Entree supprimee sautee ; panneau
|
||||
ferme, Alt+← rouvre celle qu'on vient de quitter. Logique pure
|
||||
(`entryHistoryPush` / `entryHistoryTarget`) testee dans
|
||||
`js/tests/history.test.js`. Raison : un clic par erreur dans une liste
|
||||
filtree obligeait a refaire la recherche.
|
||||
- **Notes de commandes** (2026-10-10, modele `commands`) : le corps reste du
|
||||
texte - `# titre` ouvre un groupe, chaque autre ligne non vide est une
|
||||
commande (`parseCommandNote`, teste dans `js/tests/commands.test.js`). Aucune
|
||||
colonne : le texte est deja chiffre, synchronise, exporte et lu par l'APK.
|
||||
Panneau : case *Command list* (bascule le modele, compte dans `isSoDirty` via
|
||||
`originalTemplate`) + liste avec **Copier** seulement - la fenetre au premier
|
||||
plan y serait l'app elle-meme. Recherche rapide : choisir la note ouvre sa
|
||||
liste (`qsCommands`), la saisie filtre, Entree **tape sans Entree** et
|
||||
**sans Ctrl+A + Suppr** (`executeAutofill(..., noClear)` : dans un terminal
|
||||
Ctrl+A ramene en debut de ligne), Ctrl+Entree copie, Esc revient aux entrees.
|
||||
**Masquage** (partage d'ecran) sur l'oeil existant des notes (`maskNotes`) :
|
||||
`maskCommand` garde le premier mot et les 3 derniers caracteres
|
||||
(`ssh ••••••••••••.11`), une fin de 6 caracteres ou moins est masquee en
|
||||
entier. Affichage seulement : filtre, copie et remplissage voient le vrai
|
||||
texte. L'oeil re-declenche `input` sur le textarea pour redessiner la liste.
|
||||
**Lignes continuees** : une ligne finie par `\`, `` ` `` ou `^` se recolle a
|
||||
la suivante (sans le caractere) - une seule commande, sur UNE ligne, car un
|
||||
retour a la ligne tape dans un terminal vaut Entree. Ligne vide ou fin du
|
||||
texte = fin de la commande ; un `#` apres une continuation reste du texte.
|
||||
Limite : un chemin Windows fini par `\` en fin de ligne passe pour une
|
||||
continuation.
|
||||
- **Polices a chasse fixe et arabe** (2026-10-14) : aucune n'a l'arabe, et
|
||||
Windows empruntait une police plus petite. Chaque pile `…, monospace` de
|
||||
`css/style.css` finit par `'Segoe UI', Tahoma, monospace` : le latin reste a
|
||||
chasse fixe, l'arabe prend une taille comparable.
|
||||
- **Settings search** : `#settingsSearch` filtre les `.setting-row` par
|
||||
texte ; match sur le label de section garde toute la section. Esc dans
|
||||
la search avec query = clear ; Esc vide = ferme Settings.
|
||||
@@ -1504,6 +1641,20 @@ checkpoint le WAL proprement (sinon Windows force-kill après timeout →
|
||||
résidus `-shm`/`-wal`). Kill brutal (Task Manager) laisse les résidus, mais
|
||||
SQLite récupère au prochain open.
|
||||
|
||||
## Page tenue éveillée (autofill lent après le tray, 2026-10-02)
|
||||
|
||||
Premier autofill après des heures dans le tray ou une veille : `Autofill: page
|
||||
answered in 4907 ms` (8 ms coffre tout juste ouvert). Les drapeaux
|
||||
`--disable-renderer-backgrounding` & co **sont bien appliqués** (vérifié sur la
|
||||
ligne de commande de `msedgewebview2.exe`) : ce n'est pas Chromium qui gèle la
|
||||
page, c'est Windows qui laisse s'endormir un processus que personne ne sollicite.
|
||||
|
||||
- `FKeepWarmTimer` (UMainForm) : un `ExecuteJavaScript` trivial **toutes les
|
||||
5 min** (choix de l'utilisateur, pas 30 s).
|
||||
- `TPMBridge.OnSystemResume` : `PBT_APMRESUMEAUTOMATIC` et `WTS_SESSION_UNLOCK`
|
||||
réveillent la page **tout de suite** — le minuteur ne tourne pas pendant la
|
||||
veille. Journal : `Windows resumed / unlocked — waking the page`.
|
||||
|
||||
## WebView2 nav race (écran noir cold-start)
|
||||
|
||||
Le timer de navigation (1.5 s) ne consomme `FPendingURL` que si
|
||||
@@ -1589,6 +1740,12 @@ le vide ET vide `FPendingURL` → écran noir permanent sur cold-start lent.
|
||||
```
|
||||
Surcharge MSBuild, pas dans le `.dproj` : un chemin propre a une machine n'a
|
||||
pas sa place dans un fichier versionne. **F9 dans l'IDE sort toujours sur Z:.**
|
||||
**Build de livraison = `E:\password manager\build-both.cmd`** (hors depot, memes
|
||||
raisons) : il appelle d'abord `backup-previous-exe.ps1`, qui range les exe
|
||||
actuels (`Win32/PMServer.exe`, `Win64/PMServer.exe`, rien d'autre - ni
|
||||
`vault.db` ni `config.txt`) dans `PMServer-previous.zip`, **supprime et recree
|
||||
a chaque build** : la version d'avant reste toujours recuperable. Echec de la
|
||||
sauvegarde = pas de build. Assets d'abord si le frontend a change.
|
||||
**La sortie Android suit le meme chemin** : `Z:\password-manager\Android64` est
|
||||
une **jonction** vers `E:\password manager\Android64`, posee le 2026-09-17 quand
|
||||
`Z:` est tombe a 40 Ko libres et que `aapt` echouait sans dire pourquoi.
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# Synchroniser le PC et le téléphone par un dépôt GitHub privé
|
||||
|
||||
Le PC publie une copie **chiffrée** de votre coffre dans un dépôt GitHub
|
||||
**privé** ; le téléphone la reprend. Contrairement au WebDAV local
|
||||
(`WEBDAV-LOCAL.md`), la synchro marche **de partout**, en 4G comme sur
|
||||
n'importe quel Wi-Fi, PC éteint.
|
||||
|
||||
> **Ce qui est sur GitHub** : un fichier `data.json` illisible sans le *mot de
|
||||
> passe de synchro*, qui ne quitte jamais vos appareils.
|
||||
> **Ce mot de passe est la seule protection de cette copie** : choisissez-le
|
||||
> fort (5 mots au hasard, ou 16 caractères tirés au hasard).
|
||||
|
||||
Il faut un compte GitHub (gratuit) : <https://github.com/signup>.
|
||||
|
||||
---
|
||||
|
||||
## 1. Créer le dépôt privé
|
||||
|
||||
1. Allez sur <https://github.com/new>.
|
||||
2. **Repository name** : un nom neutre, par exemple `notes` (évitez `vault`,
|
||||
`passwords`…).
|
||||
3. Cochez **Private**. **Important** : l'application **refuse** un dépôt
|
||||
public — chaque synchro y reste dans l'historique, lisible par tous.
|
||||
4. **Create repository**. Pas besoin de README.
|
||||
|
||||
Notez son adresse, par exemple `https://github.com/moi/notes`.
|
||||
|
||||
## 2. Créer le jeton d'accès
|
||||
|
||||
Le jeton permet à l'application d'écrire **dans ce dépôt-là seulement**.
|
||||
|
||||
1. Allez sur <https://github.com/settings/personal-access-tokens/new>
|
||||
(ou : photo de profil → **Settings** → **Developer settings** →
|
||||
**Personal access tokens** → **Fine-grained tokens** →
|
||||
**Generate new token**).
|
||||
2. **Token name** : un nom neutre lui aussi, par exemple `notes-sync`.
|
||||
3. **Expiration** : la durée de votre choix. À l'expiration, la synchro
|
||||
s'arrête (erreur 401) et il faudra en créer un nouveau (voir plus bas).
|
||||
4. **Repository access** : **Only select repositories** → choisissez
|
||||
**votre dépôt** (`notes`). N'accordez pas l'accès à tous vos dépôts.
|
||||
5. **Permissions** → **Repository permissions** → **Contents** :
|
||||
**Read and write**. (GitHub ajoute *Metadata : Read-only* tout seul.)
|
||||
Rien d'autre.
|
||||
6. **Generate token**, puis **copiez-le tout de suite** (`github_pat_…`) :
|
||||
GitHub ne le montre qu'une fois.
|
||||
|
||||
## 3. Configurer le PC
|
||||
|
||||
Password Manager → **Settings → Sync** :
|
||||
|
||||
| Champ | Valeur |
|
||||
|---|---|
|
||||
| Enable sync | coché |
|
||||
| URL | l'adresse du dépôt : `https://github.com/moi/notes` |
|
||||
| Username | laisser vide (ignoré pour GitHub) |
|
||||
| Password / token | le jeton `github_pat_…` |
|
||||
|
||||
1. **Test connection** → *Connection OK · snapshot not created yet*.
|
||||
2. **Set sync password** → choisissez le **mot de passe de synchro** (le même
|
||||
sur tous vos appareils ; différent du mot de passe maître).
|
||||
3. **Sync now** → un fichier `data.json` apparaît dans le dépôt, avec un
|
||||
commit *update from desktop*.
|
||||
|
||||
## 4. Configurer le téléphone
|
||||
|
||||
1. Installez `pmserverApk.apk` (autorisez l'installation depuis cette source
|
||||
quand Android le demande).
|
||||
2. Sur le PC : **Settings → Sync → QR for phone** (le QR reste 60 secondes).
|
||||
3. Sur le téléphone : **Settings → Scan the QR shown on the PC**. L'adresse
|
||||
et le jeton se remplissent.
|
||||
4. Tapez le **mot de passe de synchro** (il n'est **jamais** dans le QR),
|
||||
puis **Save**. Le coffre se télécharge.
|
||||
|
||||
Sans QR, à la main : URL `https://github.com/moi/notes`, Token `github_pat_…`.
|
||||
|
||||
## 5. Au quotidien
|
||||
|
||||
- **PC** : **Sync now** après vos modifications.
|
||||
- **Téléphone** : à l'ouverture, il vérifie en arrière-plan. Si le PC a
|
||||
publié du nouveau, le bouton Sync s'allume ; touchez-le pour récupérer.
|
||||
Une modification faite sur le téléphone est publiée aussitôt.
|
||||
- Chaque synchro est un **commit** : c'est normal de voir l'historique du
|
||||
dépôt grandir.
|
||||
|
||||
## Quand le jeton expire
|
||||
|
||||
La synchro échoue en **401**. Créez un nouveau jeton (étape 2), collez-le sur
|
||||
le PC (**Password / token**), puis refaites **QR for phone** et le scan sur le
|
||||
téléphone. Le mot de passe de synchro, lui, ne change pas.
|
||||
|
||||
---
|
||||
|
||||
## En cas de problème
|
||||
|
||||
| Message | Cause probable |
|
||||
|---|---|
|
||||
| *This repository is PUBLIC - make it private…* | Le dépôt est public. Sur GitHub : dépôt → **Settings** → **Danger Zone** → **Change visibility** → **Private**. |
|
||||
| *Auth failed (401)* | Jeton faux, mal copié ou expiré. |
|
||||
| *Auth failed (403)* | Le jeton n'a pas **Contents : Read and write**, ou n'a pas accès à ce dépôt. |
|
||||
| *404* / *Nothing to download* | Le PC n'a pas encore fait **Sync now** — ou bien le jeton ne voit pas ce dépôt (GitHub répond 404 plutôt que de révéler un dépôt privé). Vérifiez l'étape 2, point 4. |
|
||||
| *Network failure*, *HTTP 0* | Pas de réseau. L'application réessaie trois fois ; relancez la synchro. |
|
||||
| Le coffre ne s'ouvre pas sur le téléphone | Le mot de passe de synchro n'est pas le même que sur le PC. |
|
||||
|
||||
**Limite** : GitHub ne rend plus un fichier de plus de **1 Mo** par cette
|
||||
voie. Un coffre chiffré fait environ 250 Ko ; seules de très nombreuses
|
||||
icônes personnalisées pourraient s'en approcher.
|
||||
|
||||
**GitLab n'est pas pris en charge.** Gitea (auto-hébergé) fonctionne de la même
|
||||
façon : adresse du dépôt, jeton avec le droit *write:repository*.
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
Password Manager - installation
|
||||
================================
|
||||
|
||||
Quatre fichiers dans ce dossier :
|
||||
|
||||
PMServer.exe l'application Windows - c'est elle qui compte
|
||||
pmserverApk.apk l'application Android, FACULTATIVE (voir le point 7)
|
||||
WEBDAV-LOCAL.md le guide pour synchroniser le telephone sans
|
||||
depot en ligne, par ton propre PC (point 7)
|
||||
GITHUB.md le guide pour synchroniser par un depot GitHub
|
||||
prive, de partout (point 7)
|
||||
|
||||
Il n'y a pas d'installeur.
|
||||
|
||||
L'activation se fait en DEUX temps, parce que la licence est liee a
|
||||
ta machine : tu m'envoies un code que l'application t'affiche, je te
|
||||
renvoie ta cle. Compte un aller-retour avant de pouvoir t'en servir.
|
||||
|
||||
|
||||
1. OU LE METTRE
|
||||
|
||||
Mets le dossier ou tu veux, SAUF dans "Program Files" :
|
||||
l'application ecrit son coffre (vault.db) juste a cote de l'exe,
|
||||
et Windows interdit l'ecriture dans Program Files.
|
||||
|
||||
Un bon endroit : C:\Users\<toi>\PasswordManager\
|
||||
|
||||
Choisis bien maintenant : deplacer l'exe plus tard deplace le coffre
|
||||
avec lui, il faut penser a prendre le vault.db aussi.
|
||||
|
||||
|
||||
2. PREMIER LANCEMENT - M'ENVOYER TON "INSTALLATION ID"
|
||||
|
||||
Double-clic sur PMServer.exe. Le premier ecran demande une cle
|
||||
d'activation, que tu n'as pas encore. En BAS de cet ecran il y a un
|
||||
code de seize caracteres, du genre :
|
||||
|
||||
Installation ID
|
||||
CD6F-F1B3-25C4-0111
|
||||
|
||||
Envoie-moi ce code (photo de l'ecran, copier-coller, peu importe).
|
||||
Il ne dit rien sur toi : c'est une empreinte de ta carte mere et de
|
||||
ton disque, melangees et hachees. Il ne permet ni de t'identifier,
|
||||
ni de remonter a ton materiel.
|
||||
|
||||
Je te renvoie ta cle. Tu la colles dans le champ, "Activate", et
|
||||
c'est fini : elle n'est demandee qu'une fois.
|
||||
|
||||
- Un AUTRE compte Windows sur le meme PC la redemandera.
|
||||
- Elle ne marchera QUE sur cette machine. Si tu changes de carte
|
||||
mere ou de disque systeme, redemande-m'en une, c'est gratuit
|
||||
et immediat.
|
||||
|
||||
|
||||
3. CREER TON COFFRE
|
||||
|
||||
Ensuite, "Register" : tu choisis ton nom d'utilisateur et ton mot
|
||||
de passe maitre.
|
||||
|
||||
LE MOT DE PASSE MAITRE N'EST STOCKE NULLE PART. Il n'y a aucun
|
||||
moyen de le recuperer s'il est perdu - le coffre devient illisible,
|
||||
y compris pour moi. Va dans Settings > Security des le premier jour
|
||||
et genere un "recovery code", garde-le hors de l'ordinateur.
|
||||
|
||||
|
||||
4. SI L'APPLICATION NE S'OUVRE PAS (fenetre blanche ou rien)
|
||||
|
||||
Il manque le moteur d'affichage de Microsoft, deja present sur
|
||||
Windows 11 mais pas toujours sur Windows 10. Il est gratuit :
|
||||
|
||||
https://developer.microsoft.com/microsoft-edge/webview2/
|
||||
|
||||
-> "Evergreen Bootstrapper", telecharge, installe, relance l'app.
|
||||
|
||||
|
||||
5. SAUVEGARDE
|
||||
|
||||
Tout le coffre tient dans vault.db, a cote de l'exe. Le copier
|
||||
suffit a le sauvegarder. Settings > Backup fait une copie chiffree
|
||||
automatique dans le dossier de ton choix - mets-le en route.
|
||||
|
||||
Si tu ranges ce dossier dans Google Drive, OneDrive ou Dropbox, la
|
||||
sauvegarde part avec, et elle est chiffree : le service ne voit
|
||||
qu'un fichier illisible.
|
||||
|
||||
|
||||
6. AU QUOTIDIEN
|
||||
|
||||
- L'application vit dans la zone de notification (a cote de l'heure),
|
||||
elle ne se ferme pas avec la croix. Clic droit sur l'icone > Quit.
|
||||
- Ctrl+Shift+L remplit identifiant + mot de passe dans le navigateur :
|
||||
clique d'abord le champ identifiant, puis la combinaison.
|
||||
- Clic droit sur l'icone > "Fill bar" pour faire la meme chose
|
||||
a la souris, sans raccourci. Settings > Fill bar (ou clic droit
|
||||
sur la barrette) choisit ses boutons, icones ou texte, et sa
|
||||
transparence (fond retire possible : seuls les boutons restent).
|
||||
- Ctrl+Shift+Q ouvre une recherche rapide. Ouverte par ce raccourci
|
||||
(ou le bouton Search de la barrette), elle remplit le champ ou tu
|
||||
etais ; l'interrupteur Copy / Fill en haut permet de copier a la
|
||||
place. Depuis l'icone du tray, elle copie seulement.
|
||||
- Activer une 2FA sur un site : laisse son QR code a l'ecran, ouvre
|
||||
l'entree, et clique le bouton QR du champ "Two-factor (TOTP)".
|
||||
Le secret se remplit tout seul.
|
||||
- Ouvert une entree par erreur ? Alt+fleche gauche (ou la fleche < en
|
||||
haut du panneau) revient a la precedente, sans refaire la recherche.
|
||||
Alt+fleche droite repart en avant.
|
||||
- Un raccourci clavier gene une autre application ? Settings >
|
||||
Autofill, clique le raccourci puis appuie sur Suppr : il passe a
|
||||
"Off" et la combinaison redevient libre. La barrette continue de
|
||||
remplir meme si les raccourcis sont coupes.
|
||||
- NOTES DE COMMANDES (ssh, scripts...) : + > "Commands (note)", ou
|
||||
coche "Command list" dans une note existante. Une commande par
|
||||
ligne, et une ligne "# Titre" pour faire des groupes :
|
||||
# Prod
|
||||
ssh admin@10.0.0.11
|
||||
ssh admin@10.0.0.12
|
||||
Une commande trop longue peut continuer sur la ligne suivante si la
|
||||
ligne finit par \ (ou ` en PowerShell, ^ en cmd).
|
||||
Ctrl+Shift+Q, choisis la note, tape un bout de la commande, puis
|
||||
Entree : elle est tapee dans ton terminal SANS etre lancee (c'est
|
||||
toi qui valides). Ctrl+Entree la copie. Esc ou < revient aux
|
||||
entrees. L'oeil de la note masque les commandes (partage d'ecran).
|
||||
|
||||
Rien ne sort de ton ordinateur : pas de compte en ligne, pas de
|
||||
serveur. Tout est chiffre sur place.
|
||||
|
||||
|
||||
7. LE TELEPHONE (pmserverApk.apk) - FACULTATIF
|
||||
|
||||
A LIRE AVANT D'INSTALLER : l'application Android ne sait pas creer
|
||||
de coffre. Elle ne fait que reprendre celui du PC, et pour cela il
|
||||
faut un endroit ou le PC publie une copie chiffree. Deux choix :
|
||||
|
||||
- UN WEBDAV LOCAL, sur ton propre PC : rien en ligne, pas de compte.
|
||||
Le telephone se synchronise quand il est sur le meme Wi-Fi que le
|
||||
PC, et que le PC est allume. Tout est explique pas a pas dans
|
||||
WEBDAV-LOCAL.md - suis ce guide A LA PLACE des points a. et d.
|
||||
ci-dessous.
|
||||
- UN DEPOT PRIVE Gitea ou GitHub : la synchro marche de partout.
|
||||
Un depot public est refuse : n'importe qui pourrait telecharger
|
||||
ton coffre chiffre et tout son historique.
|
||||
|
||||
Sans l'un ou l'autre, l'application s'installe mais reste vide : ne
|
||||
perds pas de temps avec elle, dis-le moi et on regarde ensemble.
|
||||
|
||||
Ce qui suit resume le depot Gitea / GitHub ; GITHUB.md le detaille
|
||||
ecran par ecran (creer le depot, le jeton, regler PC et telephone).
|
||||
|
||||
Ce qu'il faut, une fois :
|
||||
|
||||
a. Sur le PC : Settings > Sync, renseigner l'adresse du depot et
|
||||
le jeton, choisir un MOT DE PASSE DE SYNCHRO (different du mot
|
||||
de passe maitre), puis "Sync now".
|
||||
Adresse : celle du depot, telle que GitHub ou Gitea l'affiche,
|
||||
par exemple https://github.com/<toi>/<depot>.git
|
||||
Pour GitHub, un jeton "fine-grained" limite a ce depot, droit
|
||||
Contents : Read and write. (GitLab n'est pas pris en charge.)
|
||||
LE MOT DE PASSE DE SYNCHRO DOIT ETRE FORT (5 mots au hasard, ou
|
||||
16 caracteres tires au hasard) : c'est lui seul qui protege la
|
||||
copie qui part sur le depot.
|
||||
|
||||
b. Sur le telephone : autoriser l'installation d'applications
|
||||
depuis cette source quand Android le demande, puis installer
|
||||
pmserverApk.apk.
|
||||
|
||||
c. Sur le PC : Settings > Sync > "QR for phone". Un QR apparait
|
||||
pour 60 secondes.
|
||||
|
||||
d. Sur le telephone : Settings > "Scan the QR shown on the PC",
|
||||
viser le QR. L'adresse et le jeton se remplissent seuls.
|
||||
Le QR ne contient PAS le mot de passe de synchro : tape-le a
|
||||
la main, puis "Save", puis "Download vault".
|
||||
|
||||
Ensuite, "Unlock with" choisit comment le coffre s'ouvre sur le
|
||||
telephone : le verrouillage d'ecran (empreinte, schema), un code
|
||||
propre a l'application, ou le mot de passe de synchro.
|
||||
|
||||
- Si tu RETIRES le verrouillage d'ecran, Android detruit la cle
|
||||
qui protegeait la copie locale. L'application demande alors le
|
||||
mot de passe et rouvre le coffre : rien n'est perdu.
|
||||
- Le telephone lit et modifie le coffre, et "Sync" fait l'aller-
|
||||
retour avec le depot (ou le WebDAV). Le PC reste la reference.
|
||||
- A l'ouverture, le telephone verifie en arriere-plan : si le PC a
|
||||
publie du nouveau, le bouton Sync s'allume.
|
||||
|
||||
Pour la 2FA sur le telephone : dans une entree, le crayon, puis le
|
||||
bouton QR du champ "2FA secret" ; vise le QR du site avec la camera.
|
||||
|
||||
Une note de commandes s'affiche sur le telephone commande par
|
||||
commande, chacune avec son bouton copier.
|
||||
|
||||
La licence ne concerne pas le telephone : c'est l'application
|
||||
Windows qui est licenciee.
|
||||
+153
@@ -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,12 +56,20 @@ type
|
||||
Error: string; // message brut, pour le journal
|
||||
end;
|
||||
|
||||
/// Vrai pour une adresse du reseau local (RFC 1918, boucle, .local, .lan,
|
||||
/// nom sans point) : le seul endroit ou le HTTP en clair est accepte.
|
||||
function IsLocalHost(const AHost: string): Boolean;
|
||||
|
||||
type
|
||||
TSyncClient = class
|
||||
private
|
||||
FBaseUrl: string;
|
||||
FToken: string;
|
||||
FTimeoutMs: Integer;
|
||||
/// Attente de la POIGNEE DE MAIN, distincte de celle de la reponse.
|
||||
FConnectMs: Integer;
|
||||
FPrivacyChecked: Boolean;
|
||||
function PublicRepoWarning: string;
|
||||
public
|
||||
constructor Create(const ABaseUrl, AToken: string);
|
||||
|
||||
@@ -74,11 +82,18 @@ type
|
||||
/// n'y ressemble pas est de toute facon inutilisable.
|
||||
function IsGitea: Boolean;
|
||||
|
||||
/// WEBDAV : toute autre URL dont le "jeton" s'ecrit utilisateur:mot-de-passe.
|
||||
/// Le fichier se lit et s'ecrit tel quel (le meme que publie le poste), et
|
||||
/// l'ETag HTTP joue le role du sha : If-None-Match a la lecture, If-Match a
|
||||
/// l'ecriture (412 = un autre appareil a publie entre-temps).
|
||||
function IsWebDav: Boolean;
|
||||
|
||||
/// Refus LOCAL, avant tout envoi : http:// hors reseau local, puis depot public.
|
||||
/// UN DEPOT PUBLIC EST REFUSE (Gitea comme GitHub) : chaque synchro est un
|
||||
/// commit, et l'historique garde tous les instantanes, attaquables hors
|
||||
/// ligne par quiconque peut les lire. Message a afficher, ou '' si le depot
|
||||
/// est prive ou si on n'a pas pu le savoir. Verifie une fois par instance.
|
||||
function PublicRepoWarning: string;
|
||||
function RefusalReason: string;
|
||||
|
||||
/// POURQUOI UN 404 OU UN 401, en clair pour l'utilisateur. Gitea repond 404
|
||||
/// aussi bien pour un fichier absent que pour un depot ou un proprietaire
|
||||
@@ -105,7 +120,7 @@ type
|
||||
implementation
|
||||
|
||||
uses
|
||||
System.JSON, System.NetEncoding;
|
||||
System.JSON, System.NetEncoding, System.StrUtils;
|
||||
|
||||
function TSyncClient.IsGitea: Boolean;
|
||||
begin
|
||||
@@ -114,6 +129,81 @@ begin
|
||||
FBaseUrl.ToLower.Contains('api.github.com/repos/');
|
||||
end;
|
||||
|
||||
function TSyncClient.IsWebDav: Boolean;
|
||||
begin
|
||||
Result := (not IsGitea) and FToken.Contains(':');
|
||||
end;
|
||||
|
||||
/// Basic sur WebDAV, Bearer pour l'endpoint PHP historique.
|
||||
function PlainAuthHeader(const AToken: string; AWebDav: Boolean): string;
|
||||
begin
|
||||
if AWebDav then
|
||||
Result := 'Basic ' + TNetEncoding.Base64.Encode(AToken).Replace(#13, '').Replace(#10, '')
|
||||
else
|
||||
Result := 'Bearer ' + AToken;
|
||||
end;
|
||||
|
||||
function PutWebDav(const AUrl, AToken, ABody, AETag: string;
|
||||
AConnectMs, ATimeoutMs: Integer): TSyncResult;
|
||||
var
|
||||
LHttp: THTTPClient;
|
||||
LResp: IHTTPResponse;
|
||||
LReq, LOut: TStringStream;
|
||||
begin
|
||||
Result := Default(TSyncResult);
|
||||
Result.Status := ssNetworkError;
|
||||
LHttp := THTTPClient.Create;
|
||||
LReq := TStringStream.Create(ABody, TEncoding.UTF8);
|
||||
LOut := TStringStream.Create('', TEncoding.UTF8);
|
||||
try
|
||||
LHttp.ConnectionTimeout := AConnectMs;
|
||||
LHttp.ResponseTimeout := ATimeoutMs;
|
||||
LHttp.HandleRedirects := False;
|
||||
LHttp.UserAgent := 'PMServer';
|
||||
LHttp.CustomHeaders['Connection'] := 'close';
|
||||
LHttp.CustomHeaders['Authorization'] := PlainAuthHeader(AToken, True);
|
||||
LHttp.CustomHeaders['Content-Type'] := 'application/json';
|
||||
// Sans ETag connu (premiere publication, serveur qui n'en rend pas) : pas
|
||||
// de condition, le dernier ecrit gagne - comme le poste dans ce cas.
|
||||
if AETag <> '' then
|
||||
LHttp.CustomHeaders['If-Match'] := AETag;
|
||||
LResp := nil;
|
||||
for var LTry := 1 to 3 do
|
||||
begin
|
||||
try
|
||||
LReq.Position := 0;
|
||||
LOut.Clear;
|
||||
LResp := LHttp.Put(AUrl, LReq, LOut);
|
||||
Break;
|
||||
except
|
||||
on E: Exception do
|
||||
begin
|
||||
Result.Error := E.ClassName + ': ' + E.Message;
|
||||
if LTry = 3 then Exit;
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
if LResp = nil then Exit;
|
||||
Result.HttpCode := LResp.StatusCode;
|
||||
case LResp.StatusCode of
|
||||
200, 201, 204:
|
||||
begin
|
||||
Result.Status := ssOK;
|
||||
Result.ETag := LResp.HeaderValue['ETag'];
|
||||
end;
|
||||
412: Result.Status := ssConflict;
|
||||
else
|
||||
Result.Status := ssHttpError;
|
||||
Result.Error := Format('HTTP %d %s - %s', [LResp.StatusCode,
|
||||
LResp.StatusText, Copy(LOut.DataString, 1, 300)]);
|
||||
end;
|
||||
finally
|
||||
LOut.Free;
|
||||
LReq.Free;
|
||||
LHttp.Free;
|
||||
end;
|
||||
end;
|
||||
|
||||
/// URL de l'API du depot (sans /contents/...), identique pour Gitea
|
||||
/// (https://hote/api/v1/repos/O/R) et GitHub (https://api.github.com/repos/O/R).
|
||||
function RepoApiUrl(const AContentsUrl: string): string;
|
||||
@@ -124,6 +214,49 @@ begin
|
||||
if LP > 0 then Result := Copy(AContentsUrl, 1, LP - 1) else Result := '';
|
||||
end;
|
||||
|
||||
/// HTTP EN CLAIR : RESEAU LOCAL SEULEMENT. Android le permet partout
|
||||
/// (network_security_config) pour que chacun pointe vers SON serveur WebDAV,
|
||||
/// a une adresse qu'on ne connait pas a la compilation ; le tri se fait donc
|
||||
/// ici. Vers Internet, le jeton ne part qu'en HTTPS.
|
||||
/// ponytail: un Wi-Fi etranger qui reutilise la meme plage recevrait le jeton
|
||||
/// en clair - le coffre, lui, reste chiffre. HTTPS chez soi supprime le risque.
|
||||
function IsLocalHost(const AHost: string): Boolean;
|
||||
var
|
||||
LParts: TArray<string>;
|
||||
A, B: Integer;
|
||||
begin
|
||||
var LHost := AHost.ToLower;
|
||||
if (LHost = 'localhost') or LHost.EndsWith('.local') or LHost.EndsWith('.lan') or
|
||||
(LHost.IndexOf('.') < 0) and (LHost <> '') then
|
||||
Exit(True);
|
||||
LParts := LHost.Split(['.']);
|
||||
if (Length(LParts) <> 4) or not TryStrToInt(LParts[0], A) or
|
||||
not TryStrToInt(LParts[1], B) then
|
||||
Exit(False);
|
||||
for var LPart in LParts do
|
||||
if not TryStrToInt(LPart, A) then Exit(False);
|
||||
A := StrToInt(LParts[0]);
|
||||
Result := (A = 10) or (A = 127) or ((A = 192) and (B = 168)) or
|
||||
((A = 172) and (B >= 16) and (B <= 31));
|
||||
end;
|
||||
|
||||
function TSyncClient.RefusalReason: string;
|
||||
var
|
||||
LHost: string;
|
||||
begin
|
||||
if StartsText('http://', FBaseUrl) then
|
||||
begin
|
||||
try
|
||||
LHost := TURI.Create(FBaseUrl).Host;
|
||||
except
|
||||
LHost := '';
|
||||
end;
|
||||
if not IsLocalHost(LHost) then
|
||||
Exit('http:// is only allowed on the local network (192.168.x.x, 10.x.x.x, .local, .lan) - use https:// for ' + LHost);
|
||||
end;
|
||||
Result := PublicRepoWarning;
|
||||
end;
|
||||
|
||||
function TSyncClient.PublicRepoWarning: string;
|
||||
var
|
||||
LHttp: THTTPClient;
|
||||
@@ -136,7 +269,7 @@ begin
|
||||
FPrivacyChecked := True;
|
||||
LHttp := THTTPClient.Create;
|
||||
try
|
||||
LHttp.ConnectionTimeout := FTimeoutMs;
|
||||
LHttp.ConnectionTimeout := FConnectMs;
|
||||
LHttp.ResponseTimeout := FTimeoutMs;
|
||||
LHttp.HandleRedirects := False;
|
||||
LHttp.UserAgent := 'PMServer';
|
||||
@@ -193,7 +326,7 @@ begin
|
||||
LRepo := LParts[1];
|
||||
LHttp := THTTPClient.Create;
|
||||
try
|
||||
LHttp.ConnectionTimeout := FTimeoutMs;
|
||||
LHttp.ConnectionTimeout := FConnectMs;
|
||||
LHttp.ResponseTimeout := FTimeoutMs;
|
||||
LHttp.HandleRedirects := False;
|
||||
LHttp.UserAgent := 'PMServer';
|
||||
@@ -293,18 +426,20 @@ begin
|
||||
Result := Default(TSyncResult);
|
||||
Result.Status := ssNetworkError;
|
||||
|
||||
if not IsGitea then
|
||||
begin
|
||||
Result.Status := ssHttpError;
|
||||
Result.Error := 'Ecriture disponible seulement vers un depot Gitea ou GitHub';
|
||||
Exit;
|
||||
end;
|
||||
if (FBaseUrl = '') or (FToken = '') then
|
||||
begin
|
||||
Result.Error := 'URL ou jeton non configure';
|
||||
Exit;
|
||||
end;
|
||||
Result.Error := PublicRepoWarning;
|
||||
if IsWebDav then
|
||||
Exit(PutWebDav(FBaseUrl, FToken, AContainerJson, ASha, FConnectMs, FTimeoutMs));
|
||||
if not IsGitea then
|
||||
begin
|
||||
Result.Status := ssHttpError;
|
||||
Result.Error := 'Ecriture disponible seulement vers Gitea, GitHub ou WebDAV (jeton utilisateur:mot-de-passe)';
|
||||
Exit;
|
||||
end;
|
||||
Result.Error := RefusalReason;
|
||||
if Result.Error <> '' then
|
||||
begin
|
||||
Result.Status := ssRefused; // HttpCode 0 : refus local, pas du serveur
|
||||
@@ -328,22 +463,33 @@ begin
|
||||
try
|
||||
LHttp := THTTPClient.Create;
|
||||
try
|
||||
LHttp.ConnectionTimeout := FTimeoutMs;
|
||||
LHttp.ConnectionTimeout := FConnectMs;
|
||||
LHttp.ResponseTimeout := FTimeoutMs;
|
||||
LHttp.HandleRedirects := False;
|
||||
LHttp.UserAgent := 'PMServer'; // GitHub refuse toute requete sans
|
||||
LHttp.CustomHeaders['Connection'] := 'close'; // voir Fetch
|
||||
LHttp.CustomHeaders['Authorization'] := 'token ' + FToken;
|
||||
LHttp.CustomHeaders['Content-Type'] := 'application/json';
|
||||
// Deux essais, meme raison qu'a la lecture : un SYN perdu ne dit rien
|
||||
// du serveur. Le corps est renvoye depuis le debut a chaque essai.
|
||||
LResp := nil;
|
||||
for var LTry := 1 to 3 do
|
||||
begin
|
||||
try
|
||||
LReq.Position := 0;
|
||||
LResync.Clear;
|
||||
LResp := LHttp.Put(FBaseUrl, LReq, LResync);
|
||||
Break;
|
||||
except
|
||||
on E: Exception do
|
||||
begin
|
||||
Result.Status := ssNetworkError;
|
||||
Result.Error := E.ClassName + ': ' + E.Message;
|
||||
Exit;
|
||||
if LTry = 3 then Exit;
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
if LResp = nil then Exit;
|
||||
Result.HttpCode := LResp.StatusCode;
|
||||
case LResp.StatusCode of
|
||||
200, 201:
|
||||
@@ -395,7 +541,21 @@ begin
|
||||
inherited Create;
|
||||
FBaseUrl := ABaseUrl.Trim;
|
||||
FToken := AToken.Trim;
|
||||
FTimeoutMs := 15000;
|
||||
// 15 s ne suffisaient pas a GitHub depuis ce telephone : un simple 404 a ete
|
||||
// mesure a 16,3 s. Mais allonger seul ne suffit pas - mesure aussi : depuis
|
||||
// le shell du meme telephone, le ping passe et le port 443 s'ouvre tout de
|
||||
// suite, alors que la requete de l'application expire. Le lien perd des
|
||||
// paquets (ping 71 a 236 ms), et c'est le SYN qui se perd. D'ou DEUX essais
|
||||
// courts plutot qu'une seule longue attente, et un total qui reste sous le
|
||||
// garde-fou de 50 s de l'ecran.
|
||||
FTimeoutMs := 30000;
|
||||
// COURT POUR SE CONNECTER, LONG POUR REPONDRE. Mesure sur ce telephone :
|
||||
// api.github.com rend plusieurs adresses, 140.82.121.6 repond en 0,22 s et
|
||||
// 140.82.121.5 n'est pas joignable du tout. Un navigateur passe a la
|
||||
// suivante aussitot ; l'application attendait son delai entier sur la
|
||||
// mauvaise. Huit secondes suffisent a une adresse qui repond, et trois
|
||||
// essais laissent le temps de tomber sur une bonne.
|
||||
FConnectMs := 8000;
|
||||
end;
|
||||
|
||||
function TSyncClient.Fetch(const AKnownETag: string): TSyncResult;
|
||||
@@ -412,7 +572,7 @@ begin
|
||||
Result.Error := 'URL ou jeton non configure';
|
||||
Exit;
|
||||
end;
|
||||
Result.Error := PublicRepoWarning;
|
||||
Result.Error := RefusalReason;
|
||||
if Result.Error <> '' then
|
||||
begin
|
||||
Result.Status := ssRefused; // HttpCode 0 : refus local, pas du serveur
|
||||
@@ -421,9 +581,14 @@ begin
|
||||
|
||||
LHttp := THTTPClient.Create;
|
||||
try
|
||||
LHttp.ConnectionTimeout := FTimeoutMs;
|
||||
LHttp.ConnectionTimeout := FConnectMs;
|
||||
LHttp.ResponseTimeout := FTimeoutMs;
|
||||
LHttp.UserAgent := 'PMServer'; // GitHub refuse toute requete sans
|
||||
// PAS DE CONNEXION REUTILISEE. Le telephone change de reseau (Wi-Fi qui
|
||||
// se reconnecte, IP qui change) et la pile Java garde des sockets ouverts
|
||||
// sur l'interface morte : la requete suivante attend son delai entier
|
||||
// pour rien. Une poignee de main de plus coute bien moins que cela.
|
||||
LHttp.CustomHeaders['Connection'] := 'close';
|
||||
// Pas de redirection suivie : une redirection vers un autre hote nous
|
||||
// ferait envoyer le jeton ailleurs.
|
||||
LHttp.HandleRedirects := False;
|
||||
@@ -434,7 +599,7 @@ begin
|
||||
LHttp.CustomHeaders['Authorization'] := 'token ' + FToken
|
||||
else
|
||||
begin
|
||||
LHttp.CustomHeaders['Authorization'] := 'Bearer ' + FToken;
|
||||
LHttp.CustomHeaders['Authorization'] := PlainAuthHeader(FToken, IsWebDav);
|
||||
// Pas sur Gitea : le sha n'est pas un ETag HTTP, et l'envoyer ferait
|
||||
// repondre 200 avec un corps complet de toute facon.
|
||||
if AKnownETag <> '' then
|
||||
@@ -443,16 +608,23 @@ begin
|
||||
|
||||
LStream := TStringStream.Create('', TEncoding.UTF8);
|
||||
try
|
||||
LResp := nil;
|
||||
for var LTry := 1 to 3 do
|
||||
begin
|
||||
try
|
||||
LResp := LHttp.Get(FBaseUrl, LStream);
|
||||
Break;
|
||||
except
|
||||
on E: Exception do
|
||||
begin
|
||||
Result.Status := ssNetworkError;
|
||||
Result.Error := E.ClassName + ': ' + E.Message;
|
||||
Exit;
|
||||
if LTry = 3 then Exit;
|
||||
LStream.Clear;
|
||||
end;
|
||||
end;
|
||||
end;
|
||||
if LResp = nil then Exit;
|
||||
|
||||
Result.HttpCode := LResp.StatusCode;
|
||||
Result.ETag := LResp.HeaderValue['ETag'];
|
||||
|
||||
@@ -37,6 +37,19 @@ function VaultToJson(AVault: TVault): string;
|
||||
/// Les lignes d'une entree, dans l'ordre d'affichage.
|
||||
function DetailToJson(const AEntry: TVaultEntry): string;
|
||||
|
||||
type
|
||||
TCommandItem = record
|
||||
Group, Cmd: string;
|
||||
end;
|
||||
|
||||
/// NOTE DE COMMANDES (modele "commands"), meme regle que le poste
|
||||
/// (js/app.js parseCommandNote) : "# titre" ouvre un groupe, chaque autre ligne
|
||||
/// non vide est une commande ; une ligne finie par \, ` ou ^ se recolle a la
|
||||
/// suivante, sur UNE ligne. Une ligne vide ou la fin du texte termine la
|
||||
/// commande en cours.
|
||||
function ParseCommandNote(const AText: string): TArray<TCommandItem>;
|
||||
function IsCommandNote(const AEntry: TVaultEntry): Boolean;
|
||||
|
||||
/// Une ligne du detail par son rang, dans le MEME ordre que DetailToJson : la
|
||||
/// page renvoie un rang, jamais une valeur, et Delphi relit le clair chez lui.
|
||||
/// Faux si le rang ne designe rien.
|
||||
@@ -245,6 +258,49 @@ begin
|
||||
Result := LRows;
|
||||
end;
|
||||
|
||||
function IsCommandNote(const AEntry: TVaultEntry): Boolean;
|
||||
begin
|
||||
Result := AEntry.IsNote and SameText(AEntry.Template, 'commands');
|
||||
end;
|
||||
|
||||
function ParseCommandNote(const AText: string): TArray<TCommandItem>;
|
||||
var
|
||||
LGroup, LPending, LLine, LPart: string;
|
||||
LCont: Boolean;
|
||||
|
||||
procedure Flush;
|
||||
begin
|
||||
if LPending <> '' then
|
||||
begin
|
||||
SetLength(Result, Length(Result) + 1);
|
||||
Result[High(Result)].Group := LGroup;
|
||||
Result[High(Result)].Cmd := LPending;
|
||||
end;
|
||||
LPending := '';
|
||||
end;
|
||||
|
||||
begin
|
||||
Result := nil;
|
||||
LGroup := '';
|
||||
LPending := '';
|
||||
for LLine in AText.Replace(#13, '').Split([#10]) do
|
||||
begin
|
||||
var LTrim := LLine.Trim;
|
||||
if LTrim = '' then begin Flush; Continue; end;
|
||||
if (LPending = '') and LTrim.StartsWith('#') then
|
||||
begin
|
||||
LGroup := LTrim.TrimLeft(['#']).Trim;
|
||||
Continue;
|
||||
end;
|
||||
LCont := CharInSet(LTrim[High(LTrim)], ['\', '`', '^']);
|
||||
if LCont then LPart := Copy(LTrim, 1, Length(LTrim) - 1).TrimRight
|
||||
else LPart := LTrim;
|
||||
if LPending = '' then LPending := LPart else LPending := LPending + ' ' + LPart;
|
||||
if not LCont then Flush;
|
||||
end;
|
||||
Flush;
|
||||
end;
|
||||
|
||||
function DetailToJson(const AEntry: TVaultEntry): string;
|
||||
var
|
||||
LRoot: TJSONObject;
|
||||
@@ -257,6 +313,9 @@ begin
|
||||
LRoot := TJSONObject.Create;
|
||||
try
|
||||
LRoot.AddPair('title', AEntry.Display);
|
||||
// L'icone du moment, pour que le formulaire montre ce qu'il va changer.
|
||||
LRoot.AddPair('icon', AEntry.IconB64);
|
||||
LRoot.AddPair('initial', Initial(AEntry.Display));
|
||||
LRoot.AddPair('totp', TJSONBool.Create(AEntry.Totp <> ''));
|
||||
LArr := TJSONArray.Create;
|
||||
LRoot.AddPair('rows', LArr);
|
||||
@@ -275,6 +334,21 @@ begin
|
||||
if LRows[I].Multiline then LObj.AddPair('m', TJSONBool.Create(True));
|
||||
LArr.AddElement(LObj);
|
||||
end;
|
||||
// La liste des commandes, pour l'affichage. La copie passe par un rang
|
||||
// (copy?f=cmd&k=), comme les lignes : Delphi relit le clair chez lui.
|
||||
if IsCommandNote(AEntry) then
|
||||
begin
|
||||
var LCmds := ParseCommandNote(AEntry.Password);
|
||||
var LCArr := TJSONArray.Create;
|
||||
LRoot.AddPair('cmds', LCArr);
|
||||
for I := 0 to High(LCmds) do
|
||||
begin
|
||||
LObj := TJSONObject.Create;
|
||||
LObj.AddPair('g', LCmds[I].Group);
|
||||
LObj.AddPair('c', LCmds[I].Cmd);
|
||||
LCArr.AddElement(LObj);
|
||||
end;
|
||||
end;
|
||||
Result := LRoot.ToJSON;
|
||||
finally
|
||||
LRoot.Free;
|
||||
|
||||
@@ -341,6 +341,128 @@ reste.
|
||||
(code, empreinte) **dans le service** avant de proposer quoi que ce soit : le
|
||||
coffre ne vit que dans l'app, ouverte ou non.
|
||||
|
||||
## Modifier une entrée — 2026-09-25 à 09-28
|
||||
|
||||
**Un formulaire pour toute l'entrée**, ouvert par le crayon de l'en-tête. Le
|
||||
crayon par ligne a disparu : chaque validation était un enregistrement complet,
|
||||
donc un commit de plus dans le dépôt.
|
||||
|
||||
- **Un seul envoi, seulement ce qui a changé** : `setfields?f0=&v0=&f1=&v1=…`
|
||||
→ `SaveEntryFields`, plusieurs `PatchEntryField` dans **un** `CommitChange`.
|
||||
Le filtre des noms est celui de `SaveEntryField` (ce qui traverse le pont se
|
||||
traite comme une entrée extérieure).
|
||||
- **La page ne connaît pas le schéma** : elle rend une ligne par champ que
|
||||
Delphi a marqué modifiable (`e` porte son nom, dans `DetailToJson`). Le champ
|
||||
`folder` est une liste (`folderSelect`, avec *+ new folder*), pas un champ
|
||||
libre — une faute de frappe rangerait l'entrée dans un dossier invisible.
|
||||
- **Mot de passe et secret 2FA naissent masqués** (création et édition), avec
|
||||
un œil (`data-eye`) pour les révéler.
|
||||
- **Icône** en tête du formulaire, trois boutons : *From site* (recherche
|
||||
automatique), *Image…* (sélecteur de photos d'Android via
|
||||
`TTakePhotoFromLibraryAction`, réduite à 128 px, provenance **custom** — le
|
||||
poste ne la remplacera jamais par une recherche), *Remove*. Une action
|
||||
d'icône passe par un enregistrement : le formulaire est rouvert ensuite avec
|
||||
ce qui y était tapé (`editAgain`). Manifeste : `READ_MEDIA_IMAGES`, et
|
||||
`READ_EXTERNAL_STORAGE` bornée à `maxSdkVersion="32"`.
|
||||
- **Scanner le QR 2FA** d'un site : bouton QR du champ 2FA →
|
||||
`scanqr?for=totp` → même caméra que le scan des réglages (`FScanTotp`). Le
|
||||
lien `otpauth://` part à la page (`PM.totpFromQr`), qui n'en garde que le
|
||||
secret ; le journal ne note que le fait. Un autre QR fait dire au bandeau
|
||||
« pas un code 2FA » et le scan continue.
|
||||
- **Pas de menu ⋮ ni de crayon sur une entrée en création** : l'écran de
|
||||
détail est réutilisé, et côté Delphi ces commandes visent la **dernière
|
||||
entrée ouverte** — *Delete* depuis la création en aurait supprimé une autre.
|
||||
|
||||
### Piège : `view` nomme l'écran, pas ce qu'on y fait
|
||||
|
||||
Un gardien `focusin` défocalise tout ce qui prend le focus hors de l'écran
|
||||
courant en comparant `view` à l'**identifiant de l'écran**. Le formulaire vit
|
||||
dans l'écran `detail` ; poser `view = 'edit'` le faisait donc défocaliser
|
||||
**chaque champ à l'instant où il prenait le focus** — clavier ouvert, pas de
|
||||
curseur, rien ne rentrait. Trouvé en inspectant la page en direct par le
|
||||
protocole DevTools (APK debug, `adb forward … localabstract:webview_devtools_remote_…`) :
|
||||
le champ était atteignable et focalisé, ce qui éliminait le DOM et le CSS.
|
||||
`view` reste `detail`, un drapeau `editing` dit qu'on modifie, et la flèche
|
||||
comme la touche retour referment le formulaire avant de quitter l'entrée.
|
||||
|
||||
### Piège : `toast` était un `<div>`
|
||||
|
||||
Le navigateur expose chaque `id` comme variable globale : `toast` désignait
|
||||
l'élément `<div id="toast">`, et **sept appels `toast('…')` de la page levaient
|
||||
« toast is not a function »** — le message ne s'affichait pas et le reste du
|
||||
gestionnaire ne s'exécutait pas (dont « Tap again to delete »). Une vraie
|
||||
`function toast(text)` globale l'emporte sur l'id.
|
||||
|
||||
## Synchro : ce qui a changé — 2026-09-25 à 09-26
|
||||
|
||||
- **L'ouverture ne tire plus l'instantané.** Elle ouvre depuis le cache, puis
|
||||
`ProbeRemote` pose une requête conditionnelle : si le dépôt a bougé, le
|
||||
témoin **bleu** du bouton Sync s'allume et l'utilisateur tire quand il veut.
|
||||
Le journal dit les trois cas : `unchanged`, `it moved`, `no answer (HTTP 0)`
|
||||
— ce dernier n'est pas « rien n'a changé ».
|
||||
- **Dépôt vide = création.** Un 404 à la lecture pendant une publication était
|
||||
traité comme un échec : rien ne partait jamais vers un dépôt neuf. Sans `sha`,
|
||||
l'API des contenus crée le fichier.
|
||||
- **Réseau : 8 s pour se connecter, 30 s pour répondre, trois essais**, et
|
||||
`Connection: close`. Mesuré : `api.github.com` rend plusieurs adresses,
|
||||
`140.82.121.6` répond en 0,22 s et `140.82.121.5` n'est joignable depuis ce
|
||||
réseau **ni par l'application ni par un navigateur**. Un navigateur passe à
|
||||
la suivante aussitôt ; l'application attendait son délai entier. Le poste
|
||||
applique la même règle.
|
||||
- **Pas de clavier de recherche pendant une synchro** : synchroniser coffre
|
||||
fermé ouvre d'abord le coffre, et ce chemin dépliait le clavier.
|
||||
- **Messages de commit neutres** : `update from phone` partout (le téléphone
|
||||
écrivait `new entry from phone`, `icon fetched…`, `entries deleted…`). Le
|
||||
détail reste dans le journal local.
|
||||
- **Nom par défaut `data.json`** au lieu de `vault-sync.json` — voir CLAUDE.md.
|
||||
|
||||
## WebDAV — 2026-10-02
|
||||
|
||||
Troisième transport, à côté de Gitea et GitHub, éprouvé contre le **WsgiDAV
|
||||
local du poste** (`E:\webdav.json`, `0.0.0.0:8080`) : lecture, 304 à
|
||||
l'ouverture, écriture 204, puis synchro du poste qui reprend la modification.
|
||||
|
||||
- **Reconnu au jeton, pas à l'URL** : une URL ni Gitea ni GitHub dont le jeton
|
||||
s'écrit `utilisateur:mot-de-passe` → `IsWebDav`, authentification Basic.
|
||||
Pas de champ « utilisateur » de plus dans l'écran : le jeton en tient lieu
|
||||
(sans `:`, c'est toujours l'ancien endpoint PHP, en Bearer).
|
||||
- **Le fichier tel quel**, le même que publie le poste : GET / PUT sans
|
||||
enveloppe base64. **L'ETag HTTP fait le sha** : `If-None-Match` à la lecture
|
||||
(304 = rien à faire), `If-Match` à l'écriture, **412 → `ssConflict`**, donc la
|
||||
même reprise relire-fusionner-republier que le 409/422 de Gitea. Sans ETag
|
||||
connu, pas de condition : le dernier écrit gagne, comme le poste.
|
||||
- **Une URL qui finit en `.json` n'est plus réécrite** par `normalizeRepoUrl`
|
||||
(elle en faisait une URL d'API Gitea) — sauf les formes longues d'un dépôt,
|
||||
`/src/branch/…` et `/blob/…`.
|
||||
- **`http://` : réseau local seulement**, pour que chacun (les amis compris)
|
||||
pointe vers son propre serveur sans adresse connue à la compilation. Android
|
||||
ne sait autoriser ni plage ni masque : `network_security_config.xml` permet
|
||||
donc le clair partout, et `TSyncClient.RefusalReason` refuse **avant tout
|
||||
envoi** un `http://` dont l'hôte n'est pas local (`IsLocalHost` : RFC 1918,
|
||||
`127.x`, `.local`, `.lan`, nom sans point). Vers Internet, HTTPS obligatoire,
|
||||
et le refus s'affiche dans les réglages. Risque assumé : un Wi-Fi étranger
|
||||
qui réutilise la même plage recevrait le mot de passe WebDAV en clair — le
|
||||
coffre, lui, reste chiffré.
|
||||
- **Côté poste** : pare-feu Windows (règle `WebDAV 8080`, `LocalSubnet`) **et**
|
||||
Portmaster (entrant autorisé pour Python) — sans le second, le port restait
|
||||
fermé malgré la règle.
|
||||
|
||||
## Notes de commandes — 2026-10-10
|
||||
|
||||
Une note marquée **Commands** sur le poste (modèle `commands`) affiche en tête
|
||||
un bloc *Commands* : une commande par ligne sous le titre de son groupe, avec
|
||||
un bouton copier. Le texte entier reste dessous, dans *Content*.
|
||||
|
||||
- **Même découpage que le poste** : `ParseCommandNote` (PMA.WebUI) est le
|
||||
portage de `parseCommandNote` (js/app.js) — `# titre` ouvre un groupe, une
|
||||
ligne finie par `\`, `` ` `` ou `^` se recolle à la suivante. Les deux
|
||||
doivent dire la même chose ; éprouvé sur les mêmes cas.
|
||||
- **La copie passe par un rang** (`copy?f=cmd&k=`), comme les autres lignes :
|
||||
Delphi relit la note et copie la commande.
|
||||
- **Ni remplissage ni masquage** ici : pas de fenêtre cible, et le corps des
|
||||
notes est déjà affiché en clair. Une note devient « Commands » depuis le
|
||||
poste (case *Command list*) ; la synchro porte le modèle.
|
||||
|
||||
## Saisie : ce qui a résisté
|
||||
|
||||
- **`user-select: none` du `body`** était hérité par les champs : le curseur ne
|
||||
|
||||
@@ -1,26 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Le trafic en clair n'est permis que vers la BOUCLE LOCALE, et interdit
|
||||
partout ailleurs.
|
||||
Le clair est PERMIS ICI, et trie dans le code (PMA.SyncClient.IsLocalHost).
|
||||
|
||||
Il l'etait auparavant partout, via usesCleartextTraffic="true". C'etait pour
|
||||
une seule raison : l'essai local passe par "adb reverse", qui fait voir le
|
||||
poste comme 127.0.0.1 et parle HTTP. Mais l'attribut ne distingue pas la
|
||||
boucle locale du reste du monde - une URL mal saisie, ou un serveur repassant
|
||||
en HTTP, envoyait le jeton en clair sur le reseau sans que rien ne le signale.
|
||||
|
||||
Ici la permission est nominative. Le jeton ne peut plus sortir qu'en TLS, et
|
||||
une URL "http://" vers un vrai hote echoue tout de suite, ce qui est
|
||||
exactement le comportement voulu : bruyant, pas silencieux.
|
||||
|
||||
cleartextTrafficPermitted="false" a la racine n'est pas redondant avec le
|
||||
defaut d'Android 9+ : il vaut aussi pour les versions anterieures, et il dit
|
||||
l'intention noir sur blanc.
|
||||
Chacun pointe vers SON serveur WebDAV, souvent en http:// sur son reseau
|
||||
local, a une adresse qu'on ne connait pas a la compilation - et Android ne
|
||||
sait autoriser ni une plage ni un masque, seulement des noms un par un.
|
||||
Avant tout envoi, la synchro refuse donc tout http:// qui ne vise pas le
|
||||
reseau local (RFC 1918, boucle, .local, .lan, nom sans point) : vers
|
||||
Internet, le jeton ne part qu'en HTTPS, et le refus s'affiche au lieu de
|
||||
laisser passer en silence.
|
||||
-->
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="false" />
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="false">127.0.0.1</domain>
|
||||
<domain includeSubdomains="false">localhost</domain>
|
||||
</domain-config>
|
||||
<base-config cleartextTrafficPermitted="true" />
|
||||
</network-security-config>
|
||||
|
||||
+316
-125
@@ -98,7 +98,10 @@
|
||||
.search { flex: 1; position: relative; }
|
||||
.search input {
|
||||
width: 100%; height: 46px; padding: 0 38px 0 40px;
|
||||
border: 0; border-radius: var(--radius-s);
|
||||
/* MEME ARRONDI QUE LES PASTILLES DE DOSSIER. Trois rayons differents se
|
||||
voyaient d'un coup d'oeil sur le meme ecran : la recherche, les
|
||||
pastilles, les boutons. Les deux premiers se touchent presque. */
|
||||
border: 0; border-radius: 999px;
|
||||
background: rgba(255,255,255,.10); color: var(--text);
|
||||
font-size: 15px; outline: none;
|
||||
}
|
||||
@@ -153,8 +156,9 @@
|
||||
text-overflow: ellipsis; white-space: nowrap; }
|
||||
.txt span { display: block; color: var(--muted); font-size: 13px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
/* Les boutons prennent le meme arrondi que la recherche et les pastilles. */
|
||||
.btn {
|
||||
flex: 0 0 auto; border: 0; border-radius: var(--radius-s);
|
||||
flex: 0 0 auto; border: 0; border-radius: 999px;
|
||||
background: var(--raised); color: var(--text);
|
||||
padding: 11px 13px; font-size: 13px; font-family: inherit;
|
||||
}
|
||||
@@ -229,6 +233,7 @@
|
||||
|
||||
/* --- detail ------------------------------------------------------------ */
|
||||
.head { display: flex; align-items: center; gap: 10px; padding: 10px 12px; }
|
||||
.head .av { width: 30px; height: 30px; font-size: 13px; flex: 0 0 auto; }
|
||||
.head h1 { flex: 1; margin: 0; font-size: 19px; font-weight: 600;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.field {
|
||||
@@ -274,6 +279,11 @@
|
||||
.field .val.multi { white-space: pre-wrap; word-break: normal;
|
||||
overflow-wrap: anywhere; }
|
||||
.field.totp .val { font-size: 30px; font-weight: 300; letter-spacing: 2px; }
|
||||
/* Note de commandes : une commande par ligne, sous le titre de son groupe. */
|
||||
.field.cmds .cmd-group { font-size: 12px; font-weight: 600; opacity: .7;
|
||||
text-transform: uppercase; margin: 10px 0 2px; }
|
||||
.field.cmds .cmd-line { padding: 4px 0; }
|
||||
.field.cmds .val.cmd { font-family: monospace; font-size: 14px; }
|
||||
.bar { height: 3px; border-radius: 2px; background: rgba(255,255,255,.18);
|
||||
margin-top: 10px; overflow: hidden; }
|
||||
.bar i { display: block; height: 100%; background: var(--text);
|
||||
@@ -309,6 +319,32 @@
|
||||
jamais - la fin d'une URL longue restait hors d'atteinte. */
|
||||
-webkit-user-select: text; user-select: text;
|
||||
}
|
||||
/* UN FORMULAIRE EST UNE SEULE CARTE. Sept champs faisaient sept cartes,
|
||||
chacune avec son fond et ses coins - un empilement de rectangles ou l'oeil
|
||||
ne voit plus le formulaire. Ici une carte, et des lignes dedans. */
|
||||
.form { background: var(--surface); border-radius: var(--radius);
|
||||
padding: 0 14px; margin-bottom: 8px; }
|
||||
.form .field { background: none; border-radius: 0; margin: 0;
|
||||
padding: 8px 0 6px;
|
||||
border-bottom: 1px solid rgba(255,255,255,.07); }
|
||||
.form .field:last-child { border-bottom: 0; }
|
||||
.form .field .field-in { border-bottom: 0; }
|
||||
/* Le de a la meme hauteur que le champ qu'il accompagne, sinon il etire la
|
||||
ligne et le trait de saisie remonte tout seul. */
|
||||
.form .field .btn.ico-btn { width: 38px; min-width: 38px; height: 38px; }
|
||||
/* UNE CARTE NE CONTIENT PAS UNE SECONDE BOITE. La carte .field porte deja un
|
||||
fond et des coins arrondis ; le champ qui s'y pose n'a donc besoin ni de
|
||||
l'un ni de l'autre - juste d'un trait qui dise ou l'on ecrit, et qui
|
||||
s'allume quand le doigt s'y pose. Hors carte (reglages, deverrouillage),
|
||||
.field-in garde sa boite : la, rien d'autre ne la dessine. */
|
||||
.field .field-in {
|
||||
background: transparent; border-radius: 0; padding-left: 0; padding-right: 0;
|
||||
height: 38px; margin-top: 0;
|
||||
border-bottom: 1px solid rgba(255,255,255,.16);
|
||||
}
|
||||
.field .field-in:focus { border-bottom-color: var(--accent); }
|
||||
.field select.field-in { padding-left: 0; }
|
||||
.field textarea.field-in { padding: 6px 0; }
|
||||
.field-in.multi { height: auto; min-height: 46px; overflow: hidden; padding: 12px 44px 12px 14px; resize: none;
|
||||
line-height: 1.35; word-break: break-all; display: block; }
|
||||
.field-in::placeholder { color: var(--muted); }
|
||||
@@ -497,7 +533,7 @@
|
||||
champ. Une adresse d'API Gitea en fait un jeton d'acces personnel
|
||||
envoye en "token" ; l'endpoint PHP en fait un "Bearer". Le
|
||||
nommer d'apres un seul des deux le rend faux pour l'autre. -->
|
||||
<input class="field-in" id="tok" placeholder="access token"
|
||||
<input class="field-in" id="tok" placeholder="access token (WebDAV: user:password)"
|
||||
type="password" autocomplete="off">
|
||||
<div class="in-x" data-clear="tok">×</div>
|
||||
</div>
|
||||
@@ -558,7 +594,9 @@
|
||||
<div class="screen" id="detail">
|
||||
<div class="head">
|
||||
<button class="btn ico-btn back-btn" id="back"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-label="Back" role="img"><path d="M20 12H5M11 5l-7 7 7 7"/></svg></button>
|
||||
<div class="av" id="dav" hidden></div>
|
||||
<h1 id="dtitle"></h1>
|
||||
<button class="btn ico-btn" id="pencil" aria-label="Edit entry"></button>
|
||||
<button class="btn ico-btn" id="kebab" aria-label="Entry menu"></button>
|
||||
</div>
|
||||
<div class="scroll"><div id="fields"></div></div>
|
||||
@@ -582,13 +620,13 @@
|
||||
entier. Un input d'une ligne ne defilait pas sous le doigt dans la
|
||||
WebView de l'app, et la fin d'une URL d'API restait inatteignable. -->
|
||||
<textarea class="field-in multi" id="sUrl" rows="1"
|
||||
placeholder="repository URL - Gitea or github.com/owner/repo (private)"
|
||||
placeholder="repository URL - Gitea, github.com/owner/repo (private), or WebDAV file URL"
|
||||
autocomplete="off" autocorrect="off" autocapitalize="none"
|
||||
spellcheck="false" inputmode="url"></textarea>
|
||||
<div class="in-x" data-clear="sUrl">×</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>
|
||||
@@ -1010,6 +1048,8 @@ var ICONS = {
|
||||
+ '<circle cx="9" cy="9" r="1.2"/><circle cx="15" cy="15" r="1.2"/>'
|
||||
+ '<circle cx="15" cy="9" r="1.2"/><circle cx="9" cy="15" r="1.2"/>',
|
||||
close: '<path d="M6 6l12 12M18 6L6 18"/>',
|
||||
qr: '<rect x="4" y="4" width="6" height="6" rx="1"/><rect x="14" y="4" width="6" height="6" rx="1"/>'
|
||||
+ '<rect x="4" y="14" width="6" height="6" rx="1"/><path d="M14 14h2v2h-2zM20 14v.01M14 20h.01M17 20h3v-3"/>',
|
||||
// Un arc, pas un cercle : c'est l'ouverture qui rend la rotation visible.
|
||||
loader: '<path d="M12 3a9 9 0 019 9"/>',
|
||||
eyeOff: '<path d="M4 4l16 16"/>'
|
||||
@@ -1040,6 +1080,13 @@ function renderDetail(d) {
|
||||
lastDetail = d;
|
||||
detailRows = d.rows || [];
|
||||
document.getElementById('dtitle').textContent = d.title || '';
|
||||
document.getElementById('kebab').hidden = false;
|
||||
document.getElementById('pencil').hidden = false;
|
||||
editing = false;
|
||||
var dav = document.getElementById('dav');
|
||||
dav.hidden = false;
|
||||
dav.className = 'av' + (d.icon ? ' has-img' : '');
|
||||
dav.innerHTML = d.icon ? '<img src="' + esc(d.icon) + '">' : esc(d.initial || '');
|
||||
var html = '', i;
|
||||
if (d.totp)
|
||||
html += '<div class="field totp" id="totp"><div class="cap">2FA code</div>'
|
||||
@@ -1047,6 +1094,23 @@ function renderDetail(d) {
|
||||
+ '<button class="btn ico-btn" data-copy="totp">'
|
||||
+ ico('copy', 'Copy code') + '</button></div>'
|
||||
+ '<div class="bar"><i id="totpb"></i></div></div>';
|
||||
// NOTE DE COMMANDES : une ligne par commande, groupees sous leur titre, avec
|
||||
// un bouton copier. Le texte entier reste plus bas, dans "Content".
|
||||
if (d.cmds && d.cmds.length) {
|
||||
var lastG = null;
|
||||
html += '<div class="field cmds"><div class="cap">Commands</div>';
|
||||
for (i = 0; i < d.cmds.length; i++) {
|
||||
var cm = d.cmds[i];
|
||||
if (cm.g !== lastG) {
|
||||
lastG = cm.g;
|
||||
if (cm.g) html += '<div class="cmd-group">' + esc(cm.g) + '</div>';
|
||||
}
|
||||
html += '<div class="line cmd-line"><div class="val cmd">' + esc(cm.c) + '</div>'
|
||||
+ '<button class="btn ico-btn" data-copy="cmd" data-k="' + i + '">'
|
||||
+ ico('copy', 'Copy command') + '</button></div>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
for (i = 0; i < detailRows.length; i++) {
|
||||
var r = detailRows[i];
|
||||
html += '<div class="field"><div class="cap">' + esc(r.c) + '</div>'
|
||||
@@ -1064,8 +1128,6 @@ function renderDetail(d) {
|
||||
+ (r.a === undefined ? ''
|
||||
: '<button class="btn ico-btn" data-save="' + i + '">'
|
||||
+ ico('down', 'Save to Downloads') + '</button>')
|
||||
+ (r.e ? '<button class="btn ico-btn" data-edit="' + i + '">'
|
||||
+ ico('edit', 'Edit') + '</button>' : '')
|
||||
+ '</div></div>';
|
||||
}
|
||||
document.getElementById('fields').innerHTML = html;
|
||||
@@ -1073,9 +1135,13 @@ function renderDetail(d) {
|
||||
document.body.classList.add('on-detail');
|
||||
view = 'detail';
|
||||
send('state?view=detail');
|
||||
// Retour au formulaire apres une action d'icone : elle passe par un
|
||||
// enregistrement, donc par ce rendu-ci.
|
||||
if (editAgain) openEditEntry();
|
||||
}
|
||||
|
||||
document.getElementById('fields').addEventListener('click', function (ev) {
|
||||
|
||||
// Meme raison que dans la liste : le dessin recoit l'appui, le bouton porte
|
||||
// l'intention. Tout ce qui suit lit et ecrit sur le bouton.
|
||||
var t = ev.target.closest('button') || ev.target;
|
||||
@@ -1099,49 +1165,35 @@ document.getElementById('fields').addEventListener('click', function (ev) {
|
||||
send('touch');
|
||||
return;
|
||||
}
|
||||
var eye = t.getAttribute('data-eye');
|
||||
if (eye) {
|
||||
var ef = document.getElementById(eye);
|
||||
if (!ef) return;
|
||||
var shown = ef.type === 'text';
|
||||
ef.type = shown ? 'password' : 'text';
|
||||
t.innerHTML = ico(shown ? 'eye' : 'eyeOff', shown ? 'Show' : 'Hide');
|
||||
send('touch');
|
||||
return;
|
||||
}
|
||||
var sc = t.getAttribute('data-scan');
|
||||
if (sc) { totpTarget = sc; send('scanqr?for=totp'); return; }
|
||||
var ia = t.getAttribute('data-iconact');
|
||||
if (ia !== null) {
|
||||
// L'ICONE SE COMMET COMME LE RESTE, donc Delphi rouvre l'entree et la vue
|
||||
// de lecture reprenait la place - on se retrouvait dehors sans avoir fini.
|
||||
// Ce drapeau dit a renderDetail de rendre la main au formulaire, avec ce
|
||||
// qui y etait deja tape.
|
||||
editAgain = collectEditValues();
|
||||
send('icon?a=' + ia);
|
||||
return;
|
||||
}
|
||||
var save = t.getAttribute('data-save');
|
||||
if (save !== null) { send('savefile?k=' + save); return; }
|
||||
var copy = t.getAttribute('data-copy');
|
||||
if (copy === 'totp') { send('copy?f=totp'); return; }
|
||||
if (copy === 'row') { send('copy?f=row&k=' + t.getAttribute('data-k')); return; }
|
||||
if (copy === 'cmd') { send('copy?f=cmd&k=' + t.getAttribute('data-k')); return; }
|
||||
|
||||
var edit = t.getAttribute('data-edit');
|
||||
if (edit !== null) { openEditor(edit); return; }
|
||||
if (t.getAttribute('data-editshow') !== null) {
|
||||
// Masque par defaut, revelable a la demande : on tape un mot de passe
|
||||
// qu'on ne peut relire nulle part ailleurs, mais il n'a pas a rester
|
||||
// expose a l'ecran pendant qu'on cherche le bouton.
|
||||
var iv = document.getElementById('editval');
|
||||
var hid = iv.type === 'password';
|
||||
iv.type = hid ? 'text' : 'password';
|
||||
t.innerHTML = ico(hid ? 'eyeOff' : 'eye', hid ? 'Hide' : 'Show');
|
||||
return;
|
||||
}
|
||||
if (t.getAttribute('data-editcancel') !== null) { renderDetail(lastDetail); return; }
|
||||
if (t.getAttribute('data-editsave') !== null) {
|
||||
var input = document.getElementById('editval');
|
||||
if (!input) return;
|
||||
// VIDER EST LEGITIME sur un site ou un identifiant - pas sur un mot de
|
||||
// passe, ou ce serait presque toujours une fausse manoeuvre. Le refus se
|
||||
// dit, sinon le bouton semble ne pas prendre l'appui.
|
||||
if (!input.value && editField === 'password') {
|
||||
toast('An empty password is not saved');
|
||||
return;
|
||||
}
|
||||
// DIRE QUE CA TRAVAILLE. L'enregistrement dechiffre, rechiffre et publie :
|
||||
// une a deux secondes pendant lesquelles rien ne bougeait, et le bouton
|
||||
// semblait n'avoir pas pris l'appui. La coche cede la place a un arc qui
|
||||
// tourne - immobile, elle laisserait croire que c'est fini.
|
||||
t.innerHTML = ico('loader', 'Saving');
|
||||
t.classList.add('spin');
|
||||
t.disabled = true;
|
||||
var cancel = document.querySelector('[data-editcancel]');
|
||||
if (cancel) cancel.disabled = true;
|
||||
input.disabled = true;
|
||||
send('setfield?f=' + encodeURIComponent(editField)
|
||||
+ '&v=' + encodeURIComponent(input.value));
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
/// LE DOSSIER SE CHOISIT, il ne se tape pas.
|
||||
@@ -1153,40 +1205,22 @@ document.getElementById('fields').addEventListener('click', function (ev) {
|
||||
///
|
||||
/// 'All' est ajoute d'office : c'est le dossier par defaut du poste, et il ne
|
||||
/// figure pas toujours dans la liste.
|
||||
function editorControl(k) {
|
||||
var val = detailRows[k].v;
|
||||
var style = ' style="margin:0;flex:1;min-width:0;width:auto"';
|
||||
// UNE ZONE DE TEXTE POUR CE QUI TIENT SUR PLUSIEURS LIGNES. Un <input>
|
||||
// applique la sanitisation de HTML et retire les retours a la ligne en
|
||||
// silence : le corps d'une note y perdait sa mise en forme, et la perte
|
||||
// partait au depot a l'enregistrement suivant.
|
||||
if (detailRows[k].m)
|
||||
return '<textarea class="field-in" id="editval" rows="6"'
|
||||
+ ' enterkeyhint="enter" autocorrect="off" spellcheck="false"'
|
||||
+ ' style="margin:0;flex:1;min-width:0;width:auto;height:auto;'
|
||||
+ 'padding:10px 14px;resize:vertical"></textarea>';
|
||||
if (editField !== 'folder')
|
||||
return '<input class="field-in" id="editval" autocomplete="off"'
|
||||
+ ' type="' + (detailRows[k].s ? 'password' : 'text') + '"'
|
||||
+ ' autocorrect="off" autocapitalize="none" spellcheck="false"'
|
||||
+ style + '>';
|
||||
|
||||
var names = ['All'], i;
|
||||
/// La liste des dossiers du coffre, pour le champ 'folder' du formulaire.
|
||||
/// Le dossier courant y est ajoute s'il n'y figure plus - renomme ailleurs, ou
|
||||
/// venu d'un import -, sinon l'ouvrir suffirait a le remplacer en silence.
|
||||
function folderSelect(val) {
|
||||
var names = ['All'], i, html;
|
||||
for (i = 0; i < folders.length; i++)
|
||||
if (folders[i] && names.indexOf(folders[i]) < 0) names.push(folders[i]);
|
||||
// Le dossier courant peut ne plus figurer dans la liste - il a ete renomme
|
||||
// ailleurs, ou l'entree vient d'un import. Le proposer quand meme evite de
|
||||
// le remplacer silencieusement en ouvrant simplement l'editeur.
|
||||
if (val && names.indexOf(val) < 0) names.push(val);
|
||||
|
||||
var html = '<select class="field-in" id="editval"' + style + '>';
|
||||
html = '<select class="field-in" data-ef="folder" style="' + FIELD_STYLE + '">';
|
||||
for (i = 0; i < names.length; i++)
|
||||
html += '<option value="' + esc(names[i]) + '"'
|
||||
+ (names[i] === val ? ' selected' : '') + '>' + esc(names[i])
|
||||
+ '</option>';
|
||||
// CHOISIR NE SUFFIT PAS : le premier dossier d'un coffre n'existe pas encore,
|
||||
// et rien ne permettait de le nommer. Cette option echange la liste contre un
|
||||
// champ libre, sans quitter l'editeur.
|
||||
// champ libre, sans quitter le formulaire.
|
||||
html += '<option value="' + NEW_FOLDER + '">+ new folder</option>';
|
||||
return html + '</select>';
|
||||
}
|
||||
@@ -1196,11 +1230,12 @@ var NEW_FOLDER = '\u0001new';
|
||||
// Le choix "+ new folder" remplace la liste par un champ. Delegue sur #fields
|
||||
// parce que le controle nait et meurt avec l'editeur.
|
||||
document.getElementById('fields').addEventListener('change', function (ev) {
|
||||
if (ev.target.id !== 'editval' || ev.target.value !== NEW_FOLDER) return;
|
||||
if (ev.target.getAttribute('data-ef') !== 'folder'
|
||||
|| ev.target.value !== NEW_FOLDER) return;
|
||||
var line = ev.target.parentNode;
|
||||
var input = document.createElement('input');
|
||||
input.className = 'field-in';
|
||||
input.id = 'editval';
|
||||
input.setAttribute('data-ef', 'folder');
|
||||
input.setAttribute('placeholder', 'folder name');
|
||||
input.setAttribute('autocomplete', 'off');
|
||||
input.setAttribute('autocapitalize', 'none');
|
||||
@@ -1225,44 +1260,6 @@ document.getElementById('fields').addEventListener('change', function (ev) {
|
||||
input.focus();
|
||||
});
|
||||
|
||||
// EDITEUR EN PLACE, pas window.prompt : une invite JavaScript demande au
|
||||
// moteur un WebChromeClient que FMX ne fournit pas, et prompt() rend alors
|
||||
// null sans un mot - le bouton semblerait ne rien faire.
|
||||
//
|
||||
// La valeur s'affiche EN CLAIR pendant la saisie. On tape un mot de passe
|
||||
// qu'on ne peut pas relire ailleurs ; le masquer ferait valider a l'aveugle
|
||||
// une frappe qu'aucune confirmation ne rattrape.
|
||||
var editField = '';
|
||||
|
||||
function openEditor(k) {
|
||||
var row = document.querySelector('[data-v="' + k + '"]');
|
||||
if (!row) return;
|
||||
// 'e' porte le nom du champ dans l'instantane, pas un booleen : c'est Delphi
|
||||
// qui decide de ce qui se modifie, la page n'a pas a le deduire d'un libelle.
|
||||
editField = detailRows[k].e || '';
|
||||
if (!editField) return;
|
||||
var line = row.parentNode;
|
||||
line.classList.toggle('wrap', !!detailRows[k].m);
|
||||
line.innerHTML = editorControl(k)
|
||||
+ (editField === 'password' ? genButton('editval') : '')
|
||||
// Show n'a de sens que sur un champ masque. Sur un site en clair il
|
||||
// n'aurait rien a reveler - et l'inverse : il le CACHERAIT.
|
||||
//
|
||||
// LES TROIS EN ICONES, comme les lignes au-dessus : ils partageaient la
|
||||
// rangee avec le champ de saisie, et trois mots y laissaient a peine de
|
||||
// quoi lire ce qu'on tape. La croix et la coche se lisent sans traduction.
|
||||
+ (detailRows[k].s ? '<button class="btn ico-btn" data-editshow>'
|
||||
+ ico('eye', 'Show') + '</button>' : '')
|
||||
+ '<button class="btn ico-btn" data-editcancel>'
|
||||
+ ico('close', 'Cancel') + '</button>'
|
||||
+ '<button class="btn ico-btn main" data-editsave>'
|
||||
+ ico('check', 'Save') + '</button>';
|
||||
var input = document.getElementById('editval');
|
||||
input.value = detailRows[k].v;
|
||||
input.focus();
|
||||
send('touch');
|
||||
}
|
||||
|
||||
// RIEN NE GLISSE DE COTE, sauf la bande de dossiers.
|
||||
//
|
||||
// overflow:hidden n'interdit que le GESTE. Le moteur, lui, garde le droit de
|
||||
@@ -1347,8 +1344,9 @@ addEventListener('keydown', function (ev) {
|
||||
if (el.tagName === 'TEXTAREA') return;
|
||||
ev.preventDefault();
|
||||
el.blur();
|
||||
if (el.id !== 'editval') return;
|
||||
var save = document.querySelector('[data-editsave]');
|
||||
// Entree sur un champ du formulaire = enregistrer, comme la coche d'avant.
|
||||
if (!el.getAttribute('data-ef')) return;
|
||||
var save = document.getElementById('eSave');
|
||||
if (save && !save.disabled) save.click();
|
||||
});
|
||||
|
||||
@@ -1359,6 +1357,17 @@ addEventListener('keydown', function (ev) {
|
||||
// defile. Une entree qu'on cree et une entree qu'on consulte occupent la meme
|
||||
// place a l'ecran, et c'est exactement ce que cette vue veut dire.
|
||||
var newKind = 'login';
|
||||
// LE CLAVIER NE MONTE PAS TOUT SEUL. Le champ prend bien le focus - mesure
|
||||
// faite : l'appui arrive et focusin part -, mais la vue web est posee dans une
|
||||
// activite FMX, et le systeme n'ouvre le clavier que si on le lui demande.
|
||||
// C'est deja ce que fait la recherche ; les champs du formulaire passent par
|
||||
// la meme porte.
|
||||
document.addEventListener('focusin', function (ev) {
|
||||
var el = ev.target;
|
||||
if (!el.classList || !el.classList.contains('field-in')) return;
|
||||
if (el.tagName === 'SELECT') return; // il ouvre sa propre liste
|
||||
send('kbd');
|
||||
}, true);
|
||||
|
||||
/// Le dossier a la creation. Le meme choix que dans l'editeur, et pour la
|
||||
/// meme raison : une entree rangee des le depart evite d'avoir a la deplacer.
|
||||
@@ -1407,6 +1416,29 @@ function genPassword(n) {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
/// AFFICHER / MASQUER. Le champ nait masque : on ouvre un formulaire dans un
|
||||
/// lieu public autant qu'on le remplit, et le mot de passe n'a pas a
|
||||
/// s'afficher tant qu'on ne le demande pas. Un appui le revele pour relire ce
|
||||
/// qu'on tape.
|
||||
function eyeButton(target) {
|
||||
return '<button class="btn ico-btn" data-eye="' + target + '">'
|
||||
+ ico('eye', 'Show') + '</button>';
|
||||
}
|
||||
|
||||
/// SCANNER LE QR QU'AFFICHE LE SITE a l'activation de la 2FA. La camera est
|
||||
/// celle du scan des reglages ; seul ce qu'elle cherche change.
|
||||
function scanButton(target) {
|
||||
return '<button class="btn ico-btn" data-scan="' + target + '">'
|
||||
+ ico('qr', 'Scan the 2FA QR code') + '</button>';
|
||||
}
|
||||
|
||||
/// UN VRAI toast() GLOBAL. Sans lui, le nom designait l'element <div
|
||||
/// id="toast"> - le navigateur expose chaque id comme variable globale - et
|
||||
/// chaque appel toast('...') de cette page levait "toast is not a function" :
|
||||
/// le message ne s'affichait pas, et le reste du gestionnaire ne s'executait
|
||||
/// pas davantage. Une declaration de fonction l'emporte sur l'id.
|
||||
function toast(text) { PM.toast(text); }
|
||||
|
||||
function genButton(target) {
|
||||
return '<button class="btn ico-btn" data-gen="' + target + '">'
|
||||
+ ico('dice', 'Generate') + '</button>';
|
||||
@@ -1417,17 +1449,26 @@ function newBodyControl(kind) {
|
||||
return '<textarea class="field-in" id="nPwd" rows="6" enterkeyhint="enter"'
|
||||
+ ' autocorrect="off" spellcheck="false" style="' + FIELD_STYLE
|
||||
+ ';height:auto;padding:10px 14px;resize:vertical"></textarea>';
|
||||
return '<input class="field-in" id="nPwd" type="text" autocomplete="off"'
|
||||
return '<input class="field-in" id="nPwd" type="password" autocomplete="off"'
|
||||
+ ' autocorrect="off" autocapitalize="none" spellcheck="false"'
|
||||
+ ' style="' + FIELD_STYLE + '">' + genButton('nPwd');
|
||||
+ ' style="' + FIELD_STYLE + '">' + eyeButton('nPwd') + genButton('nPwd');
|
||||
}
|
||||
|
||||
function openNewEntry() {
|
||||
newKind = 'login';
|
||||
detailRows = [];
|
||||
document.getElementById('dtitle').textContent = 'New entry';
|
||||
// PAS DE MENU SUR UNE ENTREE QUI N'EXISTE PAS ENCORE : l'ecran de detail est
|
||||
// reutilise pour la creation, et son menu proposait Duplicate et Delete -
|
||||
// qui, cote Delphi, visent la derniere entree ouverte.
|
||||
closeMenu();
|
||||
editing = false;
|
||||
document.getElementById('kebab').hidden = true;
|
||||
document.getElementById('pencil').hidden = true;
|
||||
document.getElementById('dav').hidden = true;
|
||||
document.getElementById('fields').innerHTML =
|
||||
'<div class="field"><div class="cap">Kind</div><div class="line">'
|
||||
'<div class="form">'
|
||||
+ '<div class="field"><div class="cap">Kind</div><div class="line">'
|
||||
+ '<button class="btn main" data-kind="login">Login</button>'
|
||||
+ '<button class="btn" data-kind="note">Note</button></div></div>'
|
||||
+ newFolderField()
|
||||
@@ -1442,12 +1483,17 @@ function openNewEntry() {
|
||||
// deja : on pouvait poser un second facteur sur une entree existante, pas
|
||||
// sur une entree qu'on venait de creer - il fallait enregistrer puis
|
||||
// rouvrir. Meme intitule que l'editeur pour qu'on reconnaisse le champ.
|
||||
+ newField('nTotp', '2FA secret', 'nTotpRow')
|
||||
+ '<div class="field"><div class="line">'
|
||||
+ '<div class="field" id="nTotpRow"><div class="cap">2FA secret</div>'
|
||||
+ '<div class="line"><input class="field-in" id="nTotp" type="password"'
|
||||
+ ' autocomplete="off" autocorrect="off" autocapitalize="characters"'
|
||||
+ ' spellcheck="false" style="' + FIELD_STYLE + '">'
|
||||
+ eyeButton('nTotp') + scanButton('nTotp') + '</div></div>'
|
||||
+ '</div>'
|
||||
+ '<div class="line" style="display:flex;margin-top:12px">'
|
||||
// flex:1 et non .wide : .wide ne regle que le rembourrage, et la largeur
|
||||
// pleine vient d'etre etire dans son conteneur.
|
||||
+ '<button class="btn main wide" id="nSave" style="flex:1">Save</button>'
|
||||
+ '</div></div>';
|
||||
+ '</div>';
|
||||
document.body.classList.remove('on-list');
|
||||
document.body.classList.add('on-detail');
|
||||
view = 'detail';
|
||||
@@ -1531,13 +1577,127 @@ function closeMenu() {
|
||||
function openMenu() {
|
||||
var m = document.getElementById('menu');
|
||||
m.innerHTML = '<button data-hist>Previous passwords</button>'
|
||||
+ '<button data-icon="fetch">Fetch icon</button>'
|
||||
+ '<button data-icon="clear">Remove icon</button>'
|
||||
+ '<button data-dup>Duplicate entry</button>'
|
||||
+ '<button class="danger" data-del>Delete entry</button>';
|
||||
m.hidden = false;
|
||||
}
|
||||
|
||||
// MODIFIER L'ENTREE ENTIERE, PAS UN CHAMP A LA FOIS.
|
||||
//
|
||||
// Chaque ligne avait son crayon : ouvrir, taper, valider, recommencer - et
|
||||
// CHAQUE validation etait un enregistrement complet, donc un commit de plus
|
||||
// dans le depot. Ici un seul formulaire, un seul Save, un seul commit.
|
||||
//
|
||||
// La page ne connait toujours pas le schema : elle rend une ligne par champ
|
||||
// que Delphi a marque modifiable ('e' porte son nom), dans l'ordre qu'il a
|
||||
// choisi. Ajouter un champ modifiable cote Delphi suffit a le voir ici.
|
||||
/// Ce que le formulaire porte a cet instant, par nom de champ. Sert a le
|
||||
/// rouvrir tel quel apres une action d'icone, qui passe par un enregistrement.
|
||||
var editAgain = null;
|
||||
/// Le champ 2FA qui attend le resultat du scan - creation ou edition.
|
||||
var totpTarget = '';
|
||||
/// Le formulaire occupe l'ecran du detail : 'view' reste donc 'detail', et
|
||||
/// c'est ce drapeau qui dit qu'on y modifie - la fleche et la touche retour
|
||||
/// s'en servent pour refermer le formulaire avant de quitter l'entree.
|
||||
var editing = false;
|
||||
|
||||
function collectEditValues() {
|
||||
var ins = document.querySelectorAll('#fields [data-ef]'), m = {}, i;
|
||||
for (i = 0; i < ins.length; i++)
|
||||
m[ins[i].getAttribute('data-ef')] = ins[i].value;
|
||||
return m;
|
||||
}
|
||||
|
||||
function openEditEntry() {
|
||||
if (!lastDetail) return;
|
||||
closeMenu();
|
||||
// L'icone reste celle que renderDetail a posee dans l'en-tete : on voit ce
|
||||
// que les trois boutons vont remplacer.
|
||||
// Pas de crayon pendant qu'on modifie : il n'a plus rien a ouvrir.
|
||||
document.getElementById('pencil').hidden = true;
|
||||
var html = '<div class="form">'
|
||||
+ '<div class="field"><div class="cap">Icon</div><div class="line">'
|
||||
+ '<button class="btn" data-iconact="fetch">From site</button>'
|
||||
+ '<button class="btn" data-iconact="pick">Image...</button>'
|
||||
+ '<button class="btn" data-iconact="clear">Remove</button>'
|
||||
+ '</div></div>', i, r;
|
||||
for (i = 0; i < detailRows.length; i++) {
|
||||
r = detailRows[i];
|
||||
if (!r.e) continue;
|
||||
html += '<div class="field"><div class="cap">' + esc(r.c) + '</div><div class="line'
|
||||
+ (r.m ? ' wrap' : '') + '">'
|
||||
+ (r.e === 'folder' ? folderSelect(r.v) : r.m
|
||||
? '<textarea class="field-in" data-ef="' + esc(r.e) + '" rows="5"'
|
||||
+ ' enterkeyhint="enter" autocorrect="off" spellcheck="false"'
|
||||
+ ' style="' + FIELD_STYLE + ';height:auto;resize:vertical"></textarea>'
|
||||
: '<input class="field-in"'
|
||||
+ (r.e === 'password' ? ' id="editpwd"'
|
||||
: r.e === 'totp_secret' ? ' id="edittotp"' : '')
|
||||
+ ' data-ef="' + esc(r.e) + '" type="'
|
||||
+ (r.e === 'password' || r.e === 'totp_secret' ? 'password' : 'text') + '"'
|
||||
+ ' autocomplete="off" autocorrect="off" autocapitalize="none"'
|
||||
+ ' spellcheck="false" style="' + FIELD_STYLE + '">')
|
||||
+ (r.e === 'password' ? eyeButton('editpwd') + genButton('editpwd') : '')
|
||||
+ (r.e === 'totp_secret' ? eyeButton('edittotp') + scanButton('edittotp') : '')
|
||||
+ '</div></div>';
|
||||
}
|
||||
// L'ICONE SE CHANGE OU SE CHANGENT LES CHAMPS. Elle vivait dans le menu de
|
||||
// l'entree, a cote de Supprimer : deux actions de nature opposee au meme
|
||||
// endroit, et rien qui montre l'icone du moment.
|
||||
html += '</div>'
|
||||
+ '<div class="line" style="display:flex;gap:8px;margin-top:12px">'
|
||||
+ '<button class="btn wide" id="eCancel" style="flex:1">Cancel</button>'
|
||||
+ '<button class="btn main wide" id="eSave" style="flex:1">Save</button>'
|
||||
+ '</div>';
|
||||
document.getElementById('fields').innerHTML = html;
|
||||
// Les valeurs APRES le rendu, jamais en attribut : c'est ainsi que
|
||||
// procedait l'editeur d'une ligne, qui acceptait la frappe.
|
||||
var fill = document.querySelectorAll('#fields [data-ef]'), fi, fe;
|
||||
for (fi = 0; fi < fill.length; fi++) {
|
||||
fe = fill[fi].getAttribute('data-ef');
|
||||
for (var fk = 0; fk < detailRows.length; fk++)
|
||||
if (detailRows[fk].e === fe && fill[fi].tagName !== 'SELECT')
|
||||
fill[fi].value = detailRows[fk].v;
|
||||
}
|
||||
document.getElementById('eCancel').addEventListener('click', function () {
|
||||
renderDetail(lastDetail);
|
||||
});
|
||||
document.getElementById('eSave').addEventListener('click', saveEditEntry);
|
||||
editing = true;
|
||||
if (editAgain) {
|
||||
var ins = document.querySelectorAll('#fields [data-ef]'), i, f;
|
||||
for (i = 0; i < ins.length; i++) {
|
||||
f = ins[i].getAttribute('data-ef');
|
||||
if (editAgain[f] !== undefined) ins[i].value = editAgain[f];
|
||||
}
|
||||
editAgain = null;
|
||||
}
|
||||
// MEME SORTIE QUE LA CREATION, qui elle accepte la frappe : c'est le seul
|
||||
// appel qui differait a l'ouverture du formulaire.
|
||||
send('state?view=detail');
|
||||
}
|
||||
|
||||
/// UN SEUL ENVOI, ET SEULEMENT CE QUI A CHANGE : le reste du coffre n'a pas a
|
||||
/// etre reecrit parce qu'on a corrige une faute de frappe dans un nom.
|
||||
function saveEditEntry() {
|
||||
var ins = document.querySelectorAll('#fields [data-ef]'), q = [], n = 0, i, el, f, v, k;
|
||||
for (i = 0; i < ins.length; i++) {
|
||||
el = ins[i];
|
||||
f = el.getAttribute('data-ef');
|
||||
v = el.value;
|
||||
for (k = 0; k < detailRows.length; k++)
|
||||
if (detailRows[k].e === f && detailRows[k].v === v) { f = ''; break; }
|
||||
if (!f) continue;
|
||||
q.push('f' + n + '=' + encodeURIComponent(f) + '&v' + n + '=' + encodeURIComponent(v));
|
||||
n++;
|
||||
}
|
||||
if (!n) { renderDetail(lastDetail); return; }
|
||||
document.getElementById('eSave').disabled = true;
|
||||
send('setfields?' + q.join('&'));
|
||||
}
|
||||
|
||||
document.getElementById('pencil').innerHTML = ico('edit', 'Edit entry');
|
||||
document.getElementById('pencil').addEventListener('click', openEditEntry);
|
||||
document.getElementById('kebab').innerHTML = ico('dots', 'Entry menu');
|
||||
document.getElementById('kebab').addEventListener('click', function (ev) {
|
||||
ev.stopPropagation();
|
||||
@@ -1749,26 +1909,33 @@ document.addEventListener('input', function (ev) {
|
||||
});
|
||||
|
||||
// L'inverse, pour l'affichage : l'URL d'API se relit comme l'adresse du depot.
|
||||
// Meme nom par defaut que le poste - les deux doivent designer le meme
|
||||
// fichier. Un depot configure avec un autre nom garde son URL complete.
|
||||
var SYNC_DEFAULT_FILE = 'data.json';
|
||||
|
||||
function shortRepoUrl(u) {
|
||||
var g = /^https:\/\/api\.github\.com\/repos\/([^\/]+)\/([^\/]+)\/contents\/(.+)$/i.exec(u || '');
|
||||
if (g) return 'https://github.com/' + g[1] + '/' + g[2] +
|
||||
(g[3] === 'vault-sync.json' ? '' : '/blob/main/' + g[3]);
|
||||
(g[3] === SYNC_DEFAULT_FILE ? '' : '/blob/main/' + g[3]);
|
||||
var m = /^(https?:\/\/[^\/]+)\/api\/v1\/repos\/([^\/]+)\/([^\/]+)\/contents\/(.+)$/i.exec(u || '');
|
||||
if (!m) return u;
|
||||
return m[1] + '/' + m[2] + '/' + m[3] +
|
||||
(m[4] === 'vault-sync.json' ? '' : '/src/branch/main/' + m[4]);
|
||||
(m[4] === SYNC_DEFAULT_FILE ? '' : '/src/branch/main/' + m[4]);
|
||||
}
|
||||
function normalizeRepoUrl(u) {
|
||||
u = u.replace(/\s+/g, '');
|
||||
if (!u || /\/api\/v1\/repos\//i.test(u) || /api\.github\.com\/repos\//i.test(u) ||
|
||||
/\.php(\?|$)/i.test(u)) return u;
|
||||
// WEBDAV : l'URL du fichier lui-meme, a garder telle quelle. Seules les
|
||||
// formes longues d'un depot (/src/branch/, /blob/) finissent aussi en .json.
|
||||
if (/\.json$/i.test(u) && !/\/(src\/branch|blob)\//i.test(u)) return u;
|
||||
// GITHUB : l'adresse du depot est sur github.com, l'API sur api.github.com.
|
||||
var g = u.match(/^https?:\/\/(www\.)?github\.com\/([^\/]+)\/([^\/]+?)(?:\.git)?(?:\/blob\/[^\/]+\/(.+?))?\/?$/i);
|
||||
if (g) return 'https://api.github.com/repos/' + g[2] + '/' + g[3] +
|
||||
'/contents/' + (g[4] || 'vault-sync.json');
|
||||
'/contents/' + (g[4] || SYNC_DEFAULT_FILE);
|
||||
var m = u.match(/^(https?:\/\/[^\/]+)\/([^\/]+)\/([^\/]+?)(?:\.git)?(?:\/src\/branch\/[^\/]+\/(.+?))?\/?$/i);
|
||||
if (!m) return u;
|
||||
return m[1] + '/api/v1/repos/' + m[2] + '/' + m[3] + '/contents/' + (m[4] || 'vault-sync.json');
|
||||
return m[1] + '/api/v1/repos/' + m[2] + '/' + m[3] + '/contents/' + (m[4] || SYNC_DEFAULT_FILE);
|
||||
}
|
||||
// Pas de Save pour ces deux-la : une case se voit cochee, elle s'applique.
|
||||
['sBack', 'sKbd'].forEach(function (id) {
|
||||
@@ -1893,12 +2060,19 @@ function goList() {
|
||||
document.body.classList.remove('on-detail');
|
||||
document.body.classList.add('on-list');
|
||||
view = 'list';
|
||||
editing = false;
|
||||
// Les valeurs en clair partent avec la vue, pas seulement de l'ecran.
|
||||
detailRows = [];
|
||||
document.getElementById('fields').innerHTML = '';
|
||||
send('state?view=list');
|
||||
}
|
||||
document.getElementById('back').addEventListener('click', goList);
|
||||
// LA FLECHE REFERME D'ABORD LE FORMULAIRE. Elle ramenait a la liste, donc une
|
||||
// modification en cours disparaissait sans qu'on ait vu l'entree.
|
||||
document.getElementById('back').addEventListener('click', function () {
|
||||
if (editing) { renderDetail(lastDetail); return; }
|
||||
goList();
|
||||
});
|
||||
|
||||
|
||||
// --- appele par Delphi -----------------------------------------------------
|
||||
var PM = {
|
||||
@@ -2142,7 +2316,24 @@ var PM = {
|
||||
ico('loader', 'Working') + '<span>Please wait...</span>';
|
||||
document.body.classList.toggle('is-busy', !!on);
|
||||
},
|
||||
back: function () { if (view === 'detail') goList(); },
|
||||
// Touche retour du telephone : le formulaire d'abord, la liste ensuite.
|
||||
// Delphi ne connait que 'detail' - la page seule sait qu'elle edite.
|
||||
back: function () {
|
||||
if (editing) { renderDetail(lastDetail); return; }
|
||||
if (view === 'detail') goList();
|
||||
},
|
||||
cancelEdit: function () { renderDetail(lastDetail); },
|
||||
|
||||
/// Le QR d'un site porte otpauth://totp/Label?secret=XXXX&issuer=... - on
|
||||
/// n'en garde que le secret, comme le champ l'attend. Un QR sans secret
|
||||
/// (un autre lien) ne touche a rien.
|
||||
totpFromQr: function (uri) {
|
||||
var m = /[?&]secret=([^&#]+)/i.exec(uri || '');
|
||||
var el = document.getElementById(totpTarget);
|
||||
if (!m || !el) { PM.toast('Not a 2FA QR code'); return; }
|
||||
el.value = decodeURIComponent(m[1]).replace(/\s+/g, '').toUpperCase();
|
||||
PM.toast('2FA secret read from the QR code');
|
||||
},
|
||||
/// La touche retour annule la selection avant de verrouiller le coffre.
|
||||
/// Sans cela, un geste d'annulation fermait le coffre - le contraire de ce
|
||||
/// qu'il veut dire.
|
||||
|
||||
+51
-17
@@ -17,10 +17,14 @@ body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Les polices a chasse fixe n'ont pas l'arabe : Windows empruntait une police
|
||||
au hasard, plus petite. 'Segoe UI' / Tahoma, places avant 'monospace' dans
|
||||
chaque pile, prennent ces caracteres a une taille comparable ; le latin reste
|
||||
dans la police a chasse fixe. */
|
||||
button { font-family: inherit; cursor: pointer; }
|
||||
input, select, textarea { font-family: inherit; font-size: inherit; }
|
||||
kbd {
|
||||
font-family: 'JetBrains Mono', 'SF Mono', Consolas, monospace;
|
||||
font-family: 'JetBrains Mono', 'SF Mono', Consolas, 'Segoe UI', Tahoma, monospace;
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--border);
|
||||
@@ -248,7 +252,7 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
.licence-machine code {
|
||||
display: inline-block; padding: 6px 10px;
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
|
||||
font-family: ui-monospace, "Cascadia Mono", Consolas, 'Segoe UI', Tahoma, monospace;
|
||||
font-size: 14px; letter-spacing: 1px; color: var(--text);
|
||||
user-select: all;
|
||||
}
|
||||
@@ -1216,7 +1220,7 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
}
|
||||
.entry-pw {
|
||||
flex: 1;
|
||||
font-family: 'JetBrains Mono', 'SF Mono', Consolas, monospace;
|
||||
font-family: 'JetBrains Mono', 'SF Mono', Consolas, 'Segoe UI', Tahoma, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
overflow: hidden;
|
||||
@@ -1268,7 +1272,7 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
}
|
||||
.totp-code {
|
||||
flex: 1;
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-family: 'JetBrains Mono', ui-monospace, 'Segoe UI', Tahoma, monospace;
|
||||
font-size: 22px;
|
||||
letter-spacing: 2px;
|
||||
color: var(--accent);
|
||||
@@ -1330,7 +1334,7 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
}
|
||||
.auth-card-code {
|
||||
flex: 1;
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-family: 'JetBrains Mono', ui-monospace, 'Segoe UI', Tahoma, monospace;
|
||||
font-size: 26px;
|
||||
letter-spacing: 3px;
|
||||
color: var(--accent);
|
||||
@@ -1386,7 +1390,7 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
.health-score.is-danger { border-color: #ef4444; color: #ef4444; }
|
||||
.health-score-num {
|
||||
font-size: 36px; font-weight: 700;
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-family: 'JetBrains Mono', ui-monospace, 'Segoe UI', Tahoma, monospace;
|
||||
}
|
||||
.health-score-lbl {
|
||||
font-size: 11px; font-weight: 600;
|
||||
@@ -1417,7 +1421,7 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
.health-bd-n { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.health-bd-pts {
|
||||
text-align: right;
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-family: 'JetBrains Mono', ui-monospace, 'Segoe UI', Tahoma, monospace;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
/* Nothing wrong in this category — dim it so the eye goes to what needs work. */
|
||||
@@ -1572,7 +1576,7 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
.history-date {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-family: 'JetBrains Mono', ui-monospace, 'Segoe UI', Tahoma, monospace;
|
||||
}
|
||||
.history-preview {
|
||||
display: flex; align-items: flex-start; gap: 6px;
|
||||
@@ -1581,7 +1585,7 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
.history-value {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-family: 'JetBrains Mono', ui-monospace, 'Segoe UI', Tahoma, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
/* Long revealed passwords wrap to multiple lines (no ellipsis) so
|
||||
@@ -1634,7 +1638,7 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cheatsheet-keys kbd {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-family: 'JetBrains Mono', ui-monospace, 'Segoe UI', Tahoma, monospace;
|
||||
font-size: 11px;
|
||||
padding: 2px 7px;
|
||||
background: var(--bg);
|
||||
@@ -1814,7 +1818,7 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
.audit-row:hover { background: var(--bg); }
|
||||
.audit-date {
|
||||
color: var(--text-dim);
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-family: 'JetBrains Mono', ui-monospace, 'Segoe UI', Tahoma, monospace;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.audit-action {
|
||||
@@ -1823,7 +1827,7 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
}
|
||||
.audit-ip {
|
||||
color: var(--text-faint);
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-family: 'JetBrains Mono', ui-monospace, 'Segoe UI', Tahoma, monospace;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@@ -1969,7 +1973,7 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
.so-note-body {
|
||||
min-height: 220px;
|
||||
resize: vertical;
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-family: 'JetBrains Mono', ui-monospace, 'Segoe UI', Tahoma, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
@@ -1979,6 +1983,15 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
.so-note-body.is-masked,
|
||||
.so-custom-multiline.is-masked { -webkit-text-security: disc; }
|
||||
.so-note-label { display: flex; align-items: center; justify-content: space-between; }
|
||||
/* Note de commandes : une ligne par commande, groupees sous leur titre. */
|
||||
.so-cmds-toggle { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-dim); cursor: pointer; }
|
||||
.so-cmds-list { margin-top: 8px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.so-cmds-group { font-size: 11px; font-weight: 600; text-transform: uppercase; color: var(--text-dim); margin-top: 8px; }
|
||||
.so-cmds-row { display: flex; align-items: center; gap: 8px; padding: 4px 8px; border: 1px solid var(--border); border-radius: 6px; }
|
||||
.so-cmds-cmd { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
|
||||
.so-cmds-empty { font-size: 12px; color: var(--text-dim); }
|
||||
#qsBack { flex-shrink: 0; }
|
||||
.qs-cmd { font-family: ui-monospace, Consolas, 'Segoe UI', Tahoma, monospace; font-size: 13px; }
|
||||
|
||||
/* ---- + New dropdown ---------------------------------- */
|
||||
.new-entry-wrap {
|
||||
@@ -2080,7 +2093,9 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.quick-search-input input {
|
||||
flex: 1;
|
||||
/* min-width:0 : sans lui le champ garde la largeur de son texte d'aide et
|
||||
pousse l'interrupteur et Esc hors du cadre. */
|
||||
flex: 1; min-width: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
@@ -2088,6 +2103,7 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
outline: none;
|
||||
}
|
||||
.quick-search-input kbd {
|
||||
flex-shrink: 0;
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
@@ -2133,6 +2149,19 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
color: var(--text);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.qs-mode {
|
||||
display: flex; gap: 2px; padding: 2px; flex-shrink: 0;
|
||||
background: var(--bg-elev-2); border-radius: 999px;
|
||||
}
|
||||
.qs-mode button {
|
||||
border: 0; background: none; cursor: pointer;
|
||||
color: var(--text-dim); font-size: 12px;
|
||||
padding: 4px 10px; border-radius: 999px;
|
||||
}
|
||||
.qs-mode button.is-on { background: var(--accent); color: #fff; }
|
||||
.quick-search-acts {
|
||||
display: flex; gap: 2px; flex-shrink: 0;
|
||||
}
|
||||
.quick-search-sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
@@ -2269,6 +2298,11 @@ body[data-editor-position="center"]:has(#settingsPanel.is-open)::before {
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.slideover-body { padding: 20px; overflow-y: auto; flex: 1; }
|
||||
/* Precedent / Suivant : le chevron du sprite, tourne. Le titre prend la place. */
|
||||
#slideover .slideover-header h3 { flex: 1; min-width: 0; margin-left: 6px; }
|
||||
.so-hist:disabled { opacity: .3; cursor: default; }
|
||||
.so-hist-back { transform: rotate(90deg); }
|
||||
.so-hist-fwd { transform: rotate(-90deg); }
|
||||
.slideover-field { margin-bottom: 16px; }
|
||||
/* Settings panel search — sits below the header, above the body. Only
|
||||
present inside #settingsPanel. */
|
||||
@@ -2513,7 +2547,7 @@ body[data-editor-position="center"]:has(#settingsPanel.is-open)::before {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-family: 'JetBrains Mono', 'Segoe UI', Tahoma, monospace;
|
||||
font-size: 13px;
|
||||
word-break: break-all;
|
||||
margin-bottom: 16px;
|
||||
@@ -2750,7 +2784,7 @@ body[data-editor-position="center"]:has(#settingsPanel.is-open)::before {
|
||||
|
||||
/* ---- Hotkey capture buttons (Settings → Autofill) ---- */
|
||||
.hotkey-capture-btn {
|
||||
font-family: ui-monospace, "Cascadia Code", Menlo, monospace;
|
||||
font-family: ui-monospace, "Cascadia Code", Menlo, 'Segoe UI', Tahoma, monospace;
|
||||
font-size: 11px;
|
||||
min-width: 140px;
|
||||
text-align: center;
|
||||
@@ -3210,7 +3244,7 @@ input[type="password"]::-ms-clear {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.prc-code {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-family: 'Courier New', 'Segoe UI', Tahoma, monospace;
|
||||
font-size: 32px;
|
||||
letter-spacing: 6px;
|
||||
text-align: center;
|
||||
|
||||
@@ -109,6 +109,7 @@ type
|
||||
// WinEvent hook that watches every foreground change in the session.
|
||||
FForegroundHook: THandle;
|
||||
FOnSystemLock: TProc;
|
||||
FOnSystemResume: TProc;
|
||||
// Set TRUE the moment Windows tells us the session is ending
|
||||
// (WM_QUERYENDSESSION / WM_ENDSESSION). FormCloseQuery checks this
|
||||
// to bypass the "minimize to tray" intercept so the form closes
|
||||
@@ -182,6 +183,7 @@ type
|
||||
// les declenche aussi, meme capture de la fenetre cible.
|
||||
procedure FireNewEntryRequest;
|
||||
procedure FireQuickSearchRequest;
|
||||
procedure PasteClipboardAsKeystrokes;
|
||||
/// Pose l'opacite qui convient a l'etat de survol donne.
|
||||
procedure ApplyLazyAlpha(AHover: Boolean);
|
||||
/// Menu du clic droit sur la barrette : le choix de l'opacite au repos.
|
||||
@@ -296,6 +298,8 @@ type
|
||||
property AutofillTotpActive: Boolean read FAutofillTotpActive;
|
||||
// Fired on main thread when Windows locks the session (WTS_SESSION_LOCK).
|
||||
property OnSystemLock: TProc read FOnSystemLock write FOnSystemLock;
|
||||
// Sortie de veille ou session deverrouillee : de quoi reveiller la page.
|
||||
property OnSystemResume: TProc read FOnSystemResume write FOnSystemResume;
|
||||
// True once WM_QUERYENDSESSION (or WM_ENDSESSION) has been received.
|
||||
// FormCloseQuery uses this to allow normal close during shutdown.
|
||||
property ShutdownPending: Boolean read FShutdownPending;
|
||||
@@ -406,6 +410,7 @@ const
|
||||
const
|
||||
WM_WTSSESSION_CHANGE = $02B1;
|
||||
WTS_SESSION_LOCK = 7;
|
||||
WTS_SESSION_UNLOCK = 8;
|
||||
NOTIFY_FOR_THIS_SESSION = 0;
|
||||
|
||||
// Power management broadcast — sent to all top-level windows when the
|
||||
@@ -446,6 +451,7 @@ const
|
||||
LAZY_ID_ADD = 106; // nouvelle entree depuis la fenetre en avant (Ctrl+Shift+A)
|
||||
LAZY_ID_SEARCH = 107; // recherche rapide (Ctrl+Shift+Q)
|
||||
LAZY_ID_SETTINGS = 108; // reglages, onglet Fill bar
|
||||
LAZY_ID_PASTE = 109; // tape le presse-papiers Windows dans la fenetre en avant
|
||||
LAZY_MENU_ICONS = 1; // hors des valeurs d'opacite (25..100) du meme menu
|
||||
LAZY_MENU_SETTINGS = 2;
|
||||
LAZY_MENU_CLEAR = 3;
|
||||
@@ -1112,6 +1118,59 @@ end;
|
||||
// The target of a fill is whatever window owns the foreground at the moment
|
||||
// the user asks for it - a shortcut, or a click on the lazy strip, which takes
|
||||
// no foreground precisely so this stays true.
|
||||
/// COLLER EN TAPANT. Certains clients a distance (TigerVNC vers un Mac) ne
|
||||
/// transmettent pas le presse-papiers Windows : Ctrl+V n'y colle rien. Le texte
|
||||
/// est donc TAPE dans la fenetre en avant, par le meme chemin que l'autofill -
|
||||
/// vraies touches pour un client VNC, dispositions basculees au besoin. Le
|
||||
/// presse-papiers est celui de Windows : coffre ouvert ou non, rien ne passe
|
||||
/// par la page. Pas de Ctrl+A + Suppr avant : on insere au curseur, comme un
|
||||
/// collage.
|
||||
procedure TPMBridge.PasteClipboardAsKeystrokes;
|
||||
var
|
||||
LTarget: HWND;
|
||||
LData: THandle;
|
||||
LPtr: PWideChar;
|
||||
LText: string;
|
||||
begin
|
||||
LTarget := GetForegroundWindow;
|
||||
if IsOwnProcessWindow(LTarget) then
|
||||
begin
|
||||
ShowBalloon('Nothing to paste into',
|
||||
'Click where the text should go first, then press Paste.', True);
|
||||
Exit;
|
||||
end;
|
||||
LText := '';
|
||||
if OpenClipboard(0) then
|
||||
try
|
||||
LData := GetClipboardData(CF_UNICODETEXT);
|
||||
if LData <> 0 then
|
||||
begin
|
||||
LPtr := GlobalLock(LData);
|
||||
if LPtr <> nil then
|
||||
try
|
||||
LText := LPtr;
|
||||
finally
|
||||
GlobalUnlock(LData);
|
||||
end;
|
||||
end;
|
||||
finally
|
||||
CloseClipboard;
|
||||
end;
|
||||
// Un retour a la ligne final, tape dans un terminal, vaudrait Entree.
|
||||
// Les sauts de ligne internes partent en #13 : Entree, comme un collage.
|
||||
LText := LText.Replace(#13#10, #10).Replace(#13, #10).TrimRight([#10])
|
||||
.Replace(#10, #13);
|
||||
if LText = '' then
|
||||
begin
|
||||
// Une image ou un fichier copies ne sont pas du texte : le dire tel quel.
|
||||
ShowBalloon('No text in the clipboard', 'Copy some text first, then press Paste.', True);
|
||||
Exit;
|
||||
end;
|
||||
if not ExecuteAutofill(LTarget, '', LText, False, False, False) then
|
||||
ShowBalloon('Paste blocked',
|
||||
'The window did not accept the keystrokes (it may run as administrator).', True);
|
||||
end;
|
||||
|
||||
procedure TPMBridge.FireAutofillRequest(AKind: TAutofillKind;
|
||||
AFromBar: Boolean = False);
|
||||
var
|
||||
@@ -1202,6 +1261,47 @@ begin
|
||||
Result := CallWindowProc(GLazyOldBtnProc, AWnd, AMsg, AWp, ALp);
|
||||
end;
|
||||
|
||||
// LA BARRE DES TACHES EST TOPMOST ELLE AUSSI, et la barrette a le droit de se
|
||||
// poser dessus. Redemander HWND_TOPMOST ne suffit pas quand on l'est deja :
|
||||
// Windows n'y voit rien a changer et la barrette reste dessous - invisible au
|
||||
// demarrage de session, puis des qu'on ouvre le menu Demarrer. Le passage par
|
||||
// NOTOPMOST force la remontee en tete de la bande. Fait seulement quand elle
|
||||
// est vraiment recouverte (un point de sa surface ne lui appartient plus),
|
||||
// pour ne pas remuer l'ordre des fenetres cinq fois par seconde.
|
||||
function LazyIsCovered(AWnd: HWND): Boolean;
|
||||
var
|
||||
LRect: TRect;
|
||||
LPt: TPoint;
|
||||
LHit: HWND;
|
||||
LPid: DWORD;
|
||||
begin
|
||||
Result := False;
|
||||
if not GetWindowRect(AWnd, LRect) then Exit;
|
||||
LPt.X := (LRect.Left + LRect.Right) div 2;
|
||||
LPt.Y := (LRect.Top + LRect.Bottom) div 2;
|
||||
LHit := WindowFromPoint(LPt);
|
||||
if LHit = 0 then Exit;
|
||||
if (LHit = AWnd) or (GetAncestor(LHit, GA_ROOT) = AWnd) then Exit;
|
||||
// NOS PROPRES FENETRES NE RECOUVRENT PAS. L'info-bulle d'un bouton s'affiche
|
||||
// PAR-DESSUS la barrette : la compter comme un recouvrement faisait
|
||||
// repasser la barrette devant (NOTOPMOST puis TOPMOST - elle disparaissait
|
||||
// un instant), ce qui fermait l'info-bulle, qui se rouvrait sous la souris
|
||||
// immobile... en boucle.
|
||||
GetWindowThreadProcessId(LHit, @LPid);
|
||||
Result := LPid <> GetCurrentProcessId;
|
||||
end;
|
||||
|
||||
procedure LazyForceToTop(AWnd: HWND);
|
||||
begin
|
||||
// UNE SEULE REMONTEE, PAS DE DETOUR PAR LE BAS. Le passage par NOTOPMOST
|
||||
// faisait descendre la barrette sous les fenetres ordinaires l'espace d'un
|
||||
// instant - un flash visible a chaque battement - et, mesure a l'appui, il
|
||||
// ne gagnait rien contre la barre des taches active, seul cas ou elle reste
|
||||
// recouverte. Contre une fenetre TOPMOST ordinaire, TOPMOST suffit.
|
||||
SetWindowPos(AWnd, HWND_TOPMOST, 0, 0, 0, 0,
|
||||
SWP_NOMOVE or SWP_NOSIZE or SWP_NOACTIVATE);
|
||||
end;
|
||||
|
||||
procedure TPMBridge.DrawLazyButton(ADraw: PDrawItemStruct);
|
||||
var
|
||||
LRect: TRect;
|
||||
@@ -1257,6 +1357,7 @@ begin
|
||||
LAZY_ID_SEARCH: LGlyph := #$E721; // Search
|
||||
LAZY_ID_OPEN: LGlyph := #$E8A7; // OpenInNewWindow
|
||||
LAZY_ID_SETTINGS: LGlyph := #$E713; // Settings (roue)
|
||||
LAZY_ID_PASTE: LGlyph := #$E77F; // Paste
|
||||
else
|
||||
LGlyph := #$E711; // Cancel (X)
|
||||
end;
|
||||
@@ -1389,13 +1490,10 @@ begin
|
||||
ApplyLazyAlpha(LIn);
|
||||
end;
|
||||
// TOPMOST N'EST PAS UN RANG, C'EST UNE BANDE. La barre des taches y est
|
||||
// elle aussi, et cliquer son fond vide la remonte en tete de cette
|
||||
// bande : une barrette posee dessus passe DERRIERE et semble avoir
|
||||
// disparu, alors qu'elle est seulement recouverte. La reposer a chaque
|
||||
// battement est ce que font tous les outils flottants ; sans mouvement
|
||||
// ni taille a changer, l'appel ne repeint rien.
|
||||
SetWindowPos(FLazyWnd, HWND_TOPMOST, 0, 0, 0, 0,
|
||||
SWP_NOMOVE or SWP_NOSIZE or SWP_NOACTIVATE);
|
||||
// elle aussi, et se remet en tete de cette bande au demarrage de la
|
||||
// session comme a l'ouverture du menu Demarrer : une barrette posee
|
||||
// dessus passe DERRIERE et semble avoir disparu.
|
||||
if LazyIsCovered(FLazyWnd) then LazyForceToTop(FLazyWnd);
|
||||
Exit;
|
||||
end;
|
||||
// Fin du glissement (la boucle modale ouverte par HTCAPTION), donc une
|
||||
@@ -1463,6 +1561,7 @@ begin
|
||||
LAZY_ID_OPEN: if Assigned(FOnTrayRestore) then FOnTrayRestore();
|
||||
LAZY_ID_ADD: FireNewEntryRequest;
|
||||
LAZY_ID_SEARCH: FireQuickSearchRequest;
|
||||
LAZY_ID_PASTE: PasteClipboardAsKeystrokes;
|
||||
LAZY_ID_SETTINGS:
|
||||
begin
|
||||
if Assigned(FOnTrayRestore) then FOnTrayRestore();
|
||||
@@ -1627,6 +1726,7 @@ begin
|
||||
AddButton('full', LAZY_ID_FULL, 'Full', 'Fill username and password', 56);
|
||||
AddButton('pwd', LAZY_ID_PWD, 'Password', 'Fill the password only', 76);
|
||||
AddButton('totp', LAZY_ID_TOTP, '2FA', 'Fill the 2FA code', 52);
|
||||
AddButton('paste', LAZY_ID_PASTE, 'Paste', 'Type the Windows clipboard into the current window', 52);
|
||||
AddButton('add', LAZY_ID_ADD, 'Add', 'New entry from the current window', 48);
|
||||
AddButton('search', LAZY_ID_SEARCH, 'Search', 'Quick search', 60);
|
||||
// Meme raison que le X : quand la barrette est allumee, elle est souvent la
|
||||
@@ -1717,6 +1817,8 @@ begin
|
||||
begin
|
||||
if AMsg.WParam = WTS_SESSION_LOCK then
|
||||
if Assigned(FOnSystemLock) then FOnSystemLock();
|
||||
if AMsg.WParam = WTS_SESSION_UNLOCK then
|
||||
if Assigned(FOnSystemResume) then FOnSystemResume();
|
||||
end
|
||||
|
||||
else if AMsg.Msg = WM_POWERBROADCAST then
|
||||
@@ -1730,6 +1832,8 @@ begin
|
||||
// suspends — fast handler required (no UI prompts, no network).
|
||||
if AMsg.WParam = PBT_APMSUSPEND then
|
||||
if Assigned(FOnSystemLock) then FOnSystemLock();
|
||||
if AMsg.WParam = PBT_APMRESUMEAUTOMATIC then
|
||||
if Assigned(FOnSystemResume) then FOnSystemResume();
|
||||
end
|
||||
|
||||
else if (AMsg.Msg = WM_QUERYENDSESSION) or (AMsg.Msg = WM_ENDSESSION) then
|
||||
@@ -2030,6 +2134,157 @@ begin
|
||||
SendInput(LCount, @LInputs[0], SizeOf(TInput));
|
||||
end;
|
||||
|
||||
function QueryFullProcessImageNameW(hProcess: THandle; dwFlags: DWORD;
|
||||
lpExeName: PWideChar; var lpdwSize: DWORD): BOOL; stdcall;
|
||||
external kernel32 name 'QueryFullProcessImageNameW';
|
||||
|
||||
/// UN VISUALISEUR VNC NE SAIT PAS TAPER DE L'UNICODE. SendUnicodeString envoie
|
||||
/// chaque caractere en VK_PACKET ; une fenetre Windows le comprend, mais VNC
|
||||
/// (TigerVNC, RealVNC, TightVNC, UltraVNC) traduit les VRAIES touches vers la
|
||||
/// machine distante et ne sait que faire de VK_PACKET : un Mac recevait
|
||||
/// "VVVVVV", un V par caractere. Pour lui on tape les touches du clavier.
|
||||
/// ponytail: reconnu au nom de l'exe ("vnc") ; un autre client a distance qui
|
||||
/// aurait le meme defaut s'ajouterait ici, ou deviendrait un reglage.
|
||||
function TargetTypesByKeyCodes(AHwnd: HWND): Boolean;
|
||||
const
|
||||
PROCESS_QUERY_LIMITED_INFORMATION = $1000;
|
||||
var
|
||||
LPid, LLen: DWORD;
|
||||
LProc: THandle;
|
||||
LName: array[0..MAX_PATH] of WideChar;
|
||||
begin
|
||||
Result := False;
|
||||
LPid := 0;
|
||||
GetWindowThreadProcessId(AHwnd, LPid);
|
||||
if LPid = 0 then Exit;
|
||||
LProc := OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, LPid);
|
||||
if LProc = 0 then Exit;
|
||||
try
|
||||
LLen := Length(LName);
|
||||
if QueryFullProcessImageNameW(LProc, 0, @LName[0], LLen) then
|
||||
Result := ExtractFileName(string(LName)).ToLower.Contains('vnc');
|
||||
finally
|
||||
CloseHandle(LProc);
|
||||
end;
|
||||
end;
|
||||
|
||||
/// Le texte en VRAIES touches, dans la disposition clavier de la cible : chaque
|
||||
/// caractere devient sa touche plus Maj / Ctrl / Alt selon VkKeyScanEx, avec
|
||||
/// son scancode (certains clients VNC lisent la position physique).
|
||||
/// ALTGR = Ctrl GAUCHE + Alt DROIT (touche etendue), comme le clavier le
|
||||
/// produit : un Ctrl+Alt generique etait lu par TigerVNC comme deux
|
||||
/// modificateurs ordinaires, et "@" (AltGr+0 en AZERTY) arrivait en "a".
|
||||
/// TEXTE MELANGE (arabe + latin) : un caractere sans touche dans la
|
||||
/// disposition courante est cherche dans les AUTRES dispositions installees
|
||||
/// sur le poste ; on y bascule la fenetre cible (TigerVNC traduit chaque touche
|
||||
/// avec la disposition de SON fil), on tape, et la disposition d'origine est
|
||||
/// rendue a la fin. Seul un caractere qu'aucune disposition installee ne sait
|
||||
/// taper est saute et compte - la copie, que TigerVNC synchronise, reste alors
|
||||
/// le recours. Rend le nombre de sautes.
|
||||
function SwitchTargetLayout(ATarget: HWND; ALayout: HKL): Boolean;
|
||||
var
|
||||
LThread: DWORD;
|
||||
begin
|
||||
LThread := GetWindowThreadProcessId(ATarget, nil);
|
||||
PostMessage(ATarget, WM_INPUTLANGCHANGEREQUEST, 0, LPARAM(ALayout));
|
||||
// La bascule est asynchrone : on attend qu'elle soit faite avant de taper,
|
||||
// sinon les premieres touches partiraient encore dans l'ancienne.
|
||||
for var I := 1 to 30 do
|
||||
begin
|
||||
if GetKeyboardLayout(LThread) = ALayout then Exit(True);
|
||||
Sleep(10);
|
||||
end;
|
||||
Result := GetKeyboardLayout(LThread) = ALayout;
|
||||
end;
|
||||
|
||||
function LayoutForChar(ACh: WideChar; ACurrent: HKL): HKL;
|
||||
var
|
||||
LList: array[0..31] of HKL;
|
||||
N, I: Integer;
|
||||
begin
|
||||
Result := 0;
|
||||
N := GetKeyboardLayoutList(Length(LList), LList);
|
||||
for I := 0 to N - 1 do
|
||||
if (LList[I] <> ACurrent) and (VkKeyScanEx(ACh, LList[I]) <> -1) then
|
||||
Exit(LList[I]);
|
||||
end;
|
||||
|
||||
function SendKeyCodeString(const AText: string; ATarget: HWND; ALayout: HKL): Integer;
|
||||
var
|
||||
LInputs: TArray<TInput>;
|
||||
LCount: Integer;
|
||||
procedure Key(AVk: Word; AUp: Boolean; AExtended: Boolean = False);
|
||||
begin
|
||||
if LCount >= Length(LInputs) then SetLength(LInputs, LCount + 16);
|
||||
FillChar(LInputs[LCount], SizeOf(TInput), 0);
|
||||
LInputs[LCount].Itype := INPUT_KEYBOARD;
|
||||
LInputs[LCount].ki.wVk := AVk;
|
||||
LInputs[LCount].ki.wScan := MapVirtualKeyEx(AVk, 0 {MAPVK_VK_TO_VSC}, ALayout);
|
||||
if AUp then LInputs[LCount].ki.dwFlags := KEYEVENTF_KEYUP;
|
||||
if AExtended then
|
||||
LInputs[LCount].ki.dwFlags := LInputs[LCount].ki.dwFlags or KEYEVENTF_EXTENDEDKEY;
|
||||
Inc(LCount);
|
||||
end;
|
||||
var
|
||||
I: Integer;
|
||||
LScan: SHORT;
|
||||
LVk, LMods: Byte;
|
||||
LShift, LCtrl, LAlt: Boolean;
|
||||
begin
|
||||
Result := 0;
|
||||
var LOriginal := ALayout;
|
||||
try
|
||||
for I := 1 to Length(AText) do
|
||||
begin
|
||||
LScan := VkKeyScanEx(AText[I], ALayout);
|
||||
if LScan = -1 then
|
||||
begin
|
||||
var LOther := LayoutForChar(AText[I], ALayout);
|
||||
if (LOther <> 0) and SwitchTargetLayout(ATarget, LOther) then
|
||||
begin
|
||||
ALayout := LOther;
|
||||
LScan := VkKeyScanEx(AText[I], ALayout);
|
||||
end;
|
||||
end;
|
||||
if LScan = -1 then
|
||||
begin
|
||||
Inc(Result);
|
||||
Continue;
|
||||
end;
|
||||
LVk := Lo(Word(LScan));
|
||||
LMods := Hi(Word(LScan));
|
||||
LShift := LMods and 1 <> 0;
|
||||
LCtrl := LMods and 2 <> 0;
|
||||
LAlt := LMods and 4 <> 0;
|
||||
LCount := 0;
|
||||
if LShift then Key(VK_LSHIFT, False);
|
||||
if LCtrl then Key(VK_LCONTROL, False);
|
||||
// Alt seul = Alt gauche ; avec Ctrl, c'est AltGr = Alt droit, etendu.
|
||||
if LAlt and LCtrl then Key(VK_RMENU, False, True)
|
||||
else if LAlt then Key(VK_LMENU, False);
|
||||
Key(LVk, False);
|
||||
Key(LVk, True);
|
||||
if LAlt and LCtrl then Key(VK_RMENU, True, True)
|
||||
else if LAlt then Key(VK_LMENU, True);
|
||||
if LCtrl then Key(VK_LCONTROL, True);
|
||||
if LShift then Key(VK_LSHIFT, True);
|
||||
SendInput(LCount, @LInputs[0], SizeOf(TInput));
|
||||
end;
|
||||
finally
|
||||
// Rendre a l'utilisateur la disposition qu'il avait choisie.
|
||||
if ALayout <> LOriginal then SwitchTargetLayout(ATarget, LOriginal);
|
||||
end;
|
||||
end;
|
||||
|
||||
/// Rend le nombre de caracteres qui n'ont pas pu etre tapes (VNC seulement).
|
||||
function SendTextTo(const AText: string; AByKeys: Boolean; ATarget: HWND;
|
||||
ALayout: HKL): Integer;
|
||||
begin
|
||||
Result := 0;
|
||||
if AByKeys then Result := SendKeyCodeString(AText, ATarget, ALayout)
|
||||
else SendUnicodeString(AText);
|
||||
end;
|
||||
|
||||
// Send one virtual-key press (down+up).
|
||||
procedure SendVKey(AVk: Word);
|
||||
var
|
||||
@@ -2244,8 +2499,12 @@ begin
|
||||
LSw := TStopwatch.StartNew;
|
||||
WaitForModifierRelease(1000);
|
||||
ForceReleaseModifiers; // timeout hit with keys still down → clean state
|
||||
var LByKeys := (ATargetHWND <> 0) and TargetTypesByKeyCodes(ATargetHWND);
|
||||
var LLayout := GetKeyboardLayout(GetWindowThreadProcessId(ATargetHWND, nil));
|
||||
FLastFillWaits := Format('foreground=%d ms, modifiers=%d ms',
|
||||
[LFgMs, LSw.ElapsedMilliseconds]);
|
||||
if LByKeys then FLastFillWaits := FLastFillWaits + ', typed as key codes (VNC)';
|
||||
var LMissing := 0;
|
||||
Sleep(FocusSettleDelayMs);
|
||||
|
||||
// Never type into our own window: if the target refused the foreground,
|
||||
@@ -2269,19 +2528,19 @@ begin
|
||||
if AUsernameOnly then
|
||||
begin
|
||||
ClearFieldIfWanted;
|
||||
SendUnicodeString(AUsername);
|
||||
Inc(LMissing, SendTextTo(AUsername, LByKeys, ATargetHWND, LLayout));
|
||||
Exit;
|
||||
end;
|
||||
|
||||
if AUsername = '' then
|
||||
begin
|
||||
ClearFieldIfWanted;
|
||||
SendUnicodeString(APassword);
|
||||
Inc(LMissing, SendTextTo(APassword, LByKeys, ATargetHWND, LLayout));
|
||||
Exit;
|
||||
end;
|
||||
|
||||
ClearFieldIfWanted;
|
||||
SendUnicodeString(AUsername);
|
||||
Inc(LMissing, SendTextTo(AUsername, LByKeys, ATargetHWND, LLayout));
|
||||
Sleep(200);
|
||||
// Real-VK send: a physically held Shift here turns Tab into Shift+Tab —
|
||||
// focus moves BACKWARD and the password lands in the username field.
|
||||
@@ -2293,8 +2552,12 @@ begin
|
||||
// Tab lands somewhere that is not an editable field (a link, a reveal
|
||||
// button, the document itself, as on the Reddit login form) it selects the
|
||||
// ENTIRE PAGE, and the password is then typed into nothing.
|
||||
SendUnicodeString(APassword);
|
||||
Inc(LMissing, SendTextTo(APassword, LByKeys, ATargetHWND, LLayout));
|
||||
finally
|
||||
if LMissing > 0 then
|
||||
FLastFillWaits := FLastFillWaits + Format(
|
||||
', %d character(s) have no key on this keyboard and were NOT typed - copy instead',
|
||||
[LMissing]);
|
||||
// Re-register with the LIVE combo (user may have rebound it), not the
|
||||
// hardcoded default — otherwise a fill would reset new-entry to Ctrl+Shift+A.
|
||||
if FNewEntryHotkeyRegistered then
|
||||
|
||||
+101
-11
@@ -98,6 +98,13 @@ type
|
||||
FFileSaveChunks: TDictionary<string, TStringBuilder>;
|
||||
FPendingURL: string;
|
||||
FNavTimer: TTimer;
|
||||
// REVEIL DE LA PAGE pendant que l'application dort dans le tray. Les
|
||||
// drapeaux --disable-renderer-backgrounding & co sont bien appliques, et
|
||||
// pourtant le premier autofill apres des heures cachee attendait 3 a 5 s
|
||||
// la reponse de la page (mesure : 8 ms coffre tout juste ouvert, 4907 ms
|
||||
// apres une veille). Windows laisse s'endormir un processus que personne
|
||||
// ne sollicite ; une instruction JS triviale toutes les 5 min l'en empeche.
|
||||
FKeepWarmTimer: TTimer;
|
||||
// Set once WebView2 fires OnInitialized. The nav timer only consumes
|
||||
// FPendingURL when this is True — otherwise a timer tick that lands
|
||||
// before the engine is ready would Navigate() into the void AND clear
|
||||
@@ -163,6 +170,7 @@ type
|
||||
procedure UpdateButtons;
|
||||
procedure NavigateToVault;
|
||||
procedure NavTimerTick(Sender: TObject);
|
||||
procedure KeepWarmTick(Sender: TObject);
|
||||
// JS↔Delphi bridge
|
||||
procedure WebBrowserBeforeNavigate(Sender: TObject;
|
||||
var Params: TTMSFNCCustomWebBrowserBeforeNavigateParams);
|
||||
@@ -287,7 +295,20 @@ begin
|
||||
// USE_EDGE_BROWSER} directive can swap the concrete class without a
|
||||
// second .fmx variant. Align Client to fill the area below PanelTop
|
||||
// and above the (collapsible) PanelLog + Splitter.
|
||||
// CACHE EFFACE AU DEMARRAGE, PAS A LA FERMETURE. TMS l'effacait dans son
|
||||
// destructeur, pendant que les processus msedgewebview2 supprimaient encore
|
||||
// leurs propres fichiers : un fichier disparu en cours de parcours
|
||||
// (EBWebView\Default\DIPS-wal) levait EDirectoryNotFoundException, non
|
||||
// rattrapee, a la fermeture. Ici aucun moteur ne tourne encore - la garde
|
||||
// d'instance unique est passee dans le .dpr. Meme dossier que TMS :
|
||||
// %TEMP%\<exe>\EdgeCache. Echec ignore : un cache reste un cache.
|
||||
try
|
||||
TDirectory.Delete(TPath.Combine(TPath.Combine(TPath.GetTempPath,
|
||||
ChangeFileExt(ExtractFileName(ParamStr(0)), '')), 'EdgeCache'), True);
|
||||
except
|
||||
end;
|
||||
WebBrowser := TWebBrowserClass.Create(Self);
|
||||
WebBrowser.AutoClearCache := False;
|
||||
// Wire events BEFORE Parent assignment: setting Parent triggers the
|
||||
// TMS browser's async WebView2 init, which fires OnInitialized when
|
||||
// Edge Chromium is ready. If we assigned the handlers afterwards we'd
|
||||
@@ -313,6 +334,14 @@ begin
|
||||
FBridge := TPMBridge.Create(Self);
|
||||
FFileSaveChunks := TDictionary<string, TStringBuilder>.Create;
|
||||
FBridge.OnSystemLock := BridgeSystemLock;
|
||||
// Le reveil des 5 min ne tourne pas pendant la veille : on le donne tout de
|
||||
// suite au retour, avant le premier autofill.
|
||||
FBridge.OnSystemResume :=
|
||||
procedure
|
||||
begin
|
||||
LogLine('Windows resumed / unlocked — waking the page');
|
||||
KeepWarmTick(nil);
|
||||
end;
|
||||
FBridge.OnTrayRestore := BridgeTrayRestore;
|
||||
// Changement fait sur la barrette elle-meme : l'onglet se relit (fillbar/get).
|
||||
FBridge.OnFillBarChanged :=
|
||||
@@ -335,6 +364,12 @@ begin
|
||||
FBridge.OnNewEntryHotkey := BridgeNewEntryHotkey;
|
||||
FBridge.OnQuickSearchRequest := BridgeQuickSearchRequest;
|
||||
FBridge.OnQuickSearchHotkey := BridgeQuickSearchHotkey;
|
||||
// Les combinaisons de la derniere session (dont les raccourcis desactives),
|
||||
// sinon les valeurs par defaut.
|
||||
var LSavedHotkeys := PM.UserPrefs.GetPref('hotkeys');
|
||||
if LSavedHotkeys <> '' then
|
||||
HandleBridgeCommand('autofill/hotkeys', LSavedHotkeys)
|
||||
else
|
||||
FBridge.RegisterAutofillHotkey; // Ctrl+Shift+L active from startup
|
||||
FBridge.ApplyTitleBarTheme(True); // dark by default, JS may toggle later
|
||||
// Apres le theme et apres OnTrayRestore : la barrette se peint elle-meme, et
|
||||
@@ -351,6 +386,10 @@ begin
|
||||
FNavTimer.Enabled := False;
|
||||
FNavTimer.OnTimer := NavTimerTick;
|
||||
|
||||
FKeepWarmTimer := TTimer.Create(Self);
|
||||
FKeepWarmTimer.Interval := 5 * 60 * 1000;
|
||||
FKeepWarmTimer.OnTimer := KeepWarmTick;
|
||||
|
||||
UpdateButtons;
|
||||
LogLine('Password Manager - d backend ready.');
|
||||
LogLine('Click Start to launch server + embedded web vault.');
|
||||
@@ -805,6 +844,12 @@ begin
|
||||
FNavTimer.Enabled := True;
|
||||
end;
|
||||
|
||||
procedure TMainForm.KeepWarmTick(Sender: TObject);
|
||||
begin
|
||||
if FBrowserInitialized then
|
||||
WebBrowser.ExecuteJavaScript('window.Bridge&&Bridge.active');
|
||||
end;
|
||||
|
||||
procedure TMainForm.NavTimerTick(Sender: TObject);
|
||||
begin
|
||||
FNavTimer.Enabled := False;
|
||||
@@ -1107,6 +1152,13 @@ begin
|
||||
// Ids, not labels: JS owns the wording AND uses them to flag the exact
|
||||
// row in Settings. A combo the user cleared (vk=0) is never listed.
|
||||
var LFailed := '';
|
||||
// RETENU EN PREF DPAPI pour etre rejoue au demarrage : les combinaisons
|
||||
// vivent dans settings_json, lu seulement apres la connexion - un raccourci
|
||||
// desactive restait donc pris par les valeurs par defaut tant que le coffre
|
||||
// etait ferme. Pas la suspension de la capture (sans full_vk). Rien de
|
||||
// secret dedans.
|
||||
if GetParam('full_vk') <> '' then
|
||||
PM.UserPrefs.SetPref('hotkeys', AParams);
|
||||
// Quick-search combo is set unconditionally (independent of the
|
||||
// autofill enabled flag) so the picker stays armed even when the
|
||||
// autofill hotkeys are turned off.
|
||||
@@ -1159,6 +1211,7 @@ begin
|
||||
// One notification for the whole batch, naming the combos that failed —
|
||||
// empty string = everything registered.
|
||||
LFailed := LFailed.TrimRight([',', ' ']);
|
||||
if FBrowserInitialized then
|
||||
WebBrowser.ExecuteJavaScript(
|
||||
'if(window.Bridge&&Bridge.onAutofillHotkeysResult)' +
|
||||
'Bridge.onAutofillHotkeysResult("' + LFailed.Replace('"', '') + '")');
|
||||
@@ -1404,9 +1457,14 @@ begin
|
||||
procedure
|
||||
var
|
||||
LText: string;
|
||||
LFound: Boolean;
|
||||
begin
|
||||
Sleep(400); // laisser Windows retirer la fenetre de l'ecran
|
||||
LText := ScanScreenForQr;
|
||||
// UN QR LU N'EST PAS UN QR DE 2FA. Le texte d'un autre QR ne part pas a
|
||||
// la page - il n'a rien a y faire -, mais le FAIT qu'un QR a ete lu,
|
||||
// si : "aucun QR" alors qu'il y en avait un envoyait chercher ailleurs.
|
||||
LFound := LText <> '';
|
||||
if Pos('otpauth://', LowerCase(LText)) <> 1 then LText := '';
|
||||
TThread.Queue(nil,
|
||||
procedure
|
||||
@@ -1414,12 +1472,14 @@ begin
|
||||
LJs: TJSONString;
|
||||
begin
|
||||
RestoreWindowFromTray;
|
||||
if LText = '' then LogLine('qr/screen: no otpauth QR found')
|
||||
else LogLine('qr/screen: otpauth QR found');
|
||||
if LText <> '' then LogLine('qr/screen: otpauth QR found')
|
||||
else if LFound then LogLine('qr/screen: a QR code, but not a 2FA one')
|
||||
else LogLine('qr/screen: no QR code on screen');
|
||||
LJs := TJSONString.Create(LText);
|
||||
try
|
||||
WebBrowser.ExecuteJavaScript(
|
||||
'if(window.Bridge&&Bridge.onScreenQr)Bridge.onScreenQr(' + LJs.ToJSON + ')');
|
||||
'if(window.Bridge&&Bridge.onScreenQr)Bridge.onScreenQr(' + LJs.ToJSON
|
||||
+ ',' + BoolToStr(LFound, True).ToLower + ')');
|
||||
finally
|
||||
LJs.Free;
|
||||
end;
|
||||
@@ -1431,6 +1491,12 @@ begin
|
||||
begin
|
||||
var LQrUrl := PM.UserPrefs.GetPref('syncUrl');
|
||||
var LQrTok := PM.UserPrefs.GetPref('syncPwd');
|
||||
// WEBDAV : le telephone attend "utilisateur:mot-de-passe" dans le jeton
|
||||
// (il en fait une authentification Basic).
|
||||
if (Pos('/api/v1/repos/', LQrUrl) = 0) and
|
||||
not ContainsText(LQrUrl, 'api.github.com/repos/') and
|
||||
(PM.UserPrefs.GetPref('syncUser') <> '') and (LQrTok <> '') then
|
||||
LQrTok := PM.UserPrefs.GetPref('syncUser') + ':' + LQrTok;
|
||||
var LBits := '';
|
||||
var LSize := 0;
|
||||
if (LQrUrl <> '') and (LQrTok <> '') then
|
||||
@@ -1448,16 +1514,21 @@ begin
|
||||
var LShort := LQrUrl;
|
||||
var LApi := Pos('/api/v1/repos/', LShort);
|
||||
// GitHub : l'adresse du depot est sur github.com, l'API sur api.github.com.
|
||||
// RACCOURCI SEULEMENT POUR LE NOM PAR DEFAUT COURANT. Un depot
|
||||
// configure avec un autre nom - dont l'ancien vault-sync.json - voyage
|
||||
// en URL complete : le telephone rallongerait sinon vers le defaut et
|
||||
// les deux appareils ne regarderaient pas le meme fichier.
|
||||
const SyncDefaultFile = '/contents/data.json';
|
||||
if StartsText('https://api.github.com/repos/', LShort) and
|
||||
EndsText('/contents/vault-sync.json', LShort) then
|
||||
EndsText(SyncDefaultFile, LShort) then
|
||||
LShort := 'https://github.com/' +
|
||||
Copy(LShort, Length('https://api.github.com/repos/') + 1,
|
||||
Length(LShort) - Length('https://api.github.com/repos/') -
|
||||
Length('/contents/vault-sync.json'))
|
||||
else if (LApi > 0) and EndsText('/contents/vault-sync.json', LShort) then
|
||||
Length(SyncDefaultFile))
|
||||
else if (LApi > 0) and EndsText(SyncDefaultFile, LShort) then
|
||||
LShort := Copy(LShort, 1, LApi) +
|
||||
Copy(LShort, LApi + Length('/api/v1/repos/'),
|
||||
Length(LShort) - LApi - Length('/api/v1/repos/') - Length('/contents/vault-sync.json') + 1);
|
||||
Length(LShort) - LApi - Length('/api/v1/repos/') - Length(SyncDefaultFile) + 1);
|
||||
LShort := StringReplace(LShort, '%', '%25', [rfReplaceAll]);
|
||||
LShort := StringReplace(LShort, '&', '%26', [rfReplaceAll]);
|
||||
LShort := StringReplace(LShort, '#', '%23', [rfReplaceAll]);
|
||||
@@ -1681,6 +1752,15 @@ begin
|
||||
LStatus: Integer;
|
||||
LErr: string;
|
||||
LEtag: string;
|
||||
begin
|
||||
// TROIS ESSAIS, ATTENTE DE CONNEXION COURTE. GitHub publie plusieurs
|
||||
// adresses et l'une d'elles, 140.82.121.5, n'est joignable depuis ce
|
||||
// reseau ni par l'application ni par un navigateur - verifie des deux
|
||||
// cotes. Un navigateur passe aussitot a la suivante ; nous attendions
|
||||
// le delai entier puis abandonnions. Seul un status 0 - la requete n'a
|
||||
// pas abouti - fait recommencer : un 404 ou un 409 sont des reponses.
|
||||
LStatus := 0;
|
||||
for var LTry := 1 to 3 do
|
||||
begin
|
||||
LStatus := 0;
|
||||
LBodyB64 := '';
|
||||
@@ -1689,7 +1769,7 @@ begin
|
||||
try
|
||||
LHttp := System.Net.HttpClient.THTTPClient.Create;
|
||||
try
|
||||
LHttp.ConnectionTimeout := 10000;
|
||||
LHttp.ConnectionTimeout := 8000; // voir la boucle ci-dessus
|
||||
LHttp.ResponseTimeout := 30000;
|
||||
// GitHub REFUSE toute requete sans User-Agent (403) ; Gitea l'ignore.
|
||||
LHttp.UserAgent := 'PMServer';
|
||||
@@ -1704,6 +1784,13 @@ begin
|
||||
end
|
||||
else if (LUser <> '') then
|
||||
begin
|
||||
// BASIC DES LA PREMIERE REQUETE, comme le telephone. Laisse a
|
||||
// Windows, l'authentification partait d'abord en anonyme : 401,
|
||||
// puis la meme requete - et un PUT envoyait le coffre deux fois.
|
||||
// Les identifiants restent aussi en reserve pour un serveur Digest.
|
||||
LHttp.CustomHeaders['Authorization'] := 'Basic ' +
|
||||
TNetEncoding.Base64.Encode(LUser + ':' + LPwd)
|
||||
.Replace(#13, '').Replace(#10, '');
|
||||
LHttp.CredentialsStorage.AddCredential(
|
||||
System.Net.URLClient.TCredentialsStorage.TCredential.Create(
|
||||
System.Net.URLClient.TAuthTargetType.Server, '', '', LUser, LPwd));
|
||||
@@ -1774,6 +1861,8 @@ begin
|
||||
LErr := E.Message;
|
||||
end;
|
||||
end;
|
||||
if LStatus <> 0 then Break;
|
||||
end;
|
||||
TThread.Queue(nil,
|
||||
procedure
|
||||
var
|
||||
@@ -1977,7 +2066,7 @@ begin
|
||||
LogLine('Windows session locked / suspend — notifying JS');
|
||||
WebBrowser.ExecuteJavaScript(
|
||||
'if(window.Bridge&&typeof Bridge.onSystemLock==="function")Bridge.onSystemLock();' +
|
||||
'else if(typeof lockVault==="function")lockVault()');
|
||||
'else if(typeof lockVault==="function")lockVault("Windows session locked or computer asleep")');
|
||||
end;
|
||||
|
||||
procedure TMainForm.BridgeTrayRestore;
|
||||
@@ -1995,7 +2084,7 @@ begin
|
||||
// User picked "Lock vault" from the tray menu. Trigger lockVault() in
|
||||
// JS — same path as the WTS_SESSION_LOCK auto-lock.
|
||||
LogLine('Lock requested from tray menu');
|
||||
WebBrowser.ExecuteJavaScript('if(typeof lockVault==="function")lockVault()');
|
||||
WebBrowser.ExecuteJavaScript('if(typeof lockVault==="function")lockVault("tray menu")');
|
||||
end;
|
||||
|
||||
procedure TMainForm.BridgeQuit;
|
||||
@@ -2205,7 +2294,8 @@ begin
|
||||
FAutofillClock := TStopwatch.StartNew;
|
||||
WebBrowser.ExecuteJavaScript(
|
||||
'if(window.Bridge&&typeof Bridge.onAutofillRequest==="function")' +
|
||||
'Bridge.onAutofillRequest("' + LTitle + '","' + LKind + '")');
|
||||
'Bridge.onAutofillRequest("' + LTitle + '","' + LKind + '",' +
|
||||
BoolToStr(FBridge.LastRequestFromBar, True).ToLower + ')');
|
||||
LogLine('Autofill ' + IfThen(FBridge.LastRequestFromBar, 'fill bar', 'hotkey') +
|
||||
' (' + LKind + ') — foreground: "' + ATitle + '"');
|
||||
end;
|
||||
|
||||
+20
-3
@@ -47,6 +47,8 @@
|
||||
<symbol id="i-printer" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></symbol>
|
||||
<symbol id="i-pin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/></symbol>
|
||||
<symbol id="i-paperclip" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05 12.25 20.24a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></symbol>
|
||||
<symbol id="i-clock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15 14"/></symbol>
|
||||
<symbol id="i-note" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="14 3 14 9 20 9"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="13" y2="17"/></symbol>
|
||||
<symbol id="i-qr" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><path d="M14 14h3v3h-3zM21 14v.01M14 21h.01M17 21h4v-4"/></symbol>
|
||||
<symbol id="i-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></symbol>
|
||||
<symbol id="i-empty-vault" viewBox="0 0 120 120" fill="none">
|
||||
@@ -369,6 +371,10 @@
|
||||
<svg><use href="#i-globe"/></svg>
|
||||
<span>Server credentials</span>
|
||||
</button>
|
||||
<button class="dropdown-item" data-new-template="commands">
|
||||
<svg><use href="#i-edit"/></svg>
|
||||
<span>Commands (note)</span>
|
||||
</button>
|
||||
<button class="dropdown-item" data-new-template="recovery-codes">
|
||||
<svg><use href="#i-shield"/></svg>
|
||||
<span>Recovery codes (note)</span>
|
||||
@@ -419,6 +425,8 @@
|
||||
<!-- ---- SLIDE-OVER DETAIL ------------------------------- -->
|
||||
<aside class="slideover" id="slideover">
|
||||
<header class="slideover-header">
|
||||
<button class="icon-btn so-hist" id="slideoverBack" title="Previous entry (Alt+Left)" disabled><svg class="so-hist-back"><use href="#i-chevron-down"/></svg></button>
|
||||
<button class="icon-btn so-hist" id="slideoverFwd" title="Next entry (Alt+Right)" disabled><svg class="so-hist-fwd"><use href="#i-chevron-down"/></svg></button>
|
||||
<h3 id="slideoverTitle">Entry</h3>
|
||||
<button class="icon-btn" id="slideoverClose"><svg><use href="#i-x"/></svg></button>
|
||||
</header>
|
||||
@@ -732,7 +740,7 @@
|
||||
<button class="btn btn-ghost btn-sm hotkey-capture-btn" id="settingNewEntryCombo">Ctrl+Shift+A</button>
|
||||
</div>
|
||||
<p style="font-size:11px;color:var(--text-faint);margin:6px 0 0;line-height:1.4">
|
||||
Click a button, then press your new combo. Needs Ctrl, Alt or Win + a letter / digit / F-key.
|
||||
Click a button, then press your new combo. Needs Ctrl, Alt or Win + a letter / digit / F-key. Press Delete instead to turn that hotkey off.
|
||||
</p>
|
||||
<button class="btn btn-ghost btn-sm" id="settingAutofillResetHotkeys" style="margin-top:6px">
|
||||
Reset to defaults
|
||||
@@ -760,6 +768,7 @@
|
||||
<label><input type="checkbox" class="fb-btn" data-key="full"> Full</label>
|
||||
<label><input type="checkbox" class="fb-btn" data-key="pwd"> Password</label>
|
||||
<label><input type="checkbox" class="fb-btn" data-key="totp"> 2FA</label>
|
||||
<label><input type="checkbox" class="fb-btn" data-key="paste"> Paste (types the clipboard)</label>
|
||||
<label><input type="checkbox" class="fb-btn" data-key="add"> Add current window</label>
|
||||
<label><input type="checkbox" class="fb-btn" data-key="search"> Search</label>
|
||||
<label><input type="checkbox" class="fb-btn" data-key="open"> Open</label>
|
||||
@@ -1225,10 +1234,18 @@
|
||||
<div class="modal-backdrop" data-close></div>
|
||||
<div class="modal-panel modal-panel-sm quick-search-panel">
|
||||
<div class="quick-search-input">
|
||||
<svg><use href="#i-search"/></svg>
|
||||
<!-- Retour a la liste des entrees, visible dans une note de commandes. -->
|
||||
<button type="button" class="icon-btn icon-btn-sm" id="qsBack" title="Back to entries (Esc)" hidden><svg class="so-hist-back"><use href="#i-chevron-down"/></svg></button>
|
||||
<svg id="qsSearchIcon"><use href="#i-search"/></svg>
|
||||
<input id="quickSearchInput" type="text"
|
||||
placeholder="Search and press Enter to copy password…"
|
||||
placeholder="Search…"
|
||||
autocomplete="off">
|
||||
<!-- Copier ou remplir : visible seulement quand une fenetre cible
|
||||
existe - sans elle, remplir n'aurait rien a viser. -->
|
||||
<div class="qs-mode" id="qsMode" hidden>
|
||||
<button type="button" data-qsmode="copy">Copy</button>
|
||||
<button type="button" data-qsmode="fill">Fill</button>
|
||||
</div>
|
||||
<kbd>Esc</kbd>
|
||||
</div>
|
||||
<div class="quick-search-results" id="quickSearchResults"></div>
|
||||
|
||||
+16
-4
@@ -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,85 @@ const ENTRY_TEMPLATES = {
|
||||
},
|
||||
};
|
||||
|
||||
// DECOUPE D'UNE NOTE DE COMMANDES. "# titre" ouvre un groupe, toute autre ligne
|
||||
// non vide est une commande, les lignes vides sont ignorees. Rend
|
||||
// [{group, cmd}] dans l'ordre du texte. Pur : tests/commands.test.js.
|
||||
// LIGNE CONTINUEE : une ligne finie par \ (bash), ` (PowerShell) ou ^ (cmd)
|
||||
// se recolle a la suivante, sans le caractere ni le retour a la ligne. Le
|
||||
// resultat tient sur UNE ligne : tapee dans un terminal, une fin de ligne vaut
|
||||
// Entree et lancerait une commande a moitie ecrite. Une ligne vide ou la fin du
|
||||
// texte termine la commande en cours.
|
||||
function parseCommandNote(text) {
|
||||
const out = [];
|
||||
let group = '';
|
||||
let pending = '';
|
||||
const flush = () => { if (pending) out.push({ group, cmd: pending }); pending = ''; };
|
||||
for (const raw of String(text || '').split(/\r?\n/)) {
|
||||
const line = raw.trim();
|
||||
if (!line) { flush(); continue; }
|
||||
if (!pending && line.startsWith('#')) { group = line.replace(/^#+\s*/, ''); continue; }
|
||||
const cont = /[\\`^]$/.test(line);
|
||||
const part = cont ? line.slice(0, -1).trimEnd() : line;
|
||||
pending = pending ? pending + ' ' + part : part;
|
||||
if (!cont) flush();
|
||||
}
|
||||
flush();
|
||||
return out;
|
||||
}
|
||||
|
||||
// AFFICHAGE MASQUE d'une commande (partage d'ecran), sur l'oeil "mask notes" :
|
||||
// le premier mot reste, puis le milieu masque, puis les 3 derniers caracteres.
|
||||
// "ssh admin@10.0.0.11" -> "ssh ••••••••••••.11". Une fin trop courte pour
|
||||
// en garder 3 sans tout montrer est masquee en entier. Affichage seulement :
|
||||
// filtre, copie et remplissage travaillent sur le vrai texte.
|
||||
function maskCommand(cmd) {
|
||||
const s = String(cmd || '');
|
||||
const sp = s.indexOf(' ');
|
||||
const head = sp < 0 ? '' : s.slice(0, sp + 1);
|
||||
const rest = sp < 0 ? s : s.slice(sp + 1);
|
||||
if (rest.length <= 6) return head + '•'.repeat(rest.length);
|
||||
return head + '•'.repeat(rest.length - 3) + rest.slice(-3);
|
||||
}
|
||||
|
||||
// HISTORIQUE DES ENTREES OUVERTES, comme celui d'un navigateur. Fonctions
|
||||
// pures sur {ids, pos} : testables sans DOM (tests/history.test.js).
|
||||
const ENTRY_HISTORY_MAX = 50;
|
||||
function entryHistoryPush(h, id) {
|
||||
if (h.ids[h.pos] === id) return; // meme entree : pas de doublon
|
||||
h.ids = h.ids.slice(0, h.pos + 1); // revenir puis ouvrir ailleurs oublie les suivants
|
||||
h.ids.push(id);
|
||||
if (h.ids.length > ENTRY_HISTORY_MAX) h.ids.shift();
|
||||
h.pos = h.ids.length - 1;
|
||||
}
|
||||
// Indice de la prochaine entree qui existe encore dans le sens dir (-1 / +1),
|
||||
// ou -1. Ne bouge rien : l'ouverture peut etre refusee (modifications non
|
||||
// enregistrees), la position ne change qu'une fois l'entree vraiment ouverte.
|
||||
function entryHistoryTarget(h, dir, exists) {
|
||||
let p = h.pos + dir;
|
||||
while (p >= 0 && p < h.ids.length && !exists(h.ids[p])) p += dir;
|
||||
return (p >= 0 && p < h.ids.length) ? p : -1;
|
||||
}
|
||||
|
||||
async function entryHistoryGo(dir) {
|
||||
const h = state.entryHistory;
|
||||
const exists = id => state.entries.some(x => x.id === id);
|
||||
// Panneau ferme : Precedent rouvre d'abord l'entree qu'on vient de quitter.
|
||||
let p = (dir < 0 && !$('#slideover').classList.contains('is-open') &&
|
||||
exists(h.ids[h.pos])) ? h.pos : entryHistoryTarget(h, dir, exists);
|
||||
if (p < 0) return;
|
||||
if (await openSlideOver(h.ids[p], { fromHistory: true }) === false) return;
|
||||
h.pos = p;
|
||||
renderEntryHistoryNav();
|
||||
}
|
||||
|
||||
function renderEntryHistoryNav() {
|
||||
const h = state.entryHistory;
|
||||
const exists = id => state.entries.some(x => x.id === id);
|
||||
const back = $('#slideoverBack'), fwd = $('#slideoverFwd');
|
||||
if (back) back.disabled = entryHistoryTarget(h, -1, exists) < 0;
|
||||
if (fwd) fwd.disabled = entryHistoryTarget(h, +1, exists) < 0;
|
||||
}
|
||||
|
||||
// Open the slideover for either an existing entry (id = number) or a
|
||||
// brand-new one (id = null). opts: { presetTitle, presetSite, template }
|
||||
// pre-fill the corresponding fields for the new-entry path.
|
||||
@@ -4054,6 +4154,8 @@ async function openSlideOver(id, opts) {
|
||||
if (!ok) return false; // caller must not act on a panel that didn't switch
|
||||
}
|
||||
if (!isNew && !e) return false;
|
||||
if (!isNew && !opts.fromHistory) entryHistoryPush(state.entryHistory, id);
|
||||
renderEntryHistoryNav();
|
||||
|
||||
// The editor must be visible when it opens: Settings sits ABOVE the
|
||||
// slideover, so a hotkey (Ctrl+Shift+A) fired with Settings open left
|
||||
@@ -4154,6 +4256,7 @@ async function openSlideOver(id, opts) {
|
||||
// injected by the template picker on new. Sent back to the server
|
||||
// on Save so the card/table can label it correctly.
|
||||
template: isNew ? (opts.presetTemplate || '') : (e.template || ''),
|
||||
originalTemplate: isNew ? '' : (e.template || ''),
|
||||
// Attachments staged BEFORE the entry exists server-side. Empty on
|
||||
// edit (existing attachments are fetched live via the dedicated
|
||||
// /entries/:id/attachments endpoint). Flushed by soSave after the
|
||||
@@ -4184,6 +4287,7 @@ async function openSlideOver(id, opts) {
|
||||
// Notes: minimal layout — name + multiline body + folder + tags.
|
||||
// No icon (covered by sidebar icon), no site/user/totp.
|
||||
body.appendChild(soEditableField('Title', 'soTitle', soState.original.title));
|
||||
body.appendChild(soCommandsField());
|
||||
body.appendChild(soNoteBodyField(plain));
|
||||
const hist = soHistoryButton();
|
||||
if (hist) body.appendChild(hist);
|
||||
@@ -4560,6 +4664,67 @@ function buildCustomFieldRow(field, idx, rerender) {
|
||||
|
||||
// Multiline note body. Maps to soState.original.password and the
|
||||
// encrypted_password+iv columns (same crypto pipeline as login passwords).
|
||||
// Liste des commandes d'une note : visible quand le modele est "commands",
|
||||
// suit le texte au fil de la frappe. Une case bascule le modele (sauvegarde =
|
||||
// enregistrement de l'entree). Copier seulement : ici la cible d'un
|
||||
// remplissage serait l'application elle-meme - le remplissage passe par la
|
||||
// recherche rapide (Ctrl+Shift+Q), qui connait la fenetre d'origine.
|
||||
function soCommandsField() {
|
||||
const wrap = el('div', { class: 'slideover-field so-cmds' });
|
||||
const lbl = el('label', { class: 'so-cmds-toggle' });
|
||||
const cb = el('input', { type: 'checkbox' });
|
||||
cb.checked = soState.template === 'commands';
|
||||
lbl.appendChild(cb);
|
||||
lbl.appendChild(document.createTextNode(' Command list (# Group, one command per line)'));
|
||||
wrap.appendChild(lbl);
|
||||
const list = el('div', { class: 'so-cmds-list' });
|
||||
wrap.appendChild(list);
|
||||
const draw = () => {
|
||||
list.innerHTML = '';
|
||||
list.hidden = soState.template !== 'commands';
|
||||
if (list.hidden) return;
|
||||
const ta = document.getElementById('soNoteBody');
|
||||
const cmds = parseCommandNote(ta ? ta.value : '');
|
||||
if (!cmds.length) {
|
||||
list.appendChild(el('div', { class: 'so-cmds-empty' }, 'No command yet - type them below.'));
|
||||
return;
|
||||
}
|
||||
let last = null;
|
||||
cmds.forEach(c => {
|
||||
if (c.group !== last) {
|
||||
last = c.group;
|
||||
if (c.group) list.appendChild(el('div', { class: 'so-cmds-group' }, c.group));
|
||||
}
|
||||
const row = el('div', { class: 'so-cmds-row' });
|
||||
row.appendChild(el('code', { class: 'so-cmds-cmd' },
|
||||
state.maskNotes ? maskCommand(c.cmd) : c.cmd));
|
||||
const b = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Copy command' });
|
||||
b.appendChild(icon('i-copy'));
|
||||
b.addEventListener('click', async ev => {
|
||||
ev.stopPropagation();
|
||||
if (Bridge.active) Bridge.copySecure(c.cmd, 30000);
|
||||
else { try { await navigator.clipboard.writeText(c.cmd); } catch (_) {} }
|
||||
toast('Command copied');
|
||||
});
|
||||
row.appendChild(b);
|
||||
list.appendChild(row);
|
||||
});
|
||||
};
|
||||
cb.addEventListener('change', ev => {
|
||||
ev.stopPropagation();
|
||||
soState.template = cb.checked ? 'commands' : '';
|
||||
draw();
|
||||
soDirtyCheck();
|
||||
});
|
||||
// Le textarea est ajoute juste apres : on s'y branche au tick suivant.
|
||||
setTimeout(() => {
|
||||
const ta = document.getElementById('soNoteBody');
|
||||
if (ta) ta.addEventListener('input', draw);
|
||||
draw();
|
||||
}, 0);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function soNoteBodyField(value) {
|
||||
const wrap = el('div', { class: 'slideover-field' });
|
||||
const lbl = el('div', { class: 'slideover-field-label so-note-label' }, 'Note');
|
||||
@@ -4582,6 +4747,8 @@ function soNoteBodyField(value) {
|
||||
state.maskNotes = !state.maskNotes;
|
||||
localStorage.setItem('maskNotes', state.maskNotes ? '1' : '0');
|
||||
ta.classList.toggle('is-masked', state.maskNotes);
|
||||
// La liste des commandes suit l'oeil (elle ecoute 'input').
|
||||
ta.dispatchEvent(new Event('input'));
|
||||
saveServerSettings();
|
||||
});
|
||||
wrap.appendChild(ta);
|
||||
@@ -4839,9 +5006,13 @@ function soTotpField(plainSecret) {
|
||||
const scan = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Scan the 2FA QR code shown on screen' });
|
||||
scan.appendChild(icon('i-qr'));
|
||||
scan.addEventListener('click', () => {
|
||||
Bridge.onScreenQr = (text) => {
|
||||
Bridge.onScreenQr = (text, found) => {
|
||||
const secret = parseOtpAuthUri(text || '');
|
||||
if (!secret) { toast('No 2FA QR code found on screen', 'warning'); return; }
|
||||
if (!secret) {
|
||||
toast(found ? 'This QR code is not a 2FA code (it has no otpauth:// link)'
|
||||
: 'No QR code found on screen', 'warning');
|
||||
return;
|
||||
}
|
||||
input.value = secret;
|
||||
updateTotpDisplay();
|
||||
soDirtyCheck();
|
||||
@@ -5036,6 +5207,9 @@ function isSoDirty() {
|
||||
tags: soState.tags.join(','),
|
||||
};
|
||||
const curCustom = JSON.stringify(soState.customFields || []);
|
||||
// La case "Command list" change le modele sans toucher au texte.
|
||||
if (soState.id != null && (soState.template || '') !== (soState.originalTemplate || ''))
|
||||
return true;
|
||||
if (soState.id == null) {
|
||||
const orig = soState.original || {};
|
||||
const staged = (soState.pendingAttachments || []).length > 0;
|
||||
@@ -7006,7 +7180,7 @@ function resetAutoLock() {
|
||||
autoLockTimer = setTimeout(() => {
|
||||
hideIdleWarning();
|
||||
toast('Auto-locked due to inactivity', 'warning');
|
||||
lockVault();
|
||||
lockVault('inactivity (' + state.autoLock + ' min)');
|
||||
}, totalMs);
|
||||
}
|
||||
|
||||
@@ -7181,13 +7355,18 @@ async function enterApp() {
|
||||
if (!ok) {
|
||||
const fresh = await bridgePinStatus();
|
||||
state.pinConfigured = fresh;
|
||||
lockVault();
|
||||
lockVault('wrong PIN after the master password');
|
||||
$('#authHint').textContent = fresh
|
||||
? 'Wrong PIN. Try again.'
|
||||
: 'Too many wrong PIN attempts — PIN has been removed. Re-set it from Settings after unlocking.';
|
||||
return;
|
||||
}
|
||||
}
|
||||
// OUVERT, QUEL QUE SOIT LE CHEMIN. Seul doUnlock remettait ce drapeau : un
|
||||
// verrou Windows avant toute connexion (demarrage dans le tray) le posait,
|
||||
// la connexion complete ne le levait pas, et l'autofill refusait un coffre
|
||||
// affiche ouvert.
|
||||
state.locked = false;
|
||||
$('#authScreen').classList.add('is-hidden');
|
||||
$('#appShell').classList.remove('is-hidden');
|
||||
$('#userName').textContent = state.username;
|
||||
@@ -7576,6 +7755,13 @@ async function init() {
|
||||
if (mod && /^[uUjJhHsSpPtTnNrR]$/.test(e.key)) return e.preventDefault(); // View source / Downloads / History / Save / Print / New tab+win / Reload
|
||||
if (mod && e.shiftKey && /^[nNwW]$/.test(e.key)) return e.preventDefault(); // New incognito / Close window
|
||||
if (mod && e.shiftKey && e.key === 'Delete') return e.preventDefault(); // Clear browsing data
|
||||
// Alt+fleches : Precedent / Suivant parmi les entrees ouvertes. Le
|
||||
// preventDefault empeche aussi la page elle-meme de "reculer".
|
||||
if (e.altKey && !mod && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
|
||||
e.preventDefault();
|
||||
if (!state.locked && state.cryptoKey && !document.querySelector('.modal:not(.is-hidden)'))
|
||||
entryHistoryGo(e.key === 'ArrowLeft' ? -1 : +1);
|
||||
}
|
||||
});
|
||||
document.addEventListener('keydown', handleCardCursorKey);
|
||||
|
||||
@@ -7781,6 +7967,8 @@ async function init() {
|
||||
|
||||
// Slide-over
|
||||
$('#slideoverClose').addEventListener('click', () => requestCloseSlideOver());
|
||||
$('#slideoverBack').addEventListener('click', () => entryHistoryGo(-1));
|
||||
$('#slideoverFwd').addEventListener('click', () => entryHistoryGo(+1));
|
||||
// Click outside the slide-over closes it. Clicks on cards re-open it for
|
||||
// another entry (so we don't close in that case; the card's own handler
|
||||
// will switch state.selectedId).
|
||||
@@ -8026,6 +8214,9 @@ async function init() {
|
||||
if (e.key !== 'Escape') return;
|
||||
if (!$('#settingsPanel').classList.contains('is-open')) return;
|
||||
if (document.querySelector('.modal:not(.is-hidden)')) return;
|
||||
// Capture de raccourci armee : Esc l'annule (son propre ecouteur,
|
||||
// enregistre APRES celui-ci), il ne ferme pas les reglages.
|
||||
if (document.querySelector('[data-capturing="1"]')) return;
|
||||
// Busy overlay up (sync/backup running) — don't close under it.
|
||||
if ($('#busyOverlay') && !$('#busyOverlay').classList.contains('is-hidden')) return;
|
||||
// When the search box has focus AND a query, the input's own
|
||||
@@ -8373,7 +8564,7 @@ async function init() {
|
||||
btn.dataset.capturing = '1';
|
||||
btn.classList.add('is-capturing');
|
||||
const original = btn.textContent;
|
||||
btn.textContent = 'Press combo… (Esc to cancel)';
|
||||
btn.textContent = 'Press combo… (Esc cancel · Del off)';
|
||||
// Free our own combos so they reach this listener instead of
|
||||
// firing their hotkey. Restored by finish() / the success path.
|
||||
hotkeyPushSilent = true;
|
||||
@@ -8428,6 +8619,20 @@ async function init() {
|
||||
return;
|
||||
}
|
||||
if (captured === 'cancel') { finish(true); return; }
|
||||
// Cle vide = vk 0 : Delphi n'enregistre pas ce raccourci.
|
||||
if (captured === 'off') {
|
||||
const off = { ctrl: false, shift: false, alt: false, win: false, key: '' };
|
||||
if (kind === 'full') state.autofillHotkeyFull = off;
|
||||
else if (kind === 'password') state.autofillHotkeyPwd = off;
|
||||
else if (kind === 'totp') state.autofillHotkeyTotp = off;
|
||||
else if (kind === 'quickSearch') state.quickSearchHotkey = off;
|
||||
else /* newEntry */ state.newEntryHotkey = off;
|
||||
btn.textContent = autofillComboLabel(off);
|
||||
finish(false);
|
||||
autofillPushHotkeys();
|
||||
saveServerSettings();
|
||||
return;
|
||||
}
|
||||
if (captured === 'invalid'){
|
||||
toast('Unsupported key — use a letter, digit or F-key', 'warning');
|
||||
finish(true);
|
||||
@@ -8736,7 +8941,11 @@ async function init() {
|
||||
// document-level handlers, which now see "no modal open"
|
||||
// (we just closed it) and also close the dirty slideover.
|
||||
e.stopPropagation();
|
||||
if (qsCommands) { qsLeaveCommands(); return; } // liste -> entrees
|
||||
closeQuickSearchModal();
|
||||
} else if (e.key === 'Backspace' && qsCommands && !qsInput.value) {
|
||||
e.preventDefault();
|
||||
qsLeaveCommands(); // retour, comme Esc
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (rows.length) { quickSearchSelected++; quickSearchRender(); }
|
||||
@@ -8747,6 +8956,11 @@ async function init() {
|
||||
e.preventDefault();
|
||||
const sel = rows[quickSearchSelected];
|
||||
if (!sel) return;
|
||||
if (qsCommands) {
|
||||
qsPickCommand(qsCommands[parseInt(sel.dataset.cmd, 10)].cmd,
|
||||
e.ctrlKey || e.metaKey);
|
||||
return;
|
||||
}
|
||||
const id = parseInt(sel.dataset.id, 10);
|
||||
const entry = state.entries.find(x => x.id === id);
|
||||
// Shift+Enter → full (user + Tab + password), Ctrl+Enter →
|
||||
|
||||
+201
-7
@@ -27,6 +27,135 @@ let quickSearchHideAfter = false;
|
||||
// HWND and is waiting for cmd://autofill/execute. On pick we SendInput
|
||||
// the password instead of copying to the clipboard.
|
||||
let quickSearchFillMode = false;
|
||||
// CE QUE L'UTILISATEUR PREFERE quand une cible existe : remplir, ou copier
|
||||
// quand meme - le remplissage echoue vers une application lancee en
|
||||
// administrateur, ou quand le gestionnaire de Chrome s'en mele. Retenu en pref
|
||||
// DPAPI : localStorage se vide a chaque lancement (port ephemere).
|
||||
let quickSearchPreferCopy = false;
|
||||
|
||||
// NOTE DE COMMANDES ouverte dans la recherche rapide : la liste remplace les
|
||||
// entrees, la saisie la filtre, Esc revient aux entrees. null = liste normale.
|
||||
let qsCommands = null;
|
||||
|
||||
async function qsEnterCommands(entry) {
|
||||
const body = await decryptPwd(entry.encrypted_password, entry.iv);
|
||||
if (body === '[ERROR]') { toast('Decryption error', 'error'); return; }
|
||||
qsCommands = parseCommandNote(body);
|
||||
const input = document.getElementById('quickSearchInput');
|
||||
input.value = '';
|
||||
input.placeholder = entryDisplayName(entry) + ' - filter commands… (Esc = back)';
|
||||
quickSearchSelected = 0;
|
||||
qsApplyMode();
|
||||
quickSearchRender();
|
||||
input.focus();
|
||||
}
|
||||
|
||||
function qsLeaveCommands() {
|
||||
qsCommands = null;
|
||||
const input = document.getElementById('quickSearchInput');
|
||||
input.value = '';
|
||||
input.placeholder = 'Search…';
|
||||
quickSearchSelected = 0;
|
||||
qsApplyMode();
|
||||
quickSearchRender();
|
||||
}
|
||||
|
||||
// Une commande, une ligne, SANS Entree : l'utilisateur valide lui-meme. Ni
|
||||
// Ctrl+A ni Suppr avant (noClear) - dans un terminal Ctrl+A ramene en debut de
|
||||
// ligne, ce ne serait pas "tout selectionner".
|
||||
async function qsPickCommand(cmd, forceCopy) {
|
||||
if (qsFills() && !forceCopy) {
|
||||
autofillPendingToast = 'Command sent';
|
||||
if (Bridge.active) Bridge.executeAutofill('', cmd, quickSearchHideAfter, undefined, true);
|
||||
quickSearchFillMode = false;
|
||||
quickSearchHideAfter = false;
|
||||
qsCommands = null;
|
||||
closeQuickSearchModal();
|
||||
return;
|
||||
}
|
||||
if (Bridge.active) Bridge.copySecure(cmd, 30000);
|
||||
else { try { await navigator.clipboard.writeText(cmd); } catch (_) {} }
|
||||
toast('Command copied');
|
||||
qsCommands = null;
|
||||
closeQuickSearchModal(true);
|
||||
}
|
||||
|
||||
function qsRenderCommands(box, q) {
|
||||
const ql = q.toLowerCase();
|
||||
const list = qsCommands
|
||||
.map((c, i) => ({ c, i }))
|
||||
.filter(x => !ql || x.c.cmd.toLowerCase().includes(ql) || x.c.group.toLowerCase().includes(ql));
|
||||
if (!list.length) {
|
||||
box.appendChild(el('div', { class: 'quick-search-empty' },
|
||||
q ? 'No command matches "' + q + '"' : 'No command in this note'));
|
||||
quickSearchSelected = 0;
|
||||
return;
|
||||
}
|
||||
if (quickSearchSelected >= list.length) quickSearchSelected = 0;
|
||||
if (quickSearchSelected < 0) quickSearchSelected = list.length - 1;
|
||||
list.forEach((x, n) => {
|
||||
const row = el('div', {
|
||||
class: 'quick-search-row' + (n === quickSearchSelected ? ' is-selected' : ''),
|
||||
'data-cmd': String(x.i),
|
||||
});
|
||||
const main = el('div', { class: 'quick-search-main' });
|
||||
main.appendChild(el('div', { class: 'quick-search-name qs-cmd' },
|
||||
state.maskNotes ? maskCommand(x.c.cmd) : x.c.cmd));
|
||||
if (x.c.group) main.appendChild(el('div', { class: 'quick-search-sub' }, x.c.group));
|
||||
row.appendChild(main);
|
||||
const acts = el('div', { class: 'quick-search-acts' });
|
||||
const b = el('button', { class: 'icon-btn icon-btn-sm', type: 'button',
|
||||
title: qsFills() ? 'Copy instead of typing' : 'Copy command' });
|
||||
b.appendChild(icon('i-copy'));
|
||||
b.addEventListener('click', ev => { ev.stopPropagation(); qsPickCommand(x.c.cmd, true); });
|
||||
acts.appendChild(b);
|
||||
row.appendChild(acts);
|
||||
row.addEventListener('click', ev => qsPickCommand(x.c.cmd, ev.ctrlKey || ev.metaKey));
|
||||
box.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
/// Remplit-on vraiment ? Il faut une cible ET ne pas avoir choisi Copy.
|
||||
function qsFills() { return quickSearchFillMode && !quickSearchPreferCopy; }
|
||||
|
||||
function qsApplyMode() {
|
||||
const box = document.getElementById('qsMode');
|
||||
if (box) {
|
||||
box.hidden = !quickSearchFillMode;
|
||||
box.querySelectorAll('[data-qsmode]').forEach(b =>
|
||||
b.classList.toggle('is-on', (b.dataset.qsmode === 'fill') === qsFills()));
|
||||
}
|
||||
const back = document.getElementById('qsBack');
|
||||
if (back) back.hidden = !qsCommands;
|
||||
const ico = document.getElementById('qsSearchIcon');
|
||||
if (ico) ico.style.display = qsCommands ? 'none' : '';
|
||||
const hintEl = document.querySelector('#quickSearchModal .quick-search-hint');
|
||||
if (hintEl && qsCommands) {
|
||||
hintEl.textContent = qsFills()
|
||||
? 'Enter / click = type the command (no Enter sent) · Ctrl+Enter / Ctrl+click = copy · Esc = back'
|
||||
: 'Enter / click = copy the command · Esc = back';
|
||||
} else if (hintEl) {
|
||||
hintEl.textContent = qsFills()
|
||||
? 'Enter / click = password · Shift+Enter / right-click = user+password · Ctrl+Enter / Ctrl+click = username · Esc = cancel'
|
||||
: 'Enter / click = copy password · Ctrl+Enter / Ctrl+click = copy username · Esc = close';
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', ev => {
|
||||
if (ev.target.closest && ev.target.closest('#qsBack')) {
|
||||
ev.stopPropagation();
|
||||
qsLeaveCommands();
|
||||
document.getElementById('quickSearchInput').focus();
|
||||
return;
|
||||
}
|
||||
const b = ev.target.closest && ev.target.closest('[data-qsmode]');
|
||||
if (!b) return;
|
||||
quickSearchPreferCopy = b.dataset.qsmode === 'copy';
|
||||
if (Bridge.active) Bridge.setPref('qsCopy', quickSearchPreferCopy ? '1' : '0');
|
||||
qsApplyMode();
|
||||
quickSearchRender();
|
||||
document.getElementById('quickSearchInput').focus();
|
||||
});
|
||||
|
||||
function quickSearchScoreEntry(e, q) {
|
||||
if (!q) return 1; // empty query → all entries pass, ordering preserved
|
||||
@@ -47,6 +176,12 @@ function quickSearchScoreEntry(e, q) {
|
||||
|
||||
function quickSearchRender() {
|
||||
const q = document.getElementById('quickSearchInput').value.trim();
|
||||
if (qsCommands) {
|
||||
const box = document.getElementById('quickSearchResults');
|
||||
box.innerHTML = '';
|
||||
qsRenderCommands(box, q);
|
||||
return;
|
||||
}
|
||||
const list = state.entries
|
||||
.map(e => ({ e, s: quickSearchScoreEntry(e, q) }))
|
||||
.filter(x => x.s > 0)
|
||||
@@ -88,6 +223,26 @@ function quickSearchRender() {
|
||||
main.appendChild(el('div', { class: 'quick-search-sub' }, e.username));
|
||||
row.appendChild(avatar);
|
||||
row.appendChild(main);
|
||||
// Un bouton par valeur presente : pas besoin de retenir clic / Ctrl+clic.
|
||||
const verb = qsFills() ? 'Fill ' : 'Copy ';
|
||||
const acts = el('div', { class: 'quick-search-acts' });
|
||||
const act = (ic, title, mode) => {
|
||||
const b = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title });
|
||||
b.appendChild(icon(ic));
|
||||
b.addEventListener('click', ev => { ev.stopPropagation(); quickSearchPickEntry(e, mode); });
|
||||
b.addEventListener('contextmenu', ev => ev.stopPropagation());
|
||||
acts.appendChild(b);
|
||||
};
|
||||
if (e.username) act('i-user', verb + 'username', 'user');
|
||||
// UNE NOTE SE COPIE TOUJOURS, meme en Fill : tapee, chacun de ses
|
||||
// retours a la ligne partirait comme un Entree dans le formulaire.
|
||||
if (e.template === 'commands')
|
||||
act('i-note', 'Open the command list', 'cmds');
|
||||
else if (e.kind === 'note')
|
||||
act('i-note', qsFills() ? 'Copy note (a note is never typed)' : 'Copy note', 'note');
|
||||
else act('i-key', verb + 'password', 'pwd');
|
||||
if (e.totp_secret && e.totp_iv) act('i-clock', verb + '2FA code', 'totp');
|
||||
row.appendChild(acts);
|
||||
// Left click → password only; Ctrl+click → username only
|
||||
// (step-2 forms / unlock screens).
|
||||
row.addEventListener('click', ev =>
|
||||
@@ -106,12 +261,47 @@ function quickSearchRender() {
|
||||
// mode: 'full' (user + Tab + password), 'user' (username only) or 'pwd'
|
||||
// (password only). In fill mode each maps to a SendInput variant; in copy
|
||||
// mode 'full' has no meaning so it falls back to copying the password.
|
||||
// 'totp' = code 2FA courant (tape comme un mot de passe, ou copie) ; 'note' =
|
||||
// corps d'une note, toujours COPIE - le taper enverrait ses retours a la ligne
|
||||
// comme des Entree dans le formulaire cible.
|
||||
async function quickSearchPickEntry(entry, mode) {
|
||||
mode = mode || 'full';
|
||||
// Note de commandes : on choisit UNE commande, jamais la note entiere.
|
||||
if (entry.template === 'commands' && mode !== 'user') return qsEnterCommands(entry);
|
||||
|
||||
if (mode === 'totp') {
|
||||
const secret = await decryptTotpSecret(entry.totp_secret, entry.totp_iv);
|
||||
let code = '';
|
||||
try { if (secret && secret !== '[ERROR]') code = (await generateTOTP(secret)).code; }
|
||||
catch (_) { code = ''; }
|
||||
if (!code) { toast('2FA: cannot read the secret', 'error'); return; }
|
||||
if (qsFills()) {
|
||||
autofillPendingToast = entryDisplayName(entry) + ' · 2FA code sent';
|
||||
if (Bridge.active) Bridge.executeAutofill('', code, quickSearchHideAfter);
|
||||
quickSearchFillMode = false;
|
||||
quickSearchHideAfter = false;
|
||||
closeQuickSearchModal();
|
||||
return;
|
||||
}
|
||||
if (Bridge.active) Bridge.copySecure(code, 30000);
|
||||
else { try { await navigator.clipboard.writeText(code); } catch (_) {} }
|
||||
toast(entryDisplayName(entry) + ' · 2FA code copied');
|
||||
closeQuickSearchModal(true);
|
||||
return;
|
||||
}
|
||||
if (mode === 'note') {
|
||||
const body = await decryptPwd(entry.encrypted_password, entry.iv);
|
||||
if (body === '[ERROR]') { toast('Decryption error', 'error'); return; }
|
||||
if (Bridge.active) Bridge.copySecure(body, 30000);
|
||||
else { try { await navigator.clipboard.writeText(body); } catch (_) {} }
|
||||
toast(entryDisplayName(entry) + ' · note copied');
|
||||
closeQuickSearchModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fill mode (Ctrl+Shift+Q hotkey): SendInput directly into the HWND
|
||||
// Delphi saved when the hotkey fired. No clipboard touch.
|
||||
if (quickSearchFillMode) {
|
||||
if (qsFills()) {
|
||||
if (mode === 'user') {
|
||||
const u = entry.username || '';
|
||||
if (!u) { toast('No username on this entry', 'warning'); return; }
|
||||
@@ -475,16 +665,20 @@ function openQuickSearchModal(hideAfter, forFill) {
|
||||
const modal = document.getElementById('quickSearchModal');
|
||||
const input = document.getElementById('quickSearchInput');
|
||||
modal.classList.remove('is-hidden');
|
||||
if (qsCommands) qsLeaveCommands();
|
||||
input.value = '';
|
||||
quickSearchSelected = 0;
|
||||
quickSearchHideAfter = !!hideAfter;
|
||||
quickSearchFillMode = !!forFill;
|
||||
// Subtle hint to the user about what Enter will do.
|
||||
const hintEl = modal.querySelector('.quick-search-hint');
|
||||
if (hintEl) {
|
||||
hintEl.textContent = forFill
|
||||
? 'Enter / click = password · Shift+Enter / right-click = user+password · Ctrl+Enter / Ctrl+click = username · Esc = cancel'
|
||||
: 'Enter / click = copy password · Ctrl+Enter / Ctrl+click = copy username · Esc = close';
|
||||
qsApplyMode();
|
||||
// La preference arrive de Delphi : on affiche tout de suite, et on
|
||||
// rectifie des qu'elle est la (une fraction de seconde).
|
||||
if (forFill && Bridge.active) {
|
||||
Bridge.getPref('qsCopy').then(v => {
|
||||
quickSearchPreferCopy = v === '1';
|
||||
qsApplyMode();
|
||||
quickSearchRender();
|
||||
}).catch(() => {});
|
||||
}
|
||||
quickSearchRender();
|
||||
setTimeout(() => input.focus(), 50);
|
||||
|
||||
+15
-4
@@ -92,21 +92,28 @@ function syncIsGitea(url) {
|
||||
|
||||
// ADRESSE DU DEPOT TELLE QUE LE NAVIGATEUR (OU "git clone") LA DONNE.
|
||||
// https://github.com/O/R(.git) -> API de GitHub ; https://hote/O/R(.git) -> API
|
||||
// de Gitea. Fichier par defaut vault-sync.json, ou celui d'un lien .../blob/B/F
|
||||
// de Gitea. Fichier par defaut SYNC_DEFAULT_FILE, ou celui d'un lien .../blob/B/F
|
||||
// (GitHub) ou .../src/branch/B/F (Gitea). Tout le reste passe tel quel - une
|
||||
// URL WebDAV, une URL d'API deja complete. GARDE : un dernier segment qui a
|
||||
// l'air d'un fichier (.json...) n'est pas un depot - sinon
|
||||
// https://serveur/webdav/vault.json deviendrait une API Gitea imaginaire.
|
||||
// LE NOM NE DIT PAS CE QUE C'EST. Un fichier appele "vault-sync.json" dans un
|
||||
// depot annonce un coffre a qui parcourt la liste des depots ; le contenu est
|
||||
// chiffre, mais le nom, lui, designe la cible. Les anciennes configurations
|
||||
// gardent leur nom : c'est l'URL complete qui est enregistree, et elle ne
|
||||
// passe pas par ici.
|
||||
const SYNC_DEFAULT_FILE = 'data.json';
|
||||
|
||||
function syncNormalizeRepoUrl(u) {
|
||||
u = (u || '').replace(/\s+/g, '');
|
||||
if (!u || syncIsGitea(u)) return u;
|
||||
const g = u.match(/^https?:\/\/(www\.)?github\.com\/([^\/]+)\/([^\/]+?)(?:\.git)?(?:\/blob\/[^\/]+\/(.+?))?\/?$/i);
|
||||
if (g) return 'https://api.github.com/repos/' + g[2] + '/' + g[3] +
|
||||
'/contents/' + (g[4] || 'vault-sync.json');
|
||||
'/contents/' + (g[4] || SYNC_DEFAULT_FILE);
|
||||
const m = u.match(/^(https?:\/\/[^\/]+)\/([^\/]+)\/([^\/]+?)(\.git)?(?:\/src\/branch\/[^\/]+\/(.+?))?\/?$/i);
|
||||
if (!m) return u;
|
||||
if (!m[4] && !m[5] && /\.[a-z0-9]{2,5}$/i.test(m[3])) return u; // un fichier, pas un depot
|
||||
return m[1] + '/api/v1/repos/' + m[2] + '/' + m[3] + '/contents/' + (m[5] || 'vault-sync.json');
|
||||
return m[1] + '/api/v1/repos/' + m[2] + '/' + m[3] + '/contents/' + (m[5] || SYNC_DEFAULT_FILE);
|
||||
}
|
||||
|
||||
// UN DEPOT PUBLIC EST REFUSE. Chaque synchro est un commit : l'historique garde
|
||||
@@ -839,7 +846,11 @@ async function runSyncNow(_attempt) {
|
||||
// not the same as none. `branch` is omitted too, so the repo's
|
||||
// default branch is used instead of a name we would be guessing.
|
||||
const env = {
|
||||
message: 'sync from ' + (state.username || 'desktop'),
|
||||
// NI LE NOM DU COMPTE NI CE QU'IL GARDE. L'historique du
|
||||
// depot affichait "sync from <utilisateur du coffre>" a chaque
|
||||
// envoi : le contenu est chiffre, cette ligne ne l'etait pas.
|
||||
// L'appareil d'origine suffit a savoir qui a publie.
|
||||
message: 'update from desktop',
|
||||
content: bytesToBase64(containerBytes)
|
||||
};
|
||||
if (remoteEtag) env.sha = remoteEtag;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// Decoupe d'une note de commandes (js/app.js parseCommandNote).
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { loadApp } = require('./harness.js');
|
||||
|
||||
const T = loadApp().__test;
|
||||
const plain = (x) => JSON.parse(JSON.stringify(x));
|
||||
|
||||
test('groupes "# titre", une commande par ligne, lignes vides ignorees', () => {
|
||||
const r = T.parseCommandNote('# Prod\r\nssh a@1\n\n ssh a@2 \n# Base\npsql -h x\n');
|
||||
assert.deepEqual(plain(r), [
|
||||
{ group: 'Prod', cmd: 'ssh a@1' },
|
||||
{ group: 'Prod', cmd: 'ssh a@2' },
|
||||
{ group: 'Base', cmd: 'psql -h x' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('commandes avant tout titre : groupe vide', () => {
|
||||
assert.equal(T.parseCommandNote('ls -la\n# G\npwd')[0].group, '');
|
||||
});
|
||||
|
||||
test('"## titre" et "#" seul', () => {
|
||||
const r = T.parseCommandNote('## Deux\na\n#\nb');
|
||||
assert.equal(r[0].group, 'Deux');
|
||||
assert.equal(r[1].group, '');
|
||||
});
|
||||
|
||||
test('masque : premier mot, milieu masque, 3 derniers caracteres', () => {
|
||||
assert.equal(T.maskCommand('ssh admin@10.0.0.11'), 'ssh ' + '•'.repeat(12) + '.11');
|
||||
assert.equal(T.maskCommand('psql -h 10.0.0.20'), 'psql ' + '•'.repeat(9) + '.20');
|
||||
});
|
||||
|
||||
test('masque : fin courte masquee en entier, pas d\'espace', () => {
|
||||
assert.equal(T.maskCommand('ls -la'), 'ls •••');
|
||||
assert.equal(T.maskCommand('hostname'), '•••••ame');
|
||||
assert.equal(T.maskCommand(''), '');
|
||||
});
|
||||
|
||||
test('lignes continuees (\\, `, ^) : une seule commande, sur une ligne', () => {
|
||||
const note = [
|
||||
'# D',
|
||||
'docker run -d \\',
|
||||
' -p 8080:80 \\',
|
||||
' nginx',
|
||||
'Get-Item a `',
|
||||
' -Force',
|
||||
'dir ^',
|
||||
' /s',
|
||||
].join('\n');
|
||||
const r = T.parseCommandNote(note);
|
||||
assert.deepEqual(plain(r).map(x => x.cmd), [
|
||||
'docker run -d -p 8080:80 nginx',
|
||||
'Get-Item a -Force',
|
||||
'dir /s',
|
||||
]);
|
||||
assert.equal(r[0].group, 'D');
|
||||
});
|
||||
|
||||
test('continuation coupee par une ligne vide ou la fin du texte', () => {
|
||||
assert.deepEqual(plain(T.parseCommandNote('a \\\n\nb')).map(x => x.cmd), ['a', 'b']);
|
||||
assert.deepEqual(plain(T.parseCommandNote('a \\')).map(x => x.cmd), ['a']);
|
||||
});
|
||||
|
||||
test('un "#" apres une continuation fait partie de la commande', () => {
|
||||
assert.equal(T.parseCommandNote('echo \\\n#x')[0].cmd, 'echo #x');
|
||||
});
|
||||
|
||||
test('texte vide ou absent', () => {
|
||||
assert.equal(T.parseCommandNote('').length, 0);
|
||||
assert.equal(T.parseCommandNote(null).length, 0);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
+311
-21
@@ -48,6 +48,7 @@ uses
|
||||
FMX.Controls.Presentation, FMX.StdCtrls, FMX.Memo, FMX.Memo.Types,
|
||||
FMX.ScrollBox, FMX.Layouts, FMX.Edit, FMX.ListBox, FMX.Objects,
|
||||
FMX.WebBrowser, System.Messaging, FMX.Media,
|
||||
FMX.MediaLibrary, FMX.MediaLibrary.Actions,
|
||||
PMA.Vault;
|
||||
|
||||
const
|
||||
@@ -340,6 +341,9 @@ type
|
||||
/// Change UN champ de l'entree ouverte. Le nom du champ vient de la page,
|
||||
/// et n'est donc pas cru sur parole.
|
||||
procedure SaveEntryField(const AField, ANewValue: string);
|
||||
/// Plusieurs champs d'un coup, en UN SEUL enregistrement - donc un seul
|
||||
/// commit dans le depot. Les deux tableaux vont par paires.
|
||||
procedure SaveEntryFields(const AFields, AValues: TArray<string>);
|
||||
/// Ajoute une entree au coffre. Le mot de passe voyage par le pont,
|
||||
/// comme 'setfield' - meme regle, meme journalisation muette.
|
||||
procedure CreateEntry(const ATitle, ASite, AUser, APwd, AKind,
|
||||
@@ -382,6 +386,12 @@ type
|
||||
// Scanner du QR affiche par le poste (ZXing.Delphi, Apache 2.0).
|
||||
var
|
||||
FScanning, FScanBusy: Boolean;
|
||||
/// Le scan en cours cherche un QR de 2FA (otpauth://) et non le lien des
|
||||
/// reglages. Pose par 'scanqr?for=totp', lu au decodage.
|
||||
FScanTotp: Boolean;
|
||||
/// L'action de la galerie survit a l'appel : le selecteur d'Android rend
|
||||
/// la main tout de suite et rappelle plus tard.
|
||||
FPickIcon: TTakePhotoFromLibraryAction;
|
||||
FScanFrame: Integer;
|
||||
FScanLay: TLayout;
|
||||
FScanImg: TImage;
|
||||
@@ -401,6 +411,12 @@ type
|
||||
function PwdName: string;
|
||||
/// Va chercher l'icone du site de l'entree ouverte, ou l'efface.
|
||||
procedure SetEntryIcon(AFetch: Boolean);
|
||||
/// CHOISIR UNE IMAGE DE LA GALERIE comme icone. Elle est reduite avant
|
||||
/// d'entrer dans le coffre - une photo de telephone pese quelques Mo, et
|
||||
/// une icone se regarde a 32 px dans une liste.
|
||||
procedure PickEntryIcon;
|
||||
procedure BeginPickIcon;
|
||||
procedure IconPicked(AImage: TBitmap);
|
||||
/// Le depot a bouge, ou ne l'a plus : LE DRAPEAU ET SON TEMOIN ENSEMBLE.
|
||||
///
|
||||
/// Ils ne l'etaient pas. FRemoteAhead etait remis a faux a trois endroits -
|
||||
@@ -494,7 +510,10 @@ begin
|
||||
// le filet.
|
||||
FDeadline := TTimer.Create(Self);
|
||||
FDeadline.Enabled := False;
|
||||
FDeadline.Interval := 25000;
|
||||
// Plus long que le delai du client HTTP (40 s) : ce garde-fou est la pour
|
||||
// le cas ou la requete ne rend jamais la main, pas pour couper une reponse
|
||||
// lente que le client attend encore - c'est ce qu'il faisait a 25 s.
|
||||
FDeadline.Interval := 50000;
|
||||
FDeadline.OnTimer := OnDeadline;
|
||||
|
||||
FTotpTimer := TTimer.Create(Self);
|
||||
@@ -707,6 +726,15 @@ begin
|
||||
FWebView := 'list';
|
||||
Key := 0;
|
||||
end
|
||||
// LE FORMULAIRE D'ABORD, l'entree ensuite. Il occupe la meme vue que le
|
||||
// detail, mais il se referme sur lui - sinon une modification en cours
|
||||
// sautait d'un coup jusqu'a la liste.
|
||||
else if FWebView = 'edit' then
|
||||
begin
|
||||
WebCall('PM.cancelEdit()');
|
||||
FWebView := 'detail';
|
||||
Key := 0;
|
||||
end
|
||||
else if FWebView = 'detail' then
|
||||
begin
|
||||
WebCall('PM.back()');
|
||||
@@ -1036,6 +1064,10 @@ end;
|
||||
/// TIRER, sinon il ecraserait ce qu'un autre appareil a publie entre-temps.
|
||||
///
|
||||
/// AReopenUuid vide = revenir a la liste plutot qu'a une entree.
|
||||
/// AComment EST LE MESSAGE DU COMMIT : il part au depot et s'y lit dans
|
||||
/// l'historique. Il dit donc "update from phone" et rien de plus - ni le nom
|
||||
/// du compte, ni ce qui a change. Le detail reste dans le journal local, ou
|
||||
/// il sert au diagnostic sans quitter l'appareil.
|
||||
procedure TForm8.CommitChange(const ALabel, AComment, AReopenUuid: string;
|
||||
const APatch: TFunc<string, string>);
|
||||
begin
|
||||
@@ -1247,13 +1279,53 @@ begin
|
||||
end;
|
||||
LUuid := FCurrent.Uuid;
|
||||
LTitle := FCurrent.Display;
|
||||
CommitChange('Saved: ' + LTitle, AField + ' change from phone', LUuid,
|
||||
CommitChange('Saved: ' + LTitle, 'update from phone', LUuid,
|
||||
function (APlain: string): string
|
||||
begin
|
||||
Result := PatchEntryField(APlain, LUuid, AField, ANewValue);
|
||||
end);
|
||||
end;
|
||||
|
||||
/// L'EDITION DE L'ENTREE ENTIERE. Un champ a la fois, c'etait un commit par
|
||||
/// champ : corriger un nom et son adresse laissait deux instantanes dans le
|
||||
/// depot. Ici les corrections s'appliquent l'une apres l'autre au meme clair,
|
||||
/// et c'est le resultat qui part.
|
||||
///
|
||||
/// Le filtre des noms est celui de SaveEntryField, et pour la meme raison : ce
|
||||
/// qui traverse le pont se traite comme une entree exterieure.
|
||||
procedure TForm8.SaveEntryFields(const AFields, AValues: TArray<string>);
|
||||
var
|
||||
LUuid, LTitle: string;
|
||||
I: Integer;
|
||||
begin
|
||||
if Length(AFields) = 0 then Exit;
|
||||
if FCurrent.Uuid = '' then
|
||||
begin
|
||||
RefuseChange('This entry has no uuid - cannot be updated');
|
||||
Exit;
|
||||
end;
|
||||
for I := 0 to High(AFields) do
|
||||
if (AFields[I] <> 'title') and (AFields[I] <> 'site')
|
||||
and (AFields[I] <> 'username') and (AFields[I] <> 'password')
|
||||
and (AFields[I] <> 'folder') and (AFields[I] <> 'totp_secret') then
|
||||
begin
|
||||
RefuseChange('That field cannot be changed from here');
|
||||
Exit;
|
||||
end;
|
||||
LUuid := FCurrent.Uuid;
|
||||
LTitle := FCurrent.Display;
|
||||
CommitChange('Saved: ' + LTitle,
|
||||
'update from phone', LUuid,
|
||||
function (APlain: string): string
|
||||
var
|
||||
J: Integer;
|
||||
begin
|
||||
Result := APlain;
|
||||
for J := 0 to High(AFields) do
|
||||
Result := PatchEntryField(Result, LUuid, AFields[J], AValues[J]);
|
||||
end);
|
||||
end;
|
||||
|
||||
/// SUPPRIMER - la seule action sans retour depuis le telephone.
|
||||
///
|
||||
/// Elle ne met pas a la corbeille : celle du poste ne voyage pas dans la
|
||||
@@ -1322,6 +1394,12 @@ begin
|
||||
// Le depot est reste ou nous l'avions laisse : rien a fondre, il
|
||||
// ne manque que notre publication.
|
||||
FETag := LGet.ETag
|
||||
else if LGet.Status = ssNoSnapshot then
|
||||
// DEPOT VIDE : c'est une creation, pas un echec. Sans sha, l'API
|
||||
// des contenus cree le fichier ; avec un sha inconnu d'elle, elle
|
||||
// refuserait. On publiait donc "read failed (HTTP 404)" en boucle
|
||||
// sur un depot neuf, et rien ne partait jamais.
|
||||
FETag := ''
|
||||
else
|
||||
begin
|
||||
LErr := Format('read failed (HTTP %d) %s', [LGet.HttpCode, LGet.Error]);
|
||||
@@ -1329,7 +1407,7 @@ begin
|
||||
end;
|
||||
|
||||
LBody := EncryptContainerWithKey(LMerged, FKey, FKeySalt, FKeyIters);
|
||||
LPut := LClient.Put(LBody, FETag, 'offline changes from phone');
|
||||
LPut := LClient.Put(LBody, FETag, 'update from phone');
|
||||
if LPut.Status = ssOK then
|
||||
begin
|
||||
FETag := LPut.ETag;
|
||||
@@ -1469,7 +1547,7 @@ begin
|
||||
// quelques secondes, et il n'arrive qu'une fois.
|
||||
if FKey = nil then LBody := EncryptContainer(LMerged, FSyncPwd)
|
||||
else LBody := EncryptContainerWithKey(LMerged, FKey, FKeySalt, FKeyIters);
|
||||
LPut := LClient.Put(LBody, LGet.ETag, 'reconciled from phone');
|
||||
LPut := LClient.Put(LBody, LGet.ETag, 'update from phone');
|
||||
if LPut.Status = ssOK then
|
||||
begin
|
||||
FETag := LPut.ETag;
|
||||
@@ -1574,7 +1652,7 @@ begin
|
||||
// Pas de rang a rouvrir : les entrees n'existeront plus. AdoptVault ramene la
|
||||
// liste, ce qui est exactement ou l'on veut se retrouver.
|
||||
CommitChange(Format('Deleted %d', [Length(LUuids)]),
|
||||
'entries deleted from phone', '',
|
||||
'update from phone', '',
|
||||
function (APlain: string): string
|
||||
var
|
||||
I: Integer;
|
||||
@@ -1665,7 +1743,7 @@ begin
|
||||
Exit;
|
||||
end;
|
||||
CommitChange(Format('Duplicated %d', [Length(LUuids)]),
|
||||
'entries duplicated from phone', '',
|
||||
'update from phone', '',
|
||||
function (APlain: string): string
|
||||
var
|
||||
I: Integer;
|
||||
@@ -1686,7 +1764,7 @@ begin
|
||||
RefuseChange('Give it at least a name');
|
||||
Exit;
|
||||
end;
|
||||
CommitChange('Added: ' + ATitle, 'new entry from phone', '',
|
||||
CommitChange('Added: ' + ATitle, 'update from phone', '',
|
||||
function (APlain: string): string
|
||||
begin
|
||||
Result := AddEntry(APlain, ATitle, ASite, AUser, APwd, AKind, AFolder,
|
||||
@@ -1950,7 +2028,10 @@ begin
|
||||
end
|
||||
|
||||
else if LAction = 'icon' then
|
||||
SetEntryIcon(Param('a') = 'fetch')
|
||||
begin
|
||||
if Param('a') = 'pick' then PickEntryIcon
|
||||
else SetEntryIcon(Param('a') = 'fetch');
|
||||
end
|
||||
|
||||
{$IFDEF DEBUG}
|
||||
// JETER LE COFFRE LOCAL - debogage seulement, et absent a la compilation
|
||||
@@ -1984,7 +2065,10 @@ begin
|
||||
// SCANNER LE QR DU POSTE depuis l'app : le scanner du systeme envoyait
|
||||
// le lien a une recherche web ou demandait avec quoi l'ouvrir.
|
||||
else if LAction = 'scanqr' then
|
||||
StartScan
|
||||
begin
|
||||
FScanTotp := Param('for') = 'totp';
|
||||
StartScan;
|
||||
end
|
||||
|
||||
else if LAction = 'pinclear' then
|
||||
ClearUnlockCode(TNetEncoding.URL.Decode(Param('c')))
|
||||
@@ -2002,6 +2086,21 @@ begin
|
||||
SaveEntryField(TNetEncoding.URL.Decode(Param('f')),
|
||||
TNetEncoding.URL.Decode(Param('v')))
|
||||
|
||||
// f0=&v0=&f1=&v1=... : autant de paires que de champs modifies.
|
||||
else if LAction = 'setfields' then
|
||||
begin
|
||||
var LF: TArray<string> := [];
|
||||
var LV: TArray<string> := [];
|
||||
var LN := 0;
|
||||
while Param('f' + IntToStr(LN)) <> '' do
|
||||
begin
|
||||
LF := LF + [TNetEncoding.URL.Decode(Param('f' + IntToStr(LN)))];
|
||||
LV := LV + [TNetEncoding.URL.Decode(Param('v' + IntToStr(LN)))];
|
||||
Inc(LN);
|
||||
end;
|
||||
SaveEntryFields(LF, LV);
|
||||
end
|
||||
|
||||
// La page vient de poser le focus sur la recherche : on deplie le clavier.
|
||||
else if LAction = 'kbd' then
|
||||
ShowSoftKeyboard
|
||||
@@ -2025,6 +2124,14 @@ begin
|
||||
if DetailRowAt(FCurrent, StrToIntDef(Param('k'), -1), LCaption, LValue) then
|
||||
CopyToClipboard(LValue, LCaption);
|
||||
end
|
||||
// Une commande d'une note de commandes, par son rang dans la liste.
|
||||
else if Param('f') = 'cmd' then
|
||||
begin
|
||||
var LCmds := ParseCommandNote(FCurrent.Password);
|
||||
var LK := StrToIntDef(Param('k'), -1);
|
||||
if (LK >= 0) and (LK <= High(LCmds)) then
|
||||
CopyToClipboard(LCmds[LK].Cmd, 'Command');
|
||||
end
|
||||
else
|
||||
begin
|
||||
LIdx := StrToIntDef(Param('i'), -1);
|
||||
@@ -2478,8 +2585,12 @@ begin
|
||||
try
|
||||
LSw := TStopwatch.StartNew;
|
||||
AdoptVault(LPlain);
|
||||
if FUiKbdOnSearch then
|
||||
WebCall('PM.focusSearch()'); // on ouvre pour chercher : clavier pret
|
||||
// ON OUVRE POUR CHERCHER - sauf quand l'ouverture n'est qu'une etape.
|
||||
// Synchroniser coffre ferme passe par ici avant de publier : le clavier
|
||||
// montait alors au milieu d'une synchro, pour une recherche que personne
|
||||
// n'avait demandee.
|
||||
if FUiKbdOnSearch and (not LChainSync) then
|
||||
WebCall('PM.focusSearch()');
|
||||
Log(' displayed in ' + LSw.ElapsedMilliseconds.ToString + ' ms');
|
||||
// Afficher d'abord, synchroniser ensuite : le coffre est a l'ecran, le
|
||||
// reseau se debrouille derriere. Sans mot de passe range, on ne peut
|
||||
@@ -2497,8 +2608,15 @@ begin
|
||||
[MinutesBetween(Now, FLastRefresh), REFRESH_MIN_MINUTES]))
|
||||
else
|
||||
begin
|
||||
// ON REGARDE, ON N'ADOPTE PAS. L'ouverture tirait tout l'instantane et
|
||||
// le dechiffrait, alors qu'elle venait d'ouvrir depuis le cache : le
|
||||
// reseau decidait du moment ou la liste changeait sous les yeux, et
|
||||
// une panne se voyait en plein ecran pour un simple lancement.
|
||||
// Une requete conditionnelle suffit a savoir si le depot a bouge ;
|
||||
// s'il a bouge, le temoin de synchro s'allume et c'est l'utilisateur
|
||||
// qui decide quand tirer.
|
||||
FLastRefresh := Now;
|
||||
FetchAndDecrypt(FUrl, FToken, FSyncPwd, True);
|
||||
TThread.ForceQueue(nil, procedure begin ProbeRemote; end);
|
||||
end;
|
||||
except
|
||||
on E: Exception do
|
||||
@@ -2907,6 +3025,30 @@ begin
|
||||
begin
|
||||
// Le serveur repond pareil dans les deux cas, par conception.
|
||||
Log(' 404: no snapshot OR invalid token');
|
||||
// DEPOT VIDE ET DES CHANGEMENTS QUI ATTENDENT ICI : on publie.
|
||||
//
|
||||
// Le telephone verifiait d'abord le mot de passe de synchro en
|
||||
// telechargeant l'instantane - et s'arretait la quand il n'y en
|
||||
// avait pas encore, en repetant 404 a chaque essai. Rien ne
|
||||
// partait jamais, alors que c'est exactement le premier envoi :
|
||||
// le poste fait de meme sur un depot neuf.
|
||||
//
|
||||
// Le mot de passe tape devient celui du depot, faute d'un
|
||||
// instantane contre lequel l'eprouver - c'est ce que veut dire
|
||||
// "premiere publication".
|
||||
if (FPlain <> '') and FPending then
|
||||
begin
|
||||
TThread.Queue(nil,
|
||||
procedure
|
||||
begin
|
||||
FSyncPwd := APwd;
|
||||
FPwdUserSet := False;
|
||||
FPwdShown := APwd;
|
||||
Log(' empty repository - publishing what waits here');
|
||||
PublishPending;
|
||||
end);
|
||||
Exit;
|
||||
end;
|
||||
if not ASilent then
|
||||
begin
|
||||
// TRANCHER L'AMBIGUITE : fichier absent, depot ou utilisateur
|
||||
@@ -3342,7 +3484,7 @@ begin
|
||||
|
||||
if not AFetch then
|
||||
begin
|
||||
CommitChange('Icon removed: ' + LTitle, 'icon removed from phone', LUuid,
|
||||
CommitChange('Icon removed: ' + LTitle, 'update from phone', LUuid,
|
||||
function (APlain: string): string
|
||||
begin
|
||||
Result := PatchEntryIcon(APlain, LUuid, '', False);
|
||||
@@ -3383,7 +3525,7 @@ begin
|
||||
// Provenance a FAUX : elle vient d'une recherche automatique, pas
|
||||
// d'un choix. Le poste ne remplace jamais une icone choisie lors
|
||||
// d'un rafraichissement, et ce drapeau est ce qui le lui dit.
|
||||
CommitChange('Icon set: ' + LTitle, 'icon fetched from phone', LUuid,
|
||||
CommitChange('Icon set: ' + LTitle, 'update from phone', LUuid,
|
||||
function (APlain: string): string
|
||||
begin
|
||||
Result := PatchEntryIcon(APlain, LUuid, LUri, False);
|
||||
@@ -3392,6 +3534,100 @@ begin
|
||||
end).Start;
|
||||
end;
|
||||
|
||||
/// L'ICONE VENUE DE LA GALERIE. Provenance a VRAI, comme un televersement
|
||||
/// depuis le poste : une icone choisie ne doit jamais etre remplacee par une
|
||||
/// recherche automatique, et ce drapeau est ce qui le dit au poste.
|
||||
const
|
||||
ICON_MAX_PX = 128;
|
||||
|
||||
procedure TForm8.IconPicked(AImage: TBitmap);
|
||||
var
|
||||
LSmall: TBitmap;
|
||||
LStream: TMemoryStream;
|
||||
LB64: TBase64Encoding;
|
||||
LUri, LTitle, LUuid: string;
|
||||
LScale: Single;
|
||||
begin
|
||||
if (AImage = nil) or (AImage.Width = 0) or (AImage.Height = 0) then Exit;
|
||||
if FCurrent.Uuid = '' then Exit;
|
||||
LUuid := FCurrent.Uuid;
|
||||
LTitle := FCurrent.Display;
|
||||
if AImage.Width > AImage.Height then
|
||||
LScale := ICON_MAX_PX / AImage.Width
|
||||
else
|
||||
LScale := ICON_MAX_PX / AImage.Height;
|
||||
if LScale > 1 then LScale := 1;
|
||||
LSmall := TBitmap.Create(Round(AImage.Width * LScale),
|
||||
Round(AImage.Height * LScale));
|
||||
try
|
||||
LSmall.Canvas.BeginScene;
|
||||
try
|
||||
LSmall.Canvas.DrawBitmap(AImage, RectF(0, 0, AImage.Width, AImage.Height),
|
||||
RectF(0, 0, LSmall.Width, LSmall.Height), 1, True);
|
||||
finally
|
||||
LSmall.Canvas.EndScene;
|
||||
end;
|
||||
LStream := TMemoryStream.Create;
|
||||
try
|
||||
LSmall.SaveToStream(LStream); // PNG
|
||||
// Sans coupure de ligne : une URI data: coupee ne s'affiche pas.
|
||||
LB64 := TBase64Encoding.Create(0);
|
||||
try
|
||||
LUri := 'data:image/png;base64,'
|
||||
+ LB64.EncodeBytesToString(LStream.Memory, LStream.Size);
|
||||
finally
|
||||
LB64.Free;
|
||||
end;
|
||||
finally
|
||||
LStream.Free;
|
||||
end;
|
||||
finally
|
||||
LSmall.Free;
|
||||
end;
|
||||
Log(Format('icon: picked image, %d chars', [Length(LUri)]));
|
||||
CommitChange('Icon set: ' + LTitle, 'update from phone', LUuid,
|
||||
function (APlain: string): string
|
||||
begin
|
||||
Result := PatchEntryIcon(APlain, LUuid, LUri, True);
|
||||
end);
|
||||
end;
|
||||
|
||||
procedure TForm8.BeginPickIcon;
|
||||
begin
|
||||
if FPickIcon = nil then
|
||||
begin
|
||||
FPickIcon := TTakePhotoFromLibraryAction.Create(Self);
|
||||
FPickIcon.OnDidFinishTaking := IconPicked;
|
||||
end;
|
||||
FPickIcon.Execute;
|
||||
end;
|
||||
|
||||
procedure TForm8.PickEntryIcon;
|
||||
begin
|
||||
if FBusy then Exit;
|
||||
if FCurrent.Uuid = '' then
|
||||
begin
|
||||
RefuseChange('This entry has no uuid');
|
||||
Exit;
|
||||
end;
|
||||
{$IFDEF ANDROID}
|
||||
// Les deux noms : le premier est celui d'Android 13 et plus, le second celui
|
||||
// d'avant. Un nom inconnu du systeme est simplement refuse, et le selecteur
|
||||
// recent n'exige de toute facon rien - on ouvre donc dans tous les cas, et
|
||||
// c'est Android qui refuse s'il doit refuser.
|
||||
PermissionsService.RequestPermissions(
|
||||
['android.permission.READ_MEDIA_IMAGES',
|
||||
'android.permission.READ_EXTERNAL_STORAGE'],
|
||||
procedure(const APermissions: TClassicStringDynArray;
|
||||
const AGrantResults: TClassicPermissionStatusDynArray)
|
||||
begin
|
||||
BeginPickIcon;
|
||||
end);
|
||||
{$ELSE}
|
||||
BeginPickIcon;
|
||||
{$ENDIF}
|
||||
end;
|
||||
|
||||
/// POSER OU CHANGER LE CODE, IMMEDIATEMENT ET SUR DEMANDE.
|
||||
///
|
||||
/// Le coffre doit etre ouvert : c'est la seule preuve que le code courant est
|
||||
@@ -3566,6 +3802,9 @@ begin
|
||||
except
|
||||
// Pas d'autofocus continu : l'image restera nette a bonne distance.
|
||||
end;
|
||||
if FScanTotp then
|
||||
FScanLbl.Text := 'Point the camera at the 2FA QR code'
|
||||
else
|
||||
FScanLbl.Text := 'Point the camera at the QR shown on the PC';
|
||||
FCam.Active := True;
|
||||
FScanWatch.Enabled := True;
|
||||
@@ -3653,13 +3892,30 @@ begin
|
||||
// CHERCHE, NE COMPARE PAS LE DEBUT : le texte decode est arrive
|
||||
// avec un caractere invisible en tete (104 lus pour 103 emis),
|
||||
// et le lien etait ignore alors qu'il etait lu.
|
||||
if Pos('pmserver://sync?', LText) > 0 then
|
||||
// 2FA : le lien otpauth:// porte le SECRET. Il part a la page,
|
||||
// qui le range dans le champ ; le journal n'en dit que le fait.
|
||||
if FScanTotp and (Pos('otpauth://', LowerCase(LText)) > 0) then
|
||||
begin
|
||||
StopScan;
|
||||
Log('QR scan: 2FA code read');
|
||||
WebCall('PM.totpFromQr(' + JsArg(Trim(Copy(LText,
|
||||
Pos('otpauth://', LowerCase(LText)), MaxInt))) + ')');
|
||||
end
|
||||
else if (not FScanTotp) and (Pos('pmserver://sync?', LText) > 0) then
|
||||
begin
|
||||
StopScan;
|
||||
ApplySyncLink(Trim(Copy(LText, Pos('pmserver://sync?', LText), MaxInt)));
|
||||
end
|
||||
// UN QR LU, MAIS PAS LE BON. On continue de viser - plusieurs QR
|
||||
// peuvent etre dans le champ - et le bandeau dit pourquoi rien ne
|
||||
// se passe, au lieu d'un scan qui semble ne rien voir.
|
||||
else if FScanTotp then
|
||||
FScanLbl.Text := 'This QR code is not a 2FA code - aim at the one the site shows'
|
||||
else
|
||||
begin
|
||||
FScanLbl.Text := 'This QR code is not the one shown by the PC';
|
||||
Log('QR scan: not a sync link, first char code ' + IntToStr(Ord(LText[1])));
|
||||
end;
|
||||
end);
|
||||
end).Start;
|
||||
end);
|
||||
@@ -3773,10 +4029,26 @@ end;
|
||||
|
||||
procedure TForm8.ProbeRemote;
|
||||
begin
|
||||
if FBusy or (FUrl = '') or (FToken = '') then Exit;
|
||||
// JOURNALISE MEME QUAND ELLE NE FAIT RIEN. Une verification muette ne se
|
||||
// distingue pas d'une verification absente : on ne savait pas si le depot
|
||||
// avait ete interroge, seulement qu'il n'avait rien dit.
|
||||
if FBusy or (FUrl = '') or (FToken = '') then
|
||||
begin
|
||||
Log('check: skipped (busy or no repository configured)');
|
||||
Exit;
|
||||
end;
|
||||
// Un coffre ne ici n'a pas de depot a interroger.
|
||||
if HasLocalContainer then Exit;
|
||||
if FETag = '' then Exit;
|
||||
if HasLocalContainer then
|
||||
begin
|
||||
Log('check: skipped (local vault, no repository)');
|
||||
Exit;
|
||||
end;
|
||||
if FETag = '' then
|
||||
begin
|
||||
Log('check: skipped (no fingerprint to compare)');
|
||||
Exit;
|
||||
end;
|
||||
Log('check: asking the repository if it moved');
|
||||
|
||||
TThread.CreateAnonymousThread(
|
||||
procedure
|
||||
@@ -3792,11 +4064,30 @@ begin
|
||||
LGet := LClient.Fetch(FETag);
|
||||
LAhead := (LGet.Status = ssOK) and (LGet.ETag <> '')
|
||||
and not SameText(LGet.ETag.Trim, FETag.Trim);
|
||||
// TROIS VERDICTS, PAS DEUX. "Rien n'a change" et "je n'ai pas pu
|
||||
// demander" se ressemblent - le temoin reste eteint dans les deux
|
||||
// cas - et le journal les confondait sous "unchanged (HTTP 0)".
|
||||
TThread.Queue(nil,
|
||||
procedure
|
||||
begin
|
||||
if LAhead then
|
||||
Log(' it moved - sync to bring it in')
|
||||
else if LGet.Status in [ssOK, ssNotModified] then
|
||||
Log(Format(' unchanged (HTTP %d)', [LGet.HttpCode]))
|
||||
else
|
||||
Log(Format(' no answer (HTTP %d) %s',
|
||||
[LGet.HttpCode, LGet.Error]));
|
||||
end);
|
||||
except
|
||||
// Hors reseau : ce n'est pas une nouvelle du depot, c'est une absence
|
||||
// de nouvelle. Le temoin ne change pas.
|
||||
on E: Exception do
|
||||
begin
|
||||
// Hors reseau : ce n'est pas une nouvelle du depot, c'est une
|
||||
// absence de nouvelle. Le temoin ne change pas, mais le journal le
|
||||
// dit - sinon rien ne distingue "pas joignable" de "rien change".
|
||||
TThread.Queue(nil, procedure begin Log(' not reachable'); end);
|
||||
Exit;
|
||||
end;
|
||||
end;
|
||||
finally
|
||||
LClient.Free;
|
||||
end;
|
||||
@@ -3805,7 +4096,6 @@ begin
|
||||
procedure
|
||||
begin
|
||||
if FRemoteAhead then Exit; // deja dit
|
||||
Log('remote has moved - sync to bring it in');
|
||||
SetRemoteAhead(True);
|
||||
end);
|
||||
end).Start;
|
||||
|
||||
Reference in New Issue
Block a user