From 3051c3c3927ecbe61fdc8c7b5fde39819f11bbd9 Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:13:05 +0100 Subject: [PATCH] feat(sync): WebDAV sur l'APK, et le QR du poste qui le porte Jeton "utilisateur:mot-de-passe" sur une URL ni Gitea ni GitHub = WebDAV, authentification Basic. Fichier lu et ecrit tel quel ; l'ETag fait le sha (If-None-Match, If-Match, 412 -> conflit). Clair autorise vers l'IP fixe du poste seulement. Eprouve contre le WsgiDAV local : 304, PUT 204, puis synchro du poste. Docs : CLAUDE.md, android-client/README.md (page eveillee comprise). Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 19 ++++ android-client/PMA.SyncClient.pas | 97 +++++++++++++++++-- android-client/README.md | 26 +++++ .../xml/network_security_config.xml | 4 + android-client/web/app.html | 9 +- delphi-backend/UMainForm.pas | 6 ++ 6 files changed, 151 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9bb3fe0..0b70d43 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1150,6 +1150,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 ``. +- **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) @@ -1558,6 +1563,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 diff --git a/android-client/PMA.SyncClient.pas b/android-client/PMA.SyncClient.pas index affb398..8728c88 100644 --- a/android-client/PMA.SyncClient.pas +++ b/android-client/PMA.SyncClient.pas @@ -76,6 +76,12 @@ 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; + /// 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 @@ -116,6 +122,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; @@ -295,17 +376,19 @@ 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; + 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 := PublicRepoWarning; if Result.Error <> '' then begin @@ -466,7 +549,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 diff --git a/android-client/README.md b/android-client/README.md index 8447daa..9e27c31 100644 --- a/android-client/README.md +++ b/android-client/README.md @@ -416,6 +416,32 @@ gestionnaire ne s'exécutait pas (dont « Tap again to delete »). Une vraie détail reste dans le journal local. - **Nom par défaut `data.json`** au lieu de `vault-sync.json` — voir CLAUDE.md. +## WebDAV — 2026-10-02 + +Troisième transport, à côté de Gitea et GitHub, éprouvé contre le **WsgiDAV +local du poste** (`E:\webdav.json`, `0.0.0.0:8080`) : lecture, 304 à +l'ouverture, écriture 204, puis synchro du poste qui reprend la modification. + +- **Reconnu au jeton, pas à l'URL** : une URL ni Gitea ni GitHub dont le jeton + s'écrit `utilisateur:mot-de-passe` → `IsWebDav`, authentification Basic. + Pas de champ « utilisateur » de plus dans l'écran : le jeton en tient lieu + (sans `:`, c'est toujours l'ancien endpoint PHP, en Bearer). +- **Le fichier tel quel**, le même que publie le poste : GET / PUT sans + enveloppe base64. **L'ETag HTTP fait le sha** : `If-None-Match` à la lecture + (304 = rien à faire), `If-Match` à l'écriture, **412 → `ssConflict`**, donc la + même reprise relire-fusionner-republier que le 409/422 de Gitea. Sans ETag + connu, pas de condition : le dernier écrit gagne, comme le poste. +- **Une URL qui finit en `.json` n'est plus réécrite** par `normalizeRepoUrl` + (elle en faisait une URL d'API Gitea) — sauf les formes longues d'un dépôt, + `/src/branch/…` et `/blob/…`. +- **Clair autorisé vers `192.168.1.28` seulement** (IP fixe du poste), dans + `network_security_config.xml` : le mot de passe WebDAV passe en clair sur le + Wi-Fi, le coffre reste chiffré. À retirer quand le serveur parlera HTTPS. + Android ne sait pas autoriser « le réseau local » : chaque adresse se nomme. +- **Côté poste** : pare-feu Windows (règle `WebDAV 8080`, `LocalSubnet`) **et** + Portmaster (entrant autorisé pour Python) — sans le second, le port restait + fermé malgré la règle. + ## Saisie : ce qui a résisté - **`user-select: none` du `body`** était hérité par les champs : le curseur ne diff --git a/android-client/android-res/xml/network_security_config.xml b/android-client/android-res/xml/network_security_config.xml index 95af0d5..d0fd800 100644 --- a/android-client/android-res/xml/network_security_config.xml +++ b/android-client/android-res/xml/network_security_config.xml @@ -22,5 +22,9 @@ 127.0.0.1 localhost + + 192.168.1.28 diff --git a/android-client/web/app.html b/android-client/web/app.html index fcb7a74..6e4f6ba 100644 --- a/android-client/web/app.html +++ b/android-client/web/app.html @@ -528,7 +528,7 @@ champ. Une adresse d'API Gitea en fait un jeton d'acces personnel envoye en "token" ; l'endpoint PHP en fait un "Bearer". Le nommer d'apres un seul des deux le rend faux pour l'autre. --> -
×
@@ -615,13 +615,13 @@ entier. Un input d'une ligne ne defilait pas sous le doigt dans la WebView de l'app, et la fin d'une URL d'API restait inatteignable. -->
×
-
×
@@ -1903,6 +1903,9 @@ function normalizeRepoUrl(u) { u = u.replace(/\s+/g, ''); if (!u || /\/api\/v1\/repos\//i.test(u) || /api\.github\.com\/repos\//i.test(u) || /\.php(\?|$)/i.test(u)) return u; + // WEBDAV : l'URL du fichier lui-meme, a garder telle quelle. Seules les + // formes longues d'un depot (/src/branch/, /blob/) finissent aussi en .json. + if (/\.json$/i.test(u) && !/\/(src\/branch|blob)\//i.test(u)) return u; // GITHUB : l'adresse du depot est sur github.com, l'API sur api.github.com. var g = u.match(/^https?:\/\/(www\.)?github\.com\/([^\/]+)\/([^\/]+?)(?:\.git)?(?:\/blob\/[^\/]+\/(.+?))?\/?$/i); if (g) return 'https://api.github.com/repos/' + g[2] + '/' + g[3] + diff --git a/delphi-backend/UMainForm.pas b/delphi-backend/UMainForm.pas index 221baa2..e429489 100644 --- a/delphi-backend/UMainForm.pas +++ b/delphi-backend/UMainForm.pas @@ -1464,6 +1464,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