feat(sync): WebDAV sur l'APK, et le QR du poste qui le porte

Jeton "utilisateur:mot-de-passe" sur une URL ni Gitea ni GitHub = WebDAV,
authentification Basic. Fichier lu et ecrit tel quel ; l'ETag fait le sha
(If-None-Match, If-Match, 412 -> conflit). Clair autorise vers l'IP fixe du
poste seulement. Eprouve contre le WsgiDAV local : 304, PUT 204, puis
synchro du poste. Docs : CLAUDE.md, android-client/README.md (page eveillee
comprise).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-10-02 23:13:05 +01:00
parent 2cc8d1b62b
commit 3051c3c392
6 changed files with 151 additions and 10 deletions
+19
View File
@@ -1150,6 +1150,11 @@ QR que l'application Android scanne elle-meme.
- **Piege CSS rencontre** : `css/style.css` impose `svg { width: 16px }` a - **Piege CSS rencontre** : `css/style.css` impose `svg { width: 16px }` a
toutes les icones, ce qui reduisait le QR a la taille d'une icone. La taille toutes les icones, ce qui reduisait le QR a la taille d'une icone. La taille
est donc posee **en style direct** sur le `<svg>`. est donc posee **en style direct** sur le `<svg>`.
- **WebDAV (2026-10-02)** : le telephone n'a qu'un champ « jeton », donc pour une
URL qui n'est ni Gitea ni GitHub le QR porte `syncUser:syncPwd` — le
telephone en fait une authentification Basic (voir android-client/README.md).
Le QR reprend l'URL telle quelle : une configuration en `127.0.0.1` designe
le telephone lui-meme une fois scannee, il faut l'IP du poste.
### GitHub, et refus d'un depot public (2026-09-18) ### GitHub, et refus d'un depot public (2026-09-18)
@@ -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 résidus `-shm`/`-wal`). Kill brutal (Task Manager) laisse les résidus, mais
SQLite récupère au prochain open. SQLite récupère au prochain open.
## Page tenue éveillée (autofill lent après le tray, 2026-10-02)
Premier autofill après des heures dans le tray ou une veille : `Autofill: page
answered in 4907 ms` (8 ms coffre tout juste ouvert). Les drapeaux
`--disable-renderer-backgrounding` & co **sont bien appliqués** (vérifié sur la
ligne de commande de `msedgewebview2.exe`) : ce n'est pas Chromium qui gèle la
page, c'est Windows qui laisse s'endormir un processus que personne ne sollicite.
- `FKeepWarmTimer` (UMainForm) : un `ExecuteJavaScript` trivial **toutes les
5 min** (choix de l'utilisateur, pas 30 s).
- `TPMBridge.OnSystemResume` : `PBT_APMRESUMEAUTOMATIC` et `WTS_SESSION_UNLOCK`
réveillent la page **tout de suite** — le minuteur ne tourne pas pendant la
veille. Journal : `Windows resumed / unlocked — waking the page`.
## WebView2 nav race (écran noir cold-start) ## WebView2 nav race (écran noir cold-start)
Le timer de navigation (1.5 s) ne consomme `FPendingURL` que si Le timer de navigation (1.5 s) ne consomme `FPendingURL` que si
+90 -7
View File
@@ -76,6 +76,12 @@ type
/// n'y ressemble pas est de toute facon inutilisable. /// n'y ressemble pas est de toute facon inutilisable.
function IsGitea: Boolean; function IsGitea: Boolean;
/// WEBDAV : toute autre URL dont le "jeton" s'ecrit utilisateur:mot-de-passe.
/// Le fichier se lit et s'ecrit tel quel (le meme que publie le poste), et
/// l'ETag HTTP joue le role du sha : If-None-Match a la lecture, If-Match a
/// l'ecriture (412 = un autre appareil a publie entre-temps).
function IsWebDav: Boolean;
/// UN DEPOT PUBLIC EST REFUSE (Gitea comme GitHub) : chaque synchro est un /// UN DEPOT PUBLIC EST REFUSE (Gitea comme GitHub) : chaque synchro est un
/// commit, et l'historique garde tous les instantanes, attaquables hors /// commit, et l'historique garde tous les instantanes, attaquables hors
/// ligne par quiconque peut les lire. Message a afficher, ou '' si le depot /// ligne par quiconque peut les lire. Message a afficher, ou '' si le depot
@@ -116,6 +122,81 @@ begin
FBaseUrl.ToLower.Contains('api.github.com/repos/'); FBaseUrl.ToLower.Contains('api.github.com/repos/');
end; end;
function TSyncClient.IsWebDav: Boolean;
begin
Result := (not IsGitea) and FToken.Contains(':');
end;
/// Basic sur WebDAV, Bearer pour l'endpoint PHP historique.
function PlainAuthHeader(const AToken: string; AWebDav: Boolean): string;
begin
if AWebDav then
Result := 'Basic ' + TNetEncoding.Base64.Encode(AToken).Replace(#13, '').Replace(#10, '')
else
Result := 'Bearer ' + AToken;
end;
function PutWebDav(const AUrl, AToken, ABody, AETag: string;
AConnectMs, ATimeoutMs: Integer): TSyncResult;
var
LHttp: THTTPClient;
LResp: IHTTPResponse;
LReq, LOut: TStringStream;
begin
Result := Default(TSyncResult);
Result.Status := ssNetworkError;
LHttp := THTTPClient.Create;
LReq := TStringStream.Create(ABody, TEncoding.UTF8);
LOut := TStringStream.Create('', TEncoding.UTF8);
try
LHttp.ConnectionTimeout := AConnectMs;
LHttp.ResponseTimeout := ATimeoutMs;
LHttp.HandleRedirects := False;
LHttp.UserAgent := 'PMServer';
LHttp.CustomHeaders['Connection'] := 'close';
LHttp.CustomHeaders['Authorization'] := PlainAuthHeader(AToken, True);
LHttp.CustomHeaders['Content-Type'] := 'application/json';
// Sans ETag connu (premiere publication, serveur qui n'en rend pas) : pas
// de condition, le dernier ecrit gagne - comme le poste dans ce cas.
if AETag <> '' then
LHttp.CustomHeaders['If-Match'] := AETag;
LResp := nil;
for var LTry := 1 to 3 do
begin
try
LReq.Position := 0;
LOut.Clear;
LResp := LHttp.Put(AUrl, LReq, LOut);
Break;
except
on E: Exception do
begin
Result.Error := E.ClassName + ': ' + E.Message;
if LTry = 3 then Exit;
end;
end;
end;
if LResp = nil then Exit;
Result.HttpCode := LResp.StatusCode;
case LResp.StatusCode of
200, 201, 204:
begin
Result.Status := ssOK;
Result.ETag := LResp.HeaderValue['ETag'];
end;
412: Result.Status := ssConflict;
else
Result.Status := ssHttpError;
Result.Error := Format('HTTP %d %s - %s', [LResp.StatusCode,
LResp.StatusText, Copy(LOut.DataString, 1, 300)]);
end;
finally
LOut.Free;
LReq.Free;
LHttp.Free;
end;
end;
/// URL de l'API du depot (sans /contents/...), identique pour Gitea /// URL de l'API du depot (sans /contents/...), identique pour Gitea
/// (https://hote/api/v1/repos/O/R) et GitHub (https://api.github.com/repos/O/R). /// (https://hote/api/v1/repos/O/R) et GitHub (https://api.github.com/repos/O/R).
function RepoApiUrl(const AContentsUrl: string): string; function RepoApiUrl(const AContentsUrl: string): string;
@@ -295,17 +376,19 @@ begin
Result := Default(TSyncResult); Result := Default(TSyncResult);
Result.Status := ssNetworkError; Result.Status := ssNetworkError;
if not IsGitea then
begin
Result.Status := ssHttpError;
Result.Error := 'Ecriture disponible seulement vers un depot Gitea ou GitHub';
Exit;
end;
if (FBaseUrl = '') or (FToken = '') then if (FBaseUrl = '') or (FToken = '') then
begin begin
Result.Error := 'URL ou jeton non configure'; Result.Error := 'URL ou jeton non configure';
Exit; Exit;
end; end;
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; Result.Error := PublicRepoWarning;
if Result.Error <> '' then if Result.Error <> '' then
begin begin
@@ -466,7 +549,7 @@ begin
LHttp.CustomHeaders['Authorization'] := 'token ' + FToken LHttp.CustomHeaders['Authorization'] := 'token ' + FToken
else else
begin begin
LHttp.CustomHeaders['Authorization'] := 'Bearer ' + FToken; LHttp.CustomHeaders['Authorization'] := PlainAuthHeader(FToken, IsWebDav);
// Pas sur Gitea : le sha n'est pas un ETag HTTP, et l'envoyer ferait // Pas sur Gitea : le sha n'est pas un ETag HTTP, et l'envoyer ferait
// repondre 200 avec un corps complet de toute facon. // repondre 200 avec un corps complet de toute facon.
if AKnownETag <> '' then if AKnownETag <> '' then
+26
View File
@@ -416,6 +416,32 @@ gestionnaire ne s'exécutait pas (dont « Tap again to delete »). Une vraie
détail reste dans le journal local. détail reste dans le journal local.
- **Nom par défaut `data.json`** au lieu de `vault-sync.json` — voir CLAUDE.md. - **Nom par défaut `data.json`** au lieu de `vault-sync.json` — voir CLAUDE.md.
## WebDAV — 2026-10-02
Troisième transport, à côté de Gitea et GitHub, éprouvé contre le **WsgiDAV
local du poste** (`E:\webdav.json`, `0.0.0.0:8080`) : lecture, 304 à
l'ouverture, écriture 204, puis synchro du poste qui reprend la modification.
- **Reconnu au jeton, pas à l'URL** : une URL ni Gitea ni GitHub dont le jeton
s'écrit `utilisateur:mot-de-passe` → `IsWebDav`, authentification Basic.
Pas de champ « utilisateur » de plus dans l'écran : le jeton en tient lieu
(sans `:`, c'est toujours l'ancien endpoint PHP, en Bearer).
- **Le fichier tel quel**, le même que publie le poste : GET / PUT sans
enveloppe base64. **L'ETag HTTP fait le sha** : `If-None-Match` à la lecture
(304 = rien à faire), `If-Match` à l'écriture, **412 → `ssConflict`**, donc la
même reprise relire-fusionner-republier que le 409/422 de Gitea. Sans ETag
connu, pas de condition : le dernier écrit gagne, comme le poste.
- **Une URL qui finit en `.json` n'est plus réécrite** par `normalizeRepoUrl`
(elle en faisait une URL d'API Gitea) — sauf les formes longues d'un dépôt,
`/src/branch/…` et `/blob/…`.
- **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é ## Saisie : ce qui a résisté
- **`user-select: none` du `body`** était hérité par les champs : le curseur ne - **`user-select: none` du `body`** était hérité par les champs : le curseur ne
@@ -22,5 +22,9 @@
<domain-config cleartextTrafficPermitted="true"> <domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">127.0.0.1</domain> <domain includeSubdomains="false">127.0.0.1</domain>
<domain includeSubdomains="false">localhost</domain> <domain includeSubdomains="false">localhost</domain>
<!-- ponytail: WebDAV local du poste (IP fixe), en clair sur le Wi-Fi.
Le coffre reste chiffre ; le mot de passe WebDAV, lui, passe en
clair. A retirer quand le serveur parlera HTTPS. -->
<domain includeSubdomains="false">192.168.1.28</domain>
</domain-config> </domain-config>
</network-security-config> </network-security-config>
+6 -3
View File
@@ -528,7 +528,7 @@
champ. Une adresse d'API Gitea en fait un jeton d'acces personnel champ. Une adresse d'API Gitea en fait un jeton d'acces personnel
envoye en "token" ; l'endpoint PHP en fait un "Bearer". Le envoye en "token" ; l'endpoint PHP en fait un "Bearer". Le
nommer d'apres un seul des deux le rend faux pour l'autre. --> nommer d'apres un seul des deux le rend faux pour l'autre. -->
<input class="field-in" id="tok" placeholder="access token" <input class="field-in" id="tok" placeholder="access token (WebDAV: user:password)"
type="password" autocomplete="off"> type="password" autocomplete="off">
<div class="in-x" data-clear="tok">&#215;</div> <div class="in-x" data-clear="tok">&#215;</div>
</div> </div>
@@ -615,13 +615,13 @@
entier. Un input d'une ligne ne defilait pas sous le doigt dans la entier. Un input d'une ligne ne defilait pas sous le doigt dans la
WebView de l'app, et la fin d'une URL d'API restait inatteignable. --> WebView de l'app, et la fin d'une URL d'API restait inatteignable. -->
<textarea class="field-in multi" id="sUrl" rows="1" <textarea class="field-in multi" id="sUrl" rows="1"
placeholder="repository URL - Gitea or github.com/owner/repo (private)" placeholder="repository URL - Gitea, github.com/owner/repo (private), or WebDAV file URL"
autocomplete="off" autocorrect="off" autocapitalize="none" autocomplete="off" autocorrect="off" autocapitalize="none"
spellcheck="false" inputmode="url"></textarea> spellcheck="false" inputmode="url"></textarea>
<div class="in-x" data-clear="sUrl">&#215;</div> <div class="in-x" data-clear="sUrl">&#215;</div>
</div> </div>
<div class="in-wrap"> <div class="in-wrap">
<input class="field-in" id="sTok" placeholder="access token" <input class="field-in" id="sTok" placeholder="access token (WebDAV: user:password)"
type="password" autocomplete="off"> type="password" autocomplete="off">
<div class="in-x" data-clear="sTok">&#215;</div> <div class="in-x" data-clear="sTok">&#215;</div>
</div> </div>
@@ -1903,6 +1903,9 @@ function normalizeRepoUrl(u) {
u = u.replace(/\s+/g, ''); u = u.replace(/\s+/g, '');
if (!u || /\/api\/v1\/repos\//i.test(u) || /api\.github\.com\/repos\//i.test(u) || if (!u || /\/api\/v1\/repos\//i.test(u) || /api\.github\.com\/repos\//i.test(u) ||
/\.php(\?|$)/i.test(u)) return u; /\.php(\?|$)/i.test(u)) return u;
// WEBDAV : l'URL du fichier lui-meme, a garder telle quelle. Seules les
// formes longues d'un depot (/src/branch/, /blob/) finissent aussi en .json.
if (/\.json$/i.test(u) && !/\/(src\/branch|blob)\//i.test(u)) return u;
// GITHUB : l'adresse du depot est sur github.com, l'API sur api.github.com. // GITHUB : l'adresse du depot est sur github.com, l'API sur api.github.com.
var g = u.match(/^https?:\/\/(www\.)?github\.com\/([^\/]+)\/([^\/]+?)(?:\.git)?(?:\/blob\/[^\/]+\/(.+?))?\/?$/i); var g = u.match(/^https?:\/\/(www\.)?github\.com\/([^\/]+)\/([^\/]+?)(?:\.git)?(?:\/blob\/[^\/]+\/(.+?))?\/?$/i);
if (g) return 'https://api.github.com/repos/' + g[2] + '/' + g[3] + if (g) return 'https://api.github.com/repos/' + g[2] + '/' + g[3] +
+6
View File
@@ -1464,6 +1464,12 @@ begin
begin begin
var LQrUrl := PM.UserPrefs.GetPref('syncUrl'); var LQrUrl := PM.UserPrefs.GetPref('syncUrl');
var LQrTok := PM.UserPrefs.GetPref('syncPwd'); var LQrTok := PM.UserPrefs.GetPref('syncPwd');
// WEBDAV : le telephone attend "utilisateur:mot-de-passe" dans le jeton
// (il en fait une authentification Basic).
if (Pos('/api/v1/repos/', LQrUrl) = 0) and
not ContainsText(LQrUrl, 'api.github.com/repos/') and
(PM.UserPrefs.GetPref('syncUser') <> '') and (LQrTok <> '') then
LQrTok := PM.UserPrefs.GetPref('syncUser') + ':' + LQrTok;
var LBits := ''; var LBits := '';
var LSize := 0; var LSize := 0;
if (LQrUrl <> '') and (LQrTok <> '') then if (LQrUrl <> '') and (LQrTok <> '') then