diff --git a/android-client/web/app.html b/android-client/web/app.html index d514f2e..ca36130 100644 --- a/android-client/web/app.html +++ b/android-client/web/app.html @@ -179,6 +179,14 @@ .btn.ico-btn svg { width: 22px; height: 22px; } /* Le retour d'appui ne peut plus venir du fond : il vient du trait. */ .btn.ico-btn:active { opacity: .45; } + /* Le point des modifications en attente. Sur le cadenas parce qu'il est + toujours a l'ecran de la liste, et qu'il parle deja de l'etat du coffre. */ + .ico.pending { position: relative; } + .ico.pending::after { + content: ''; position: absolute; top: 6px; right: 6px; + width: 9px; height: 9px; border-radius: 50%; + background: #ffb44d; box-shadow: 0 0 0 2px var(--bg-top); + } @keyframes spin { to { transform: rotate(360deg); } } /* L'ENREGISTREMENT DURE UNE A DEUX SECONDES, et le bouton doit le dire. Le mot "Saving..." le disait ; une coche immobile ne dirait rien, et c'est @@ -1459,6 +1467,12 @@ var PM = { /// Delphi l'appelle des qu'il part travailler et des qu'il revient. Le /// filet est son minuteur de 25 s : meme si une operation ne rend jamais la /// main, le voile se leve. + /// DES MODIFICATIONS ATTENDENT ICI. Un point sur le cadenas : discret tant + /// qu'on ne le cherche pas, mais present a chaque coup d'oeil - c'est la + /// seule facon de savoir que le depot ignore encore ce qu'on vient de taper. + setPending: function (on) { + document.getElementById('lock').classList.toggle('pending', !!on); + }, setBusy: function (on) { if (on) document.getElementById('busy').firstChild.innerHTML = ico('loader', 'Working') + 'Please wait...'; diff --git a/mainApk.pas b/mainApk.pas index 482bfb5..dcbff20 100644 --- a/mainApk.pas +++ b/mainApk.pas @@ -148,6 +148,8 @@ type /// Elle part au verrouillage, avec lui. FKey, FKeySalt: TBytes; FKeyIters: Integer; + /// Des modifications sont rangees ici sans avoir atteint le depot. + FPending: Boolean; /// L'INSTANTANE BRUT, tel qu'il a ete recu. Pas une reserialisation de /// TVault : celle-ci perdrait tout ce que le telephone ne lit pas - /// le contenu des pieces jointes, l'avatar, les tombstones - c'est @@ -286,6 +288,9 @@ type procedure SaveAttachment(ARowIndex: Integer); /// Fond le coffre ne ici dans le depot, puis passe l'autorite au depot. procedure ReconcileWithRemote; + /// Publie ce qui a ete enregistre hors connexion. Sans effet s'il n'y a + /// rien en attente. + procedure PublishPending; procedure ReopenEntryByUuid(const AUuid: string); procedure OnClipTimer(Sender: TObject); procedure OnDeadline(Sender: TObject); @@ -849,161 +854,144 @@ begin LClient: TSyncClient; LGet, LPut: TSyncResult; LPlain, LNew, LBody, LErr: string; - LKey, LSalt: TBytes; - LIter, LAttempt: Integer; + LAttempt: Integer; LSw: TStopwatch; - LDone: Boolean; + LPublished, LLocalOnly, LPatched: Boolean; begin - LDone := False; - LErr := ''; LClient := nil; + LErr := ''; + LNew := ''; + LPublished := False; + LPatched := False; + LLocalOnly := HasLocalContainer; try try - if HasLocalContainer then + // --------------------------------------------------------------- + // 1. MODIFIER ET RANGER ICI. TOUJOURS, ET D'ABORD. + // + // L'enregistrement partait autrefois du reseau : sans lui, rien + // n'etait garde. Or ce que l'utilisateur vient de taper lui appartient + // deja - le publier est une consequence, pas une condition. On ecrit + // donc le cache avant toute chose, et le depot suit quand il peut. + // --------------------------------------------------------------- + LSw := TStopwatch.StartNew; + if LLocalOnly then LPlain := OpenContainer(LoadLocalContainer, FSyncPwd) + else LPlain := FPlain; + if LPlain = '' then + raise Exception.Create('no snapshot in hand - open the vault again'); + LNew := APatch(LPlain); + LPatched := True; + Log(Format('save: patch in %d ms', [LSw.ElapsedMilliseconds])); + + LSw := TStopwatch.StartNew; + LBody := EncryptContainerWithKey(LNew, FKey, FKeySalt, FKeyIters); + Log(Format('save: re-encrypt in %d ms', [LSw.ElapsedMilliseconds])); + + if LLocalOnly then SaveLocalContainer(LBody); + try + SaveVault(LNew, FETag, FSyncPwd, BiometricAvailable, False, + FPinSealed, FKey, FKeySalt, FKeyIters); + except + on E: Exception do Log(' cache not written: ' + E.Message); + end; + + // --------------------------------------------------------------- + // 2. PUBLIER SI ON PEUT. Un echec ici ne perd rien. + // --------------------------------------------------------------- + if LLocalOnly then begin - // COFFRE LOCAL : rien a tirer, rien a publier, aucun conflit possible - // - il n'y a qu'un ecrivain. Le conteneur est reecrit d'abord, le - // cache ensuite : l'inverse laisserait un cache en avance sur - // l'autorite si l'ecriture echouait entre les deux. - LSw := TStopwatch.StartNew; - LPlain := OpenContainer(LoadLocalContainer, FSyncPwd); - LKey := FKey; LSalt := FKeySalt; LIter := FKeyIters; - LNew := APatch(LPlain); - Log(Format('save: decrypt+patch in %d ms', [LSw.ElapsedMilliseconds])); - LSw := TStopwatch.StartNew; - LBody := EncryptContainerWithKey(LNew, LKey, LSalt, LIter); - Log(Format('save: re-encrypt in %d ms', [LSw.ElapsedMilliseconds])); - SaveLocalContainer(LBody); - try - SaveVault(LNew, FETag, FSyncPwd, BiometricAvailable, False, - FPinSealed, FKey, FKeySalt, FKeyIters); - except - on E: Exception do Log(' cache not written: ' + E.Message); - end; + // Coffre ne ici : il n'y a pas de depot, donc rien a publier. + LPublished := True; Log('save: written locally'); - LDone := True; end else begin LClient := TSyncClient.Create(FUrl, FToken); for LAttempt := 1 to 3 do begin - // PREMIER ESSAI SANS TIRER. On tient l'instantane et le sha qui va - // avec ; le tirage ne servait qu'a s'assurer de modifier la - // derniere version publiee, et le sha le prouve mieux : s'il n'est - // plus le bon, Gitea refuse. On ne retire donc que lorsqu'on a - // vraiment quelque chose a apprendre. - if (LAttempt = 1) and (FPlain <> '') and (FETag <> '') then - begin - Log('save: publishing against the snapshot in hand'); - LGet.Status := ssOK; - LGet.Container := ''; - LGet.ETag := FETag; - LPlain := FPlain; - end - else - begin - Log(Format('save: attempt %d - GET', [LAttempt])); - // Sans empreinte connue : on VEUT le corps, pas un 304. - LGet := LClient.Fetch; - if LGet.Status <> ssOK then - begin - LErr := Format('read failed (HTTP %d) %s', [LGet.HttpCode, LGet.Error]); - Break; - end; - LPlain := ''; - end; - - try - // UNE SEULE DERIVATION PAR ENREGISTREMENT : la variante Ex rend - // la cle, son sel et les iterations, que le rechiffrement - // reutilise. Deriver deux fois le meme mot de passe coutait une - // seconde fois quatre a six secondes, pour une cle equivalente. - LSw := TStopwatch.StartNew; - // LPlain deja pose quand on publie contre l'instantane en main. - if LPlain = '' then - LPlain := OpenContainer(LGet.Container, FSyncPwd); - LKey := FKey; LSalt := FKeySalt; LIter := FKeyIters; - LNew := APatch(LPlain); - Log(Format('save: decrypt+patch in %d ms', [LSw.ElapsedMilliseconds])); - except - on E: Exception do - begin - LErr := E.Message; - Break; - end; - end; - LSw := TStopwatch.StartNew; - LBody := EncryptContainerWithKey(LNew, LKey, LSalt, LIter); - Log(Format('save: re-encrypt in %d ms', [LSw.ElapsedMilliseconds])); - LSw := TStopwatch.StartNew; - LPut := LClient.Put(LBody, LGet.ETag, AComment); + LPut := LClient.Put(LBody, FETag, AComment); Log(Format('save: PUT %d chars in %d ms', [Length(LBody), LSw.ElapsedMilliseconds])); if LPut.Status = ssOK then begin - Log('save: published, new sha ' + LPut.ETag); - // Le cache local suit le distant, avec SON empreinte : les - // laisser diverger ferait retelecharger et redechiffrer a la - // prochaine ouverture pour retrouver ce qu'on vient d'ecrire. FETag := LPut.ETag; - try - SaveVault(LNew, FETag, FSyncPwd, BiometricAvailable, False, - FPinSealed, FKey, FKeySalt, FKeyIters); - except - on E: Exception do Log(' cache not written: ' + E.Message); - end; - LDone := True; + LPublished := True; + Log('save: published, new sha ' + LPut.ETag); + Break; + end; + if LPut.Status <> ssConflict then + begin + LErr := Format('HTTP %d %s', [LPut.HttpCode, LPut.Error]); Break; end; - if LPut.Status = ssConflict then + // LE DISTANT A BOUGE : ON FUSIONNE, on ne rejoue pas. + // + // Rejouer la seule modification en cours sur l'instantane frais + // etait juste tant qu'on ne pouvait ecrire qu'en ligne. Depuis + // qu'on enregistre hors connexion, plusieurs changements peuvent + // attendre ici : n'en rejouer qu'un perdrait les autres. + Log(Format('save: %d - another device published, merging', + [LPut.HttpCode])); + LGet := LClient.Fetch; + if LGet.Status <> ssOK then begin - Log(Format('save: %d - another device published, re-reading', - [LPut.HttpCode])); - LPlain := ''; - Continue; + LErr := Format('read failed (HTTP %d) %s', + [LGet.HttpCode, LGet.Error]); + Break; + end; + LNew := MergeSnapshots(LNew, OpenContainer(LGet.Container, FSyncPwd)); + LBody := EncryptContainerWithKey(LNew, FKey, FKeySalt, FKeyIters); + FETag := LGet.ETag; + // Le cache suit la fusion : sans cela il resterait sur notre + // version d'avant, et le prochain essai repartirait de la. + try + SaveVault(LNew, FETag, FSyncPwd, BiometricAvailable, False, + FPinSealed, FKey, FKeySalt, FKeyIters); + except + on E: Exception do Log(' cache not written: ' + E.Message); end; - - LErr := Format('write failed (HTTP %d) %s', [LPut.HttpCode, LPut.Error]); - Break; end; - - if (not LDone) and (LErr = '') then + if (not LPublished) and (LErr = '') then LErr := 'the remote kept changing - try again'; end; - - TThread.Queue(nil, - procedure - begin - if LDone then - begin - AdoptVault(LNew); - WebCall('PM.toast(' + JsArg(ALabel) + ')'); - end - else - WebCall('PM.toast(' + JsArg('Not saved - ' + LErr) + ')'); - end); except - // FILET. Le rechiffrement et le PUT sont hors du try qui couvre le - // dechiffrement : une exception y sortait sans un mot, et l'echec - // devenait indiscernable d'une lenteur. on E: Exception do begin LErr := E.ClassName + ': ' + E.Message; Log('save FAILED: ' + LErr); - TThread.Queue(nil, procedure - begin WebCall('PM.toast(' + JsArg('Not saved - ' + LErr) + ')'); end); end; end; + + TThread.Queue(nil, + procedure + begin + if not LPatched then + begin + // La modification elle-meme n'a pas abouti : rien a adopter, et + // c'est le seul cas ou l'utilisateur perd sa saisie. + WebCall('PM.toast(' + JsArg('Not saved - ' + LErr) + ')'); + Exit; + end; + AdoptVault(LNew); + FPending := not LPublished; + WebCall(Format('PM.setPending(%s)', + [LowerCase(BoolToStr(FPending, True))])); + if LPublished then + WebCall('PM.toast(' + JsArg(ALabel) + ')') + else + // DIRE LES DEUX : que c'est garde, et que le depot ne le sait pas + // encore. Un simple "echec" ferait retaper ce qui est deja la. + WebCall('PM.toast(' + JsArg( + ALabel + ' - saved here, not published yet') + ')'); + end); finally LClient.Free; // LA REMISE EN ETAT EST ICI, PAS DANS LE CHEMIN NOMINAL. Elle etait // apres la boucle : une exception au rechiffrement ou au PUT la // sautait, et la ligne restait sur "Saving..." pour toujours - sans // message, puisque le message aussi etait dans la partie sautee. - // Quoi qu'il arrive au-dessus, l'ecran redevient utilisable. TThread.Queue(nil, procedure begin @@ -1069,6 +1057,108 @@ end; /// /// L'ORDRE COMPTE. On publie d'abord, on efface ensuite : l'inverse perdrait le /// coffre entier si la publication echouait. +/// PUBLIER CE QUI ATTEND ICI. +/// +/// L'enregistrement hors connexion garde la modification dans le cache et +/// laisse le depot en arriere. Il faut donc un moment ou l'on rattrape, et +/// c'est la synchronisation - le geste par lequel on demande justement que +/// les deux cotes se reparlent. +/// +/// FUSION, PAS ECRASEMENT : le depot a pu bouger pendant qu'on etait hors +/// ligne. MergeSnapshots arbitre entree par entree sur `updated_at`, donc ce +/// qui a ete modifie ailleurs plus recemment survit a notre publication. +procedure TForm8.PublishPending; +begin + if not FPending then Exit; + if FBusy then Exit; + if (FPlain = '') or (FSyncPwd = '') then Exit; + SetBusy(True); + Status('Publishing what was saved offline...'); + + TThread.CreateAnonymousThread( + procedure + var + LClient: TSyncClient; + LGet, LPut: TSyncResult; + LMerged, LBody, LErr: string; + LAttempt: Integer; + LDone: Boolean; + begin + LClient := TSyncClient.Create(FUrl, FToken); + LDone := False; + LErr := ''; + LMerged := FPlain; + try + try + for LAttempt := 1 to 3 do + begin + LGet := LClient.Fetch; + if LGet.Status = ssOK then + begin + LMerged := MergeSnapshots(LMerged, + OpenContainer(LGet.Container, FSyncPwd)); + FETag := LGet.ETag; + end + else if LGet.Status = ssNotModified then + // Le depot est reste ou nous l'avions laisse : rien a fondre, il + // ne manque que notre publication. + FETag := LGet.ETag + else + begin + LErr := Format('read failed (HTTP %d) %s', [LGet.HttpCode, LGet.Error]); + Break; + end; + + LBody := EncryptContainerWithKey(LMerged, FKey, FKeySalt, FKeyIters); + LPut := LClient.Put(LBody, FETag, 'offline changes from phone'); + if LPut.Status = ssOK then + begin + FETag := LPut.ETag; + try + SaveVault(LMerged, FETag, FSyncPwd, BiometricAvailable, False, + FPinSealed, FKey, FKeySalt, FKeyIters); + except + on E: Exception do Log(' cache not written: ' + E.Message); + end; + LDone := True; + Break; + end; + if LPut.Status <> ssConflict then + begin + LErr := Format('HTTP %d %s', [LPut.HttpCode, LPut.Error]); + Break; + end; + end; + if (not LDone) and (LErr = '') then + LErr := 'the remote kept changing - try again'; + except + on E: Exception do LErr := E.ClassName + ': ' + E.Message; + end; + + TThread.Queue(nil, + procedure + begin + if LDone then + begin + FPending := False; + AdoptVault(LMerged); + Log('pending changes published, new sha ' + FETag); + WebCall('PM.setPending(false)'); + WebCall('PM.toast("Offline changes published")'); + end + else + begin + Log('pending changes not published: ' + LErr); + WebCall('PM.toast(' + JsArg('Still not published - ' + LErr) + ')'); + end; + end); + finally + LClient.Free; + TThread.Queue(nil, procedure begin SetBusy(False); end); + end; + end).Start; +end; + procedure TForm8.ReconcileWithRemote; begin if FBusy then Exit; @@ -1988,6 +2078,16 @@ begin Exit; end; + // CE QUI ATTEND ICI PART D'ABORD. Tirer avant de publier ecraserait notre + // cache par la version du depot, et la modification faite hors connexion + // disparaitrait sans un mot - exactement ce que l'enregistrement hors + // connexion promet d'eviter. + if FPending then + begin + PublishPending; + Exit; + end; + // MEME PORTE QUE « Ouvrir le coffre ». Sans cela la protection ne tenait // qu'a un bouton pres : synchroniser reconstruit le coffre depuis le serveur // et l'affiche, ce qui revient exactement au meme resultat sans passer par