feat(android): a vault born here can be given a repository
Until now the two situations never met: a local vault had no repository, a pulled vault had no local container. The day both exist, the rule "the local container is the authority" means saves never reach the repository - and the two diverge in silence, which is the worst outcome available. Sync now merges them once and then deletes the local container: the repository becomes the authority and the device behaves like any other. That makes divergence impossible rather than unlikely. Order matters - publish first, delete after; the reverse would lose the whole vault if the publish failed. The remote is the base, never the local. It carries what the phone cannot rebuild: attachment bodies, the avatar, folder colours, and whatever the desktop adds next. Starting from the local would amputate all of it, and the desktop would adopt the amputated version on its next pull. Arbitration is the desktop's, term for term: last write wins on updated_at, a tombstone beats an older entry but not one edited after it - that one was revived on purpose. Timestamps are ISO 8601 UTC on both sides, milliseconds included, so lexicographic order IS chronological order. MergeSelfCheck asserts the four rules at startup in debug. The merge never runs against a real vault before it has run there.
This commit is contained in:
+34
-3
@@ -730,9 +730,40 @@ téléphone qu'on voudra ensuite ouvrir sur le poste demandera un import.
|
|||||||
bouton en bas d'écran, en deux temps, qui se désarme seul au bout de quatre
|
bouton en bas d'écran, en deux temps, qui se désarme seul au bout de quatre
|
||||||
secondes — un bouton armé indéfiniment se fait toucher par mégarde bien plus
|
secondes — un bouton armé indéfiniment se fait toucher par mégarde bien plus
|
||||||
tard.
|
tard.
|
||||||
4. **Réconcilier avec la synchronisation** : un coffre né seul auquel on
|
4. **Réconcilier avec la synchronisation** — fait le 2026-08-21.
|
||||||
ajoute un dépôt. C'est là que la fusion revient, et elle a été évitée
|
|
||||||
jusqu'ici pour de bonnes raisons.
|
Jusqu'ici les deux situations ne se rencontraient pas : un coffre local
|
||||||
|
n'avait pas de dépôt, un coffre tiré d'un dépôt n'avait pas de conteneur
|
||||||
|
local. Le jour où les deux existent, la règle « le conteneur local fait
|
||||||
|
autorité » fait que les enregistrements **n'atteignent jamais le dépôt** —
|
||||||
|
et les deux divergent en silence, la pire des issues.
|
||||||
|
|
||||||
|
*Sync* fond donc les deux, **une fois**, puis **efface le conteneur local** :
|
||||||
|
le dépôt devient l'autorité et l'appareil se comporte dès lors comme
|
||||||
|
n'importe quel autre. C'est ce qui rend la divergence impossible plutôt
|
||||||
|
qu'improbable. L'ordre compte — on publie d'abord, on efface ensuite ;
|
||||||
|
l'inverse perdrait le coffre entier si la publication échouait.
|
||||||
|
|
||||||
|
**Le distant sert de base, jamais le local.** Il porte ce que le téléphone
|
||||||
|
ne sait pas reconstruire : le contenu des pièces jointes, l'avatar, la
|
||||||
|
couleur des dossiers, et ce que le poste ajoutera demain. Repartir du local
|
||||||
|
amputerait tout cela, et le poste adopterait la version amputée au tirage
|
||||||
|
suivant — la perte silencieuse contre laquelle `PMA.VaultPatch` existe.
|
||||||
|
|
||||||
|
L'arbitrage est celui du poste, terme à terme : dernier écrit gagnant sur
|
||||||
|
`updated_at`, une pierre tombale l'emporte sur une entrée plus ancienne
|
||||||
|
qu'elle, mais **pas** sur une entrée modifiée après — celle-là a été
|
||||||
|
ressuscitée volontairement. Les horodatages étant de l'ISO 8601 UTC des deux
|
||||||
|
côtés, millisecondes comprises, l'ordre lexicographique **est** l'ordre
|
||||||
|
chronologique : aucune date à analyser.
|
||||||
|
|
||||||
|
Dépôt vide : rien à fusionner, on publie. Le 404 de Gitea étant ambigu —
|
||||||
|
fichier absent ou dépôt qu'on n'a pas le droit de voir — on ne cherche pas à
|
||||||
|
trancher : publier tranche, un jeton sans droit échouant au PUT avant que
|
||||||
|
rien n'ait été effacé.
|
||||||
|
|
||||||
|
`MergeSelfCheck` vérifie les quatre règles au démarrage en débogage. La
|
||||||
|
fusion ne tourne jamais contre un vrai coffre avant d'avoir tourné là.
|
||||||
|
|
||||||
### Vérifié : le poste reprend ce que le téléphone crée — 2026-08-21
|
### Vérifié : le poste reprend ce que le téléphone crée — 2026-08-21
|
||||||
|
|
||||||
|
|||||||
@@ -147,6 +147,9 @@ procedure ClearVault;
|
|||||||
function HasLocalContainer: Boolean;
|
function HasLocalContainer: Boolean;
|
||||||
function LoadLocalContainer: string;
|
function LoadLocalContainer: string;
|
||||||
procedure SaveLocalContainer(const AContainer: string);
|
procedure SaveLocalContainer(const AContainer: string);
|
||||||
|
/// Efface le conteneur local. A n'appeler QU'APRES avoir publie son contenu :
|
||||||
|
/// c'est le geste qui fait passer l'autorite au depot, et il est sans retour.
|
||||||
|
procedure DropLocalContainer;
|
||||||
|
|
||||||
/// Chemin du fichier, pour le journal et les tests.
|
/// Chemin du fichier, pour le journal et les tests.
|
||||||
function VaultPath: string;
|
function VaultPath: string;
|
||||||
@@ -250,6 +253,12 @@ begin
|
|||||||
TFile.WriteAllText(LocalContainerPath, AContainer, TEncoding.UTF8);
|
TFile.WriteAllText(LocalContainerPath, AContainer, TEncoding.UTF8);
|
||||||
end;
|
end;
|
||||||
|
|
||||||
|
procedure DropLocalContainer;
|
||||||
|
begin
|
||||||
|
if TFile.Exists(LocalContainerPath) then
|
||||||
|
TFile.Delete(LocalContainerPath);
|
||||||
|
end;
|
||||||
|
|
||||||
function VaultPath: string;
|
function VaultPath: string;
|
||||||
begin
|
begin
|
||||||
// GetHomePath sur Android = /data/data/<paquet>/files, prive a l'app.
|
// GetHomePath sur Android = /data/data/<paquet>/files, prive a l'app.
|
||||||
|
|||||||
@@ -65,6 +65,25 @@ function AddEntry(const APlainJson, ATitle, ASite, AUsername, APassword,
|
|||||||
/// une suppression qui s'y arreterait ne se propagerait donc nulle part.
|
/// une suppression qui s'y arreterait ne se propagerait donc nulle part.
|
||||||
function DeleteEntry(const APlainJson, AUuid: string): string;
|
function DeleteEntry(const APlainJson, AUuid: string): string;
|
||||||
|
|
||||||
|
/// Fond l'instantane LOCAL dans l'instantane DISTANT et rend le resultat.
|
||||||
|
///
|
||||||
|
/// LE DISTANT SERT DE BASE, jamais le local. Il porte ce que le telephone ne
|
||||||
|
/// sait pas reconstruire - le contenu des pieces jointes, l'avatar du compte,
|
||||||
|
/// la couleur des dossiers, et tout ce que le poste ajoutera demain. Repartir
|
||||||
|
/// du local amputerait tout cela, et le poste adopterait la version amputee au
|
||||||
|
/// tirage suivant : la meme perte silencieuse et definitive contre laquelle
|
||||||
|
/// cette unite entiere est ecrite.
|
||||||
|
///
|
||||||
|
/// L'arbitrage est celui du poste, terme a terme : dernier ecrit gagnant sur
|
||||||
|
/// `updated_at`, et une pierre tombale l'emporte sur une entree plus ancienne
|
||||||
|
/// qu'elle. Les horodatages sont de l'ISO 8601 UTC des deux cotes,
|
||||||
|
/// millisecondes comprises, donc l'ordre lexicographique EST l'ordre
|
||||||
|
/// chronologique - aucune date a analyser.
|
||||||
|
///
|
||||||
|
/// Une entree sans uuid est ignoree : rien ne permettrait de la reconnaitre
|
||||||
|
/// chez l'autre, et l'ajouter en creerait un doublon a chaque fusion.
|
||||||
|
function MergeSnapshots(const ALocalJson, ARemoteJson: string): string;
|
||||||
|
|
||||||
/// Horodatage UTC au format que tout le reste emploie (toISOString cote JS).
|
/// Horodatage UTC au format que tout le reste emploie (toISOString cote JS).
|
||||||
/// MELANGER heure locale et UTC a deja decale l'arbitrage de la fusion une
|
/// MELANGER heure locale et UTC a deja decale l'arbitrage de la fusion une
|
||||||
/// fois : il n'y a qu'une source d'heure ici, et elle est en UTC.
|
/// fois : il n'y a qu'une source d'heure ici, et elle est en UTC.
|
||||||
@@ -100,6 +119,114 @@ begin
|
|||||||
end;
|
end;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
|
/// L'heure de suppression retenue pour un uuid, ou '' s'il n'en a pas.
|
||||||
|
/// La plus recente des deux listes : une entree ressuscitee puis resupprimee
|
||||||
|
/// doit rester supprimee.
|
||||||
|
function TombstoneTime(const AStones: TJSONArray; const AUuid: string): string;
|
||||||
|
var
|
||||||
|
I: Integer;
|
||||||
|
LObj: TJSONObject;
|
||||||
|
LWhen: string;
|
||||||
|
begin
|
||||||
|
Result := '';
|
||||||
|
if AStones = nil then Exit;
|
||||||
|
for I := 0 to AStones.Count - 1 do
|
||||||
|
begin
|
||||||
|
if not (AStones.Items[I] is TJSONObject) then Continue;
|
||||||
|
LObj := AStones.Items[I] as TJSONObject;
|
||||||
|
if LObj.GetValue<string>('uuid', '') <> AUuid then Continue;
|
||||||
|
LWhen := LObj.GetValue<string>('deleted_at', '');
|
||||||
|
if LWhen > Result then Result := LWhen;
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
|
||||||
|
/// Copie profonde d'un objet JSON : le reparser est le moyen le plus court, et
|
||||||
|
/// le seul qui n'oublie aucun champ.
|
||||||
|
function CloneJson(const AObj: TJSONObject): TJSONObject;
|
||||||
|
begin
|
||||||
|
Result := TJSONObject.ParseJSONValue(AObj.ToJSON) as TJSONObject;
|
||||||
|
end;
|
||||||
|
|
||||||
|
function MergeSnapshots(const ALocalJson, ARemoteJson: string): string;
|
||||||
|
var
|
||||||
|
LLocal, LMerged: TJSONObject;
|
||||||
|
LLocalEntries, LOutEntries, LLocalStones, LOutStones: TJSONArray;
|
||||||
|
LEntry, LOther: TJSONObject;
|
||||||
|
I, J: Integer;
|
||||||
|
LUuid, LMine, LTheirs, LKilled: string;
|
||||||
|
LFound: Boolean;
|
||||||
|
begin
|
||||||
|
LLocal := TJSONObject.ParseJSONValue(ALocalJson) as TJSONObject;
|
||||||
|
if LLocal = nil then raise EPMAPatch.Create('Instantane local illisible');
|
||||||
|
try
|
||||||
|
LMerged := TJSONObject.ParseJSONValue(ARemoteJson) as TJSONObject;
|
||||||
|
if LMerged = nil then raise EPMAPatch.Create('Instantane distant illisible');
|
||||||
|
try
|
||||||
|
if not LMerged.TryGetValue<TJSONArray>('entries', LOutEntries) then
|
||||||
|
raise EPMAPatch.Create('Instantane distant sans tableau "entries"');
|
||||||
|
if not LMerged.TryGetValue<TJSONArray>('tombstones', LOutStones) then
|
||||||
|
begin
|
||||||
|
LOutStones := TJSONArray.Create;
|
||||||
|
LMerged.AddPair('tombstones', LOutStones);
|
||||||
|
end;
|
||||||
|
|
||||||
|
// 1. Les pierres tombales locales rejoignent celles du distant. Avant les
|
||||||
|
// entrees : l'etape suivante s'en sert pour arbitrer.
|
||||||
|
if LLocal.TryGetValue<TJSONArray>('tombstones', LLocalStones) then
|
||||||
|
for I := 0 to LLocalStones.Count - 1 do
|
||||||
|
if LLocalStones.Items[I] is TJSONObject then
|
||||||
|
LOutStones.AddElement(CloneJson(LLocalStones.Items[I] as TJSONObject));
|
||||||
|
|
||||||
|
// 2. Chaque entree locale : absente du distant on l'ajoute, presente on
|
||||||
|
// garde la plus recente des deux.
|
||||||
|
if LLocal.TryGetValue<TJSONArray>('entries', LLocalEntries) then
|
||||||
|
for I := 0 to LLocalEntries.Count - 1 do
|
||||||
|
begin
|
||||||
|
if not (LLocalEntries.Items[I] is TJSONObject) then Continue;
|
||||||
|
LEntry := LLocalEntries.Items[I] as TJSONObject;
|
||||||
|
LUuid := LEntry.GetValue<string>('uuid', '');
|
||||||
|
if LUuid = '' then Continue;
|
||||||
|
LMine := LEntry.GetValue<string>('updated_at', '');
|
||||||
|
|
||||||
|
LFound := False;
|
||||||
|
for J := 0 to LOutEntries.Count - 1 do
|
||||||
|
begin
|
||||||
|
if not (LOutEntries.Items[J] is TJSONObject) then Continue;
|
||||||
|
LOther := LOutEntries.Items[J] as TJSONObject;
|
||||||
|
if LOther.GetValue<string>('uuid', '') <> LUuid then Continue;
|
||||||
|
LFound := True;
|
||||||
|
LTheirs := LOther.GetValue<string>('updated_at', '');
|
||||||
|
if LMine > LTheirs then
|
||||||
|
begin
|
||||||
|
LOutEntries.Remove(J).Free;
|
||||||
|
LOutEntries.AddElement(CloneJson(LEntry));
|
||||||
|
end;
|
||||||
|
Break;
|
||||||
|
end;
|
||||||
|
if not LFound then LOutEntries.AddElement(CloneJson(LEntry));
|
||||||
|
end;
|
||||||
|
|
||||||
|
// 3. Une pierre tombale posterieure a l'entree la retire. Posterieure
|
||||||
|
// SEULEMENT : une entree modifiee apres coup a ete ressuscitee
|
||||||
|
// volontairement, et la retuer effacerait ce geste.
|
||||||
|
for J := LOutEntries.Count - 1 downto 0 do
|
||||||
|
begin
|
||||||
|
if not (LOutEntries.Items[J] is TJSONObject) then Continue;
|
||||||
|
LOther := LOutEntries.Items[J] as TJSONObject;
|
||||||
|
LKilled := TombstoneTime(LOutStones, LOther.GetValue<string>('uuid', ''));
|
||||||
|
if (LKilled <> '') and (LKilled > LOther.GetValue<string>('updated_at', '')) then
|
||||||
|
LOutEntries.Remove(J).Free;
|
||||||
|
end;
|
||||||
|
|
||||||
|
Result := LMerged.ToJSON;
|
||||||
|
finally
|
||||||
|
LMerged.Free;
|
||||||
|
end;
|
||||||
|
finally
|
||||||
|
LLocal.Free;
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
|
||||||
function DeleteEntry(const APlainJson, AUuid: string): string;
|
function DeleteEntry(const APlainJson, AUuid: string): string;
|
||||||
var
|
var
|
||||||
LRoot, LEntry, LStone: TJSONObject;
|
LRoot, LEntry, LStone: TJSONObject;
|
||||||
|
|||||||
+223
@@ -255,6 +255,7 @@ type
|
|||||||
{$IFDEF DEBUG}
|
{$IFDEF DEBUG}
|
||||||
procedure CryptoSelfCheck;
|
procedure CryptoSelfCheck;
|
||||||
procedure PatchSelfCheck;
|
procedure PatchSelfCheck;
|
||||||
|
procedure MergeSelfCheck;
|
||||||
{$ENDIF}
|
{$ENDIF}
|
||||||
procedure OpenEntry(AVaultIndex: Integer);
|
procedure OpenEntry(AVaultIndex: Integer);
|
||||||
/// Ouvre un conteneur en evitant la derivation quand la cle en main
|
/// Ouvre un conteneur en evitant la derivation quand la cle en main
|
||||||
@@ -271,6 +272,8 @@ type
|
|||||||
procedure CreateEntry(const ATitle, ASite, AUser, APwd, AKind: string);
|
procedure CreateEntry(const ATitle, ASite, AUser, APwd, AKind: string);
|
||||||
/// Supprime l'entree ouverte, pierre tombale comprise. Sans retour.
|
/// Supprime l'entree ouverte, pierre tombale comprise. Sans retour.
|
||||||
procedure DeleteCurrentEntry;
|
procedure DeleteCurrentEntry;
|
||||||
|
/// Fond le coffre ne ici dans le depot, puis passe l'autorite au depot.
|
||||||
|
procedure ReconcileWithRemote;
|
||||||
procedure ReopenEntryByUuid(const AUuid: string);
|
procedure ReopenEntryByUuid(const AUuid: string);
|
||||||
procedure OnClipTimer(Sender: TObject);
|
procedure OnClipTimer(Sender: TObject);
|
||||||
procedure OnDeadline(Sender: TObject);
|
procedure OnDeadline(Sender: TObject);
|
||||||
@@ -598,6 +601,7 @@ begin
|
|||||||
TFile.WriteAllText(LPath, LContainer, TEncoding.UTF8);
|
TFile.WriteAllText(LPath, LContainer, TEncoding.UTF8);
|
||||||
Log('selfcheck: container written for cross-check');
|
Log('selfcheck: container written for cross-check');
|
||||||
PatchSelfCheck;
|
PatchSelfCheck;
|
||||||
|
MergeSelfCheck;
|
||||||
except
|
except
|
||||||
on E: Exception do
|
on E: Exception do
|
||||||
Log('selfcheck FAILED: ' + E.ClassName + ' - ' + E.Message);
|
Log('selfcheck FAILED: ' + E.ClassName + ' - ' + E.Message);
|
||||||
@@ -654,6 +658,82 @@ end;
|
|||||||
/// PAR UUID ET NON PAR INDICE : le coffre vient d'etre remplace par celui du
|
/// PAR UUID ET NON PAR INDICE : le coffre vient d'etre remplace par celui du
|
||||||
/// serveur, et rien ne garantit que la meme entree occupe le meme rang - un
|
/// serveur, et rien ne garantit que la meme entree occupe le meme rang - un
|
||||||
/// tri se fait sur le nom affiche, qu'une autre machine a pu changer.
|
/// tri se fait sur le nom affiche, qu'une autre machine a pu changer.
|
||||||
|
/// Verifie les QUATRE regles de la fusion, celles dont un ecart se paierait en
|
||||||
|
/// entrees perdues ou ressuscitees. Elle ne tourne jamais contre un vrai coffre
|
||||||
|
/// avant d'avoir tourne ici.
|
||||||
|
procedure TForm8.MergeSelfCheck;
|
||||||
|
const
|
||||||
|
LOCAL =
|
||||||
|
'{"entries":[' +
|
||||||
|
'{"uuid":"only-local","updated_at":"2026-01-01T00:00:00.000Z"},' +
|
||||||
|
'{"uuid":"both","updated_at":"2026-06-01T00:00:00.000Z","title":"newer here"},' +
|
||||||
|
'{"uuid":"killed-there","updated_at":"2026-01-01T00:00:00.000Z"},' +
|
||||||
|
'{"uuid":"revived","updated_at":"2026-07-01T00:00:00.000Z"}],' +
|
||||||
|
'"tombstones":[]}';
|
||||||
|
REMOTE =
|
||||||
|
'{"avatar_b64":"KEEPME","entries":[' +
|
||||||
|
'{"uuid":"only-remote","updated_at":"2026-01-01T00:00:00.000Z",' +
|
||||||
|
'"attachments":[{"content_b64":"KEEPTOO"}]},' +
|
||||||
|
'{"uuid":"both","updated_at":"2026-02-01T00:00:00.000Z","title":"older"},' +
|
||||||
|
'{"uuid":"killed-there","updated_at":"2026-01-01T00:00:00.000Z"},' +
|
||||||
|
'{"uuid":"revived","updated_at":"2026-01-01T00:00:00.000Z"}],' +
|
||||||
|
'"tombstones":[' +
|
||||||
|
'{"uuid":"killed-there","deleted_at":"2026-05-01T00:00:00.000Z"},' +
|
||||||
|
'{"uuid":"revived","deleted_at":"2026-05-01T00:00:00.000Z"}]}';
|
||||||
|
|
||||||
|
function Find(const AArr: TJSONArray; const AUuid: string): TJSONObject;
|
||||||
|
var
|
||||||
|
I: Integer;
|
||||||
|
begin
|
||||||
|
Result := nil;
|
||||||
|
for I := 0 to AArr.Count - 1 do
|
||||||
|
if (AArr.Items[I] is TJSONObject) and
|
||||||
|
((AArr.Items[I] as TJSONObject).GetValue<string>('uuid', '') = AUuid) then
|
||||||
|
Exit(AArr.Items[I] as TJSONObject);
|
||||||
|
end;
|
||||||
|
|
||||||
|
var
|
||||||
|
LOut: string;
|
||||||
|
LRoot: TJSONObject;
|
||||||
|
LArr: TJSONArray;
|
||||||
|
LOk: Boolean;
|
||||||
|
begin
|
||||||
|
try
|
||||||
|
LOut := MergeSnapshots(LOCAL, REMOTE);
|
||||||
|
LRoot := TJSONObject.ParseJSONValue(LOut) as TJSONObject;
|
||||||
|
if LRoot = nil then
|
||||||
|
begin
|
||||||
|
Log('merge selfcheck: result is not JSON');
|
||||||
|
Exit;
|
||||||
|
end;
|
||||||
|
try
|
||||||
|
LArr := LRoot.GetValue('entries') as TJSONArray;
|
||||||
|
LOk := True;
|
||||||
|
// Ce que le distant seul portait survit, contenu des pieces jointes
|
||||||
|
// compris - c'est la raison pour laquelle il sert de base.
|
||||||
|
LOk := LOk and (Find(LArr, 'only-remote') <> nil);
|
||||||
|
LOk := LOk and (LRoot.GetValue<string>('avatar_b64', '') = 'KEEPME');
|
||||||
|
// Ce que le local seul portait est ajoute.
|
||||||
|
LOk := LOk and (Find(LArr, 'only-local') <> nil);
|
||||||
|
// Meme uuid des deux cotes : le plus recent gagne.
|
||||||
|
LOk := LOk and (Find(LArr, 'both') <> nil) and
|
||||||
|
(Find(LArr, 'both').GetValue<string>('title', '') = 'newer here');
|
||||||
|
// Pierre tombale posterieure a l'entree : elle disparait.
|
||||||
|
LOk := LOk and (Find(LArr, 'killed-there') = nil);
|
||||||
|
// Pierre tombale ANTERIEURE : l'entree a ete ressuscitee volontairement,
|
||||||
|
// et la retuer effacerait ce geste.
|
||||||
|
LOk := LOk and (Find(LArr, 'revived') <> nil);
|
||||||
|
if LOk then Log('merge selfcheck: the four rules hold')
|
||||||
|
else Log('merge selfcheck: FAILED - ' + Copy(LOut, 1, 400));
|
||||||
|
finally
|
||||||
|
LRoot.Free;
|
||||||
|
end;
|
||||||
|
except
|
||||||
|
on E: Exception do
|
||||||
|
Log('merge selfcheck FAILED: ' + E.ClassName + ' - ' + E.Message);
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
|
||||||
{$ENDIF}
|
{$ENDIF}
|
||||||
|
|
||||||
procedure TForm8.ReopenEntryByUuid(const AUuid: string);
|
procedure TForm8.ReopenEntryByUuid(const AUuid: string);
|
||||||
@@ -953,6 +1033,139 @@ end;
|
|||||||
/// au prochain tirage.
|
/// au prochain tirage.
|
||||||
///
|
///
|
||||||
/// La page demande confirmation avant d'en arriver ici.
|
/// La page demande confirmation avant d'en arriver ici.
|
||||||
|
/// RECONCILIER - un coffre ne sur le telephone auquel on donne un depot.
|
||||||
|
///
|
||||||
|
/// Jusqu'ici les deux situations ne se rencontraient pas : un coffre local
|
||||||
|
/// n'avait pas de depot, un coffre tire d'un depot n'avait pas de conteneur
|
||||||
|
/// local. Le jour ou les deux existent, la regle "le conteneur local fait
|
||||||
|
/// autorite" fait que les enregistrements n'atteignent jamais le depot - et les
|
||||||
|
/// deux divergent en silence, ce qui est la pire des issues.
|
||||||
|
///
|
||||||
|
/// La fusion se fait UNE FOIS, puis le conteneur local est efface : le depot
|
||||||
|
/// devient l'autorite, et l'appareil se comporte des lors comme n'importe quel
|
||||||
|
/// autre. C'est ce qui rend la divergence impossible plutot qu'improbable.
|
||||||
|
///
|
||||||
|
/// L'ORDRE COMPTE. On publie d'abord, on efface ensuite : l'inverse perdrait le
|
||||||
|
/// coffre entier si la publication echouait.
|
||||||
|
procedure TForm8.ReconcileWithRemote;
|
||||||
|
begin
|
||||||
|
if FBusy then Exit;
|
||||||
|
if not HasLocalContainer then
|
||||||
|
begin
|
||||||
|
Status('No local vault to reconcile');
|
||||||
|
Exit;
|
||||||
|
end;
|
||||||
|
if FSyncPwd = '' then
|
||||||
|
begin
|
||||||
|
Status('Open the vault first');
|
||||||
|
Exit;
|
||||||
|
end;
|
||||||
|
SetBusy(True);
|
||||||
|
Status('Reconciling...');
|
||||||
|
|
||||||
|
TThread.CreateAnonymousThread(
|
||||||
|
procedure
|
||||||
|
var
|
||||||
|
LClient: TSyncClient;
|
||||||
|
LGet, LPut: TSyncResult;
|
||||||
|
LLocal, LRemote, LMerged, LBody, LErr: string;
|
||||||
|
LAttempt: Integer;
|
||||||
|
LDone, LFirstPush: Boolean;
|
||||||
|
begin
|
||||||
|
LClient := TSyncClient.Create(FUrl, FToken);
|
||||||
|
LDone := False;
|
||||||
|
LFirstPush := False;
|
||||||
|
LErr := '';
|
||||||
|
try
|
||||||
|
try
|
||||||
|
LLocal := OpenContainer(LoadLocalContainer, FSyncPwd);
|
||||||
|
|
||||||
|
for LAttempt := 1 to 3 do
|
||||||
|
begin
|
||||||
|
LGet := LClient.Fetch;
|
||||||
|
if LGet.Status = ssNoSnapshot then
|
||||||
|
begin
|
||||||
|
// Depot vide : rien a fusionner, seulement a publier. Le cas le
|
||||||
|
// plus frequent - on donne un depot neuf a un coffre.
|
||||||
|
//
|
||||||
|
// 404 EST AMBIGU chez Gitea : fichier absent, ou depot prive qu'on
|
||||||
|
// n'a pas le droit de voir. On ne cherche pas a trancher ici -
|
||||||
|
// publier tranche pour nous, puisqu'un jeton sans droit echouera au
|
||||||
|
// PUT et que rien n'aura ete efface d'ici la.
|
||||||
|
LMerged := LLocal;
|
||||||
|
LGet.ETag := '';
|
||||||
|
LFirstPush := True;
|
||||||
|
end
|
||||||
|
else if LGet.Status = ssOK then
|
||||||
|
begin
|
||||||
|
LRemote := OpenContainer(LGet.Container, FSyncPwd);
|
||||||
|
LMerged := MergeSnapshots(LLocal, LRemote);
|
||||||
|
end
|
||||||
|
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, LGet.ETag, 'reconciled from phone');
|
||||||
|
if LPut.Status = ssOK then
|
||||||
|
begin
|
||||||
|
FETag := LPut.ETag;
|
||||||
|
LDone := True;
|
||||||
|
Break;
|
||||||
|
end;
|
||||||
|
if LPut.Status = ssConflict then Continue;
|
||||||
|
LErr := Format('write failed (HTTP %d) %s', [LPut.HttpCode, LPut.Error]);
|
||||||
|
Break;
|
||||||
|
end;
|
||||||
|
|
||||||
|
if (not LDone) and (LErr = '') then
|
||||||
|
LErr := 'the remote kept changing - try again';
|
||||||
|
|
||||||
|
if LDone then
|
||||||
|
begin
|
||||||
|
// PUBLIE, DONC PLUS BESOIN DU CONTENEUR LOCAL. C'est ce geste qui
|
||||||
|
// passe l'autorite au depot ; le faire avant la publication aurait
|
||||||
|
// perdu le coffre.
|
||||||
|
DropLocalContainer;
|
||||||
|
try
|
||||||
|
SaveVault(LMerged, FETag, FSyncPwd, BiometricAvailable, False,
|
||||||
|
FPinSealed, FKey, FKeySalt, FKeyIters);
|
||||||
|
except
|
||||||
|
on E: Exception do Log(' cache not written: ' + E.Message);
|
||||||
|
end;
|
||||||
|
if LFirstPush then Log('Reconcile: repository was empty - published')
|
||||||
|
else Log('Reconcile: merged with the repository');
|
||||||
|
end;
|
||||||
|
|
||||||
|
TThread.Queue(nil,
|
||||||
|
procedure
|
||||||
|
begin
|
||||||
|
if LDone then
|
||||||
|
begin
|
||||||
|
AdoptVault(LMerged);
|
||||||
|
WebCall('PM.toast("Reconciled - the repository is now the source")');
|
||||||
|
end
|
||||||
|
else
|
||||||
|
WebCall('PM.toast(' + JsArg('Not reconciled - ' + LErr) + ')');
|
||||||
|
end);
|
||||||
|
except
|
||||||
|
on E: Exception do
|
||||||
|
begin
|
||||||
|
LErr := E.ClassName + ': ' + E.Message;
|
||||||
|
Log('Reconcile FAILED: ' + LErr);
|
||||||
|
TThread.Queue(nil, procedure
|
||||||
|
begin WebCall('PM.toast(' + JsArg('Not reconciled - ' + LErr) + ')'); end);
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
finally
|
||||||
|
LClient.Free;
|
||||||
|
TThread.Queue(nil, procedure begin SetBusy(False); ShowUnlock; end);
|
||||||
|
end;
|
||||||
|
end).Start;
|
||||||
|
end;
|
||||||
|
|
||||||
procedure TForm8.DeleteCurrentEntry;
|
procedure TForm8.DeleteCurrentEntry;
|
||||||
var
|
var
|
||||||
LUuid, LTitle: string;
|
LUuid, LTitle: string;
|
||||||
@@ -1610,6 +1823,16 @@ begin
|
|||||||
end;
|
end;
|
||||||
WebCall('PM.clearLog()');
|
WebCall('PM.clearLog()');
|
||||||
|
|
||||||
|
// UN COFFRE NE ICI A QUI L'ON DONNE UN DEPOT : il faut fondre les deux, pas
|
||||||
|
// ecraser l'un par l'autre. Sans cela le conteneur local resterait
|
||||||
|
// l'autorite, les enregistrements n'atteindraient jamais le depot, et les
|
||||||
|
// deux divergeraient en silence.
|
||||||
|
if HasLocalContainer then
|
||||||
|
begin
|
||||||
|
ReconcileWithRemote;
|
||||||
|
Exit;
|
||||||
|
end;
|
||||||
|
|
||||||
// MEME PORTE QUE « Ouvrir le coffre ». Sans cela la protection ne tenait
|
// MEME PORTE QUE « Ouvrir le coffre ». Sans cela la protection ne tenait
|
||||||
// qu'a un bouton pres : synchroniser reconstruit le coffre depuis le serveur
|
// 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
|
// et l'affiche, ce qui revient exactement au meme resultat sans passer par
|
||||||
|
|||||||
Reference in New Issue
Block a user