Show, Cancel and Save shared their row with the input field, and three words
left barely enough room to read what one types - the very crowding that made
the row overflow in the first place. A cross and a tick read without
translation.
The tick gives way to a turning arc while saving. The word "Saving..." said
the wait was real; a motionless tick would say nothing, and it is exactly
during that second or two that one presses again.
The form's Save and Delete entry keep their words: they are not inline
controls sharing a row, and the second is the only action with no way back.
With an icon inside, the tap target is the svg or one of its strokes, never
the button. Reading the attribute off the target returns null and the handler
falls through to its default - here, opening the entry instead of copying.
Half the taps worked, which is the worst kind of failure to diagnose.
Three translucent pills per list row weighed more than what they pointed at.
The drawing alone carries it.
The target stays 44 px: that is what a finger expects, and it does not need to
be visible to exist. The press feedback moves from the background to the
stroke, since there is no background left to dim.
Copying a password or a username opened the entry instead - about half the
time, which is the worst kind of failure to diagnose. Since those buttons
carry an icon, the tap target is the <svg> or one of its strokes, so reading
the attribute off the target returned null and the handler fell through to
"open". A finger landing sometimes on the glyph and sometimes on the margin
made it look random. Both handlers now resolve to the button first.
The new-entry form gets a folder, like the editor: an entry filed from the
start saves having to move it.
And a folder can now be created, not only chosen. Filing an entry under a
name is not enough - it is `folders` that fills the desktop's column, and a
name missing from it leaves the entry in a folder nothing displays, looking
lost. The two now go together, in one place, for both editing and creation.
'All' is never registered: it is the default, not a folder.
The folder had no row of its own - it shared "Folder / tags" with the tags,
so neither could be touched. It is now CHOSEN from the known folders rather
than typed: a typo would file the entry under a folder nothing displays,
here or on the desktop, and it would look lost. Tags stay read-only because
they are an array in the snapshot, and writing one as a string would break
its shape for the desktop.
The 2FA secret gets its own masked row. The code has had a panel for a long
time, but it is computed and renewed every thirty seconds; the secret is what
one sets, changes and removes, and nothing allowed that from the phone.
User, Pass, Copy, Show and Edit become drawings. Four words on a list row left
little room for the entry's name and had to be re-read every time. Every icon
carries an aria-label: replacing a word with a drawing must not take the
information away from someone listening to a screen reader.
Decided: it is outside the repository, it no longer enters a release build,
and the token it carries opens a test repository only. Keeping it saves
retyping forty characters at every reinstall of the bench.
What would warrant revocation is a token granting access to anything beyond
that repository - not this one. Recording the decision so it stops coming
back as an open item.
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.
The manifest asked for fine and coarse location, camera, phone, phone state
and external storage - Delphi's defaults, none of them used. On a password
vault those are exactly the wrong signals: whoever reads the app's listing saw
a request for position and camera with no possible explanation. The list is
written by hand now.
A trap on the way: naming the substitution marker inside the comment that
explains its removal is enough to bring it back. The substitution is textual
and does not care that it lands in a comment.
The bench credentials are no longer compiled into a release - the local
include is debug-only. Verified in the binary in UTF-8 AND UTF-16: Delphi
stores strings as UTF-16, so an ASCII grep returned zero on both sides and
proved nothing.
The log leaves the home screen in release without leaving the application: a
1.5 s press on the status line brings it back. Nothing secret is in it, but a
permanent Log button is development noise, and diagnostics must stay reachable
for the day something goes wrong remotely.
And the Release configuration did not compile at all, which nobody knew: the
two self-checks had their declarations under IFDEF DEBUG and their bodies
outside. Invisible as long as one only ever builds Debug.
Signing is what remains, and the keystore password is the user's to hold.
Conflict detected, fresh snapshot pulled, the change applied ON TOP of it,
publish accepted. Nothing clobbered - which is the whole point of dropping
the pre-publish fetch.
The second attempt also derived the key again, 4397 ms: the desktop had
re-encrypted its container with a fresh salt, so the key in hand no longer
fitted. That is exactly the case the reuse guards against, seen firing for
the first time. It costs one derivation per desktop publish, no more.
The retry path finally ran, and did not recognise what it was handed. A
desktop publishing between our read and our write earns a 422, not a 409, so
the save failed instead of retrying - which is exactly what a never-exercised
path hides: it was written, it compiled, it was even commented, and it did
not fire.
Gitea answers 409 when the sha no longer matches and 422 when it is missing
or the request is judged unprocessable, "file already exists" among them.
Both say the same thing to us: the sha is stale. Re-reading and retrying is
right for both and cannot clobber anything - the retry publishes against the
fresh sha. A genuinely malformed body would fail again and give up after
three tries, carrying Gitea's own words.
The log now prints which code it was, rather than assuming.
The desktop, after syncing: pulled 4 new, 1 updated, 1 deleted, pushed 11
entries. Create, edit and delete in one line, seen from the side that did not
perform them. The deletion is the one that mattered - vanishing locally proved
nothing, the tombstone had to travel.
Steps 1 to 3 of section 7 are closed. The 409 is what remains.
It was introduced with two readers and no writer. Cancel called
renderDetail(null), which threw on d.rows and did nothing at all - and
editDone, which uses it to hand control back after a refusal, was dead the
same way. That is why the successive fixes for "Cancel does not respond"
changed nothing: they all ended in the same silent throw.
Assigned where a detail is drawn, which is the one place that knows it.
It went from 0000 to 00000000 with nobody deciding it. The typed code and the
code the cache is sealed under were the same variable, so every write of the
cache re-sealed under whatever sat in the field - a sync, which asks for no
code at all, was enough to change it, silently.
They are two things now. Only opening the vault touches the seal, because
that is the one moment we know the current code is known: the decryption just
proved it. Everything else - sync, background refresh, saving an entry -
rewrites the cache under the code already in place.
Hence a second field, shown only once a code exists: the current one must open
the vault for the new one to take. Without it this fix would have locked the
device under the code set by accident, which is exactly the harm it is meant
to prevent.
Both, or nothing: the row leaves the snapshot AND a tombstone takes its
place. Removing the row alone does not delete - the desktop still holds its
own copy, and the next pull would bring it back as an entry we never had.
The time matters too: the desktop's arbitration keeps an entry edited AFTER
the deletion rather than killing it a second time.
No trash on the phone, deliberately. The desktop's is a `deleted` flag that
does not travel in sync, so a deletion stopping there would propagate
nowhere. Hence a button at the very bottom, in two taps, that disarms itself
after four seconds - a button left armed gets touched by accident much
later, and this is the one action with no way back.
The confirmation is in the page because the engine cannot offer one: a
JavaScript prompt needs a WebChromeClient FMX does not provide.
Not exercised on the device: it would have destroyed a real entry.
PatchEntryField already took a field name; what was missing was carrying it
from the row. It travels in the detail row itself rather than being guessed
from the label - a caption is prose, and the day it changes the page would
send a field name that does not exist, or quietly stop offering the edit.
Delphi does not take it on trust either: four names accepted, the rest
refused. The snapshot also carries tombstones, attachments and timestamps
that PatchEntryField would overwrite without understanding them.
An empty row now shows IF it can be filled - a missing username you cannot
add is a dead end. Emptying is allowed, except on a password, where it would
almost always be a slip; the refusal says so rather than doing nothing.
Show only appears on a masked field: on a site in clear it has nothing to
reveal, and would in fact hide it. A note keeps only Name and Content - a
site and a username mean nothing there.
Deferred, with the numbers rather than an impression. The 2.7 seconds left
are entirely the upload, sent twice-encoded because Gitea's contents API
wants base64 inside JSON and the container already is base64. One 116 Ko
attachment is about 78% of what travels on every write.
Three ways out, cheapest first, including the one that needs no code.
The trace has served: it named the cause where two rounds of reasoning had
missed it. Out with it, and out with the viewport measurement it proved
worthless here - a value that is always zero reads as a mechanism, and the
next person would trust it.
adjustResize goes too. Measured to do nothing: Android ignores the mode for a
fullscreen window, which FMX's is. Leaving it in the manifest would suggest it
carries something.
A field already near the top of the panel - the settings ones - stays where it
is. It was visible as it was, and lifting it only moved under the eye what was
already fine.
The plan keeps what the measurement cost: on this screen, instrument first.
adjustResize changed nothing either: FMX's window is fullscreen, and Android
ignores the mode there. So three sources were asked what the keyboard covers
- visualViewport, innerHeight, the window itself - and all three answer as if
it were not there.
Stop asking. While a field has focus the panel reserves 60% of the height
below its content; that is overflow, so it scrolls, and the focused field is
lifted to the top of the panel where no keyboard can reach it whatever its
size. The reserve goes at the first field left, so nothing floats when one is
not typing.
Crude on purpose. Every attempt at measuring this correctly has been wrong,
and the honest version of "we cannot know" is to make the answer not matter.
Two fixes placed by reasoning changed nothing observable, which means the
reasoning was wrong somewhere I cannot see from here. This writes the
numbers into the log rather than a fleeting message, so they can be read
after the fact: the window height, the visible height, and - the one that
settles it - what the scroll box shows against what it contains. Equal, and
there is nothing to scroll, which would be the whole problem.
Temporary. It comes out once the cause is known.
The frame is nailed to the whole screen, so when the keyboard rises the
content overflows nothing and the bottom third becomes unreachable: the
form's password field could only be seen by folding the keyboard away.
Reserving below the scroll area exactly the height the keyboard covers
gives the panel something to scroll. The measure answers correctly whether
or not the engine actually resizes the window - if it does, innerHeight
shrinks too, the reserve is zero, and the natural overflow already does the
work. No need to know which case applies.
A first attempt shrank the body to the visible height; it changed nothing
observable, and it depended on which of three over-constrained box
properties the engine drops. This does not.
The plan records where a save's time actually goes: 46 ms of crypto, 2745 ms
of PUT for 267 Ko. That is the floor for a monolithic container.
Confirmed on the device where reading could not: a real save prints
"opened with the key already in hand, in 48 ms". What remains in the wait
is the GET and the PUT, nothing computed.
Opening from the cache derives nothing, so the key learned last session was
gone and the FIRST save of every session paid the 4.5 seconds again. It now
travels in the sealed blob, next to the plaintext vault and to the password
that opens the container - that neighbourhood is what makes it acceptable:
it exposes nothing the file does not already hold.
The three parts go together or not at all. A key without its salt does not
say what it fits, which makes it useless, or worse, used where it does not
belong. A cache written by an earlier version carries none: LoadVault
returns nil, one derivation happens, and the next write stores it.
A freshly registered vault still derives once - EncryptContainer does not
hand back the key it just derived.
The plan records what the desktop confirmed: the two entries created on the
phone imported with nothing to correct.
Adding an entry took five to seven seconds. The log now says where they
went: key derived in 4395 ms. Every save re-derived the master password -
600000 iterations - to arrive at exactly the key that had just been used.
The remedy was already half written: EncryptContainerWithKey exists for
this reason, but only within a single save. The key now lives for the
session, and is reused only when the container carries the same salt and
the same iteration count - the desktop may have re-encrypted its own with
a fresh salt, and a key that does not fit fails on GCM's tag while the
message blames the password, which would be right.
Nothing new is exposed: the plaintext vault is already in memory, and the
cache already holds the password that produces this key. It goes with
them, at lock - salt included, or the next open would think it holds a
usable key and decrypt against nothing.
The envelope is read in one place now rather than copied per entry point:
at three copies, a drift between them would surface as a wrong-password
error.
Measured against the real repository: HTTP 200, decrypted, 8 entries - so
the envelope refactor holds. The reuse line itself is not yet measured on
the device.
A vault you cannot add to is not a vault, so an account created here was
useless without this. A plus button next to the padlock, a form, and a
kind - login or note, which the vault distinguishes and whose body is the
password field either way.
One save path now, two transports, and the rule that decides between them
is: if a local container exists it is the authority. A vault born here has
no repository; a vault pulled from one has no local container. The case
where both exist - a local vault that later gains a repository - needs a
merge, and that is section 7 step 4.
The patch arrives as a function rather than an already-patched snapshot:
the remote path must modify WHAT IT HAS JUST PULLED, or it would overwrite
whatever another device published in between. That constraint is why the
two transports could not simply share a finished payload.
Two traps closed while passing: the form left the typed password sitting
in the document after the save, and a refusal left its Save button dead
with the text behind it.
Verified on the device that the button and the form appear and read
correctly. Saving was not exercised - the vault on this phone syncs to a
real repository, and a test entry would have been published to it with no
way to delete it from here yet.
Until now the phone could only read a vault that came from elsewhere:
without a URL, a token and a sync password it showed nothing. It now
signs in like the desktop does - username and master password - and can
create its own vault, with no network at all.
The local container carries exactly what the repository would carry: same
format, same encryption under the password. Two reasons, not one. It is
the desktop's model, where the master password protects the vault at rest
rather than the hardware alone; and the format staying the repository's
means sync keeps working with nothing to convert. No Keystore on that
file - it is already encrypted under the password, and binding it to the
device would make it unrecoverable after a reinstall. vault.bin keeps
that role: it is the fast-open cache, and only it needs sealing.
The password opens the container - the decryption itself is the check, no
secret is ever compared - and the username, read from inside once open,
says whose vault it is.
The home screen now knows which of three situations it is in: nothing
here yet, a container without a cache, or a cache ready to open.
Verified on the device: with the cache set aside, the screen offers
username, master password and Create vault, alongside the download path.
Creating an account could not be exercised to the end - the device locked
its screen mid-run - and the vault that was set aside is back in place,
same size, still sealed under its code.
Two defects, one feature made useless. The cache did seal under the code,
but nothing on the home screen asked for it: opening without one failed,
the failure was read as corruption, and the device offered to download a
vault it held perfectly well. And the next refresh rewrote the cache in
the old shape, so the code was undone seconds after being set, silently.
The code now lives on the home screen, not among the server settings: it
seals this device's cache, while the other three fields describe the
shared remote. Always visible - hiding it when none is set would remove
the only place to set one - the hint saying which case you are in. An
absent code is no longer attempted, a wrong one is no longer corruption,
and a sealed cache is never rewritten without its code.
The two panels each got an arrow, and only one opens at a time.
Measured end to end on the device: version byte 1, code typed, open, byte
4; cold start says "Unlock code required" and open without the code
leaves the byte at 4; correct code opens the vault, and the refresh that
follows leaves it at 4.
It was our own handler. The web view is not a field the system knows, so
it does not intercept the key for us - it arrives here, and we consumed
it in every case: the vault locked, or the list came back, while the
keyboard stayed up with no way left to close it.
hideSoftInputFromWindow answers whether there was anything to fold, which
is the synchronous signal needed to decide. Something folded, key eaten;
nothing folded, the key keeps its usual meaning.
Measured on the device, before and after: mInputShown stayed true, it now
goes false and the application stays in front. A second press locks, as
before.
Same origin as the sideways slide. The keyboard's validate key asks for
the next field, and the next one in the document sits on a view parked
outside the frame. Focus landed there unseen: the keyboard changed shape
- numeric for the code, alphabetic for the search - and would not close,
because the field holding it open was off screen.
No field of a resting view keeps the focus now, and the validate key
closes the keyboard instead of jumping. In the editor it saves, which is
what the key is for after typing a password.
The write test goes with it: it proved the write path before the first
real PUT, that path now carries the vault itself, and the button, its
handler, the probe and the debug hook that revealed it were only ever
scaffolding.
The previous attempt clamped the scrolling panes, and the panel still
slid. It was not the panes: overflow:hidden forbids the GESTURE, not the
engine, which keeps the right to move a box so the newly focused field
comes into view - and the rail's off-screen views, parked at
translateX(100%), leave it room to the right. Confirmed by measurement:
the CSS was in the shipped library and the behaviour was unchanged.
Caught in the capture phase now, whichever box moves and whatever moves
it. No view here has content to the right, except the folder strip.
The editing row kept the full-width field and set three buttons beside
it, so the line was wider than the frame. A pane scrollable on one axis
is scrollable on both - the specification turns the untouched axis into
`auto` - so the overflow was draggable, and the keyboard's Enter key
brought the field back into view, taking the whole panel with it.
The field now shares its line, and no pane scrolls sideways. Enter
validates, which is what the key was pressed for.
The reported pain: changing the pattern destroys the local cache every time.
That is Android doing exactly what it promises - a key created with
setUserAuthenticationRequired is invalidated the moment the screen lock
changes, so a new lock cannot open data sealed under the old one. No storage
format changes that, which is why SQLite would not have helped: the problem is
what the key is tied to, not what the file looks like.
So the cache gains a third sealing format, VER_PIN, with two layers:
inner - AES-GCM under PBKDF2(code, 100k) : it takes the code
outer - the Keystore AES key WITHOUT the auth constraint : it takes the device
The outer key is never invalidated, so the file survives any change to the
screen lock; the code restores the protection that removing the constraint
would otherwise cost. 100k iterations rather than 600k because a four to six
digit code carries little entropy - more rounds mostly slow the honest user,
and the real barrier is the outer layer requiring this phone.
The code lives in a fourth settings field and is NEVER stored: empty at every
launch, typed to open, and used to re-seal on every write. That is what
separates it from the token, whose persistence is a convenience with no
consequence since it only opens ciphertext.
Built but NOT installed - the phone went off the network.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
save: attempt 1 - GET
save: decrypt+patch in 4519 ms
save: re-encrypt in 23 ms
save: published, new sha 802515d4...
The phone writes to the vault. The 23 ms measure the reused key: the second
derivation was worth the same 4.5 seconds as the first.
Three things are recorded as open rather than left to be rediscovered: the 409
retry is written but has never run, there is no offline editing (the phone
patches the snapshot it just pulled, so Save needs the network - that is the
price of having no merge to write), and only one field is editable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported together, and the second explains the first: Cancel did nothing
because Save disables it and nothing re-enabled it - a disabled button fires no
click. And the screen never came back because the entry guards (busy, no uuid,
no sync password) returned with a toast and nothing else, leaving the editor
open with every control dead. A cul-de-sac with no way out but killing the app.
Each guard now calls PM.editDone, which redraws the row from the last detail
received. The page can leave edit mode without asking Delphi for anything,
which is what it needed to recover from a refusal that never reached the
network.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two defects, both reported from the device.
Nothing came back after Save. AdoptVault rebuilds the LIST, not the detail, so
the row stayed in edit mode: the input remained on screen and nothing said
whether the write had gone through. The detail is now redrawn from the vault in
both cases - after a success it carries the new value, after a failure the old
one, which reads without needing a sentence. It is found by UUID rather than by
index: the vault has just been replaced by the server's, and nothing guarantees
the same entry sits at the same rank.
The button also stayed inert-looking for the several seconds a save takes, so it
now says "Saving..." and disables itself while the work runs.
And the typed password showed in clear. My reasoning was that you cannot verify
it anywhere else - but that argues for a reveal button, not for leaving it
exposed the whole time. It is masked, with Show next to it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported as "Save takes 6 to 8 seconds". A save decrypts the snapshot it just
pulled and re-encrypts the patched one - with the same password. That was two
full PBKDF2 runs at 600k iterations, four to six seconds each on this phone,
for two keys that protect the same file.
DecryptContainerEx now hands back the key, its salt and the iteration count,
and EncryptContainerWithKey reuses them. The IV is still drawn fresh every
time: reusing an IV with the same key breaks GCM entirely, whereas reusing the
salt merely yields the same key - which is the whole point. A salt slows an
attack on the password; it has no reason to differ between two versions of the
same file.
EncryptContainer keeps its old shape and now delegates, so there is a single
place that composes a container.
The log times both halves, so the next measurement says where the remaining
seconds are rather than inviting another guess.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pull, patch, re-encrypt, publish - in that order, and against the SERVER's
snapshot rather than the local cache. That is what replaces the merge the plan
asked for: anything untouched is already everyone's newest version, so there is
nothing to reconcile.
The only conflict left is another device publishing between our read and our
write. Gitea refuses it on the sha with a 409, and the whole cycle repeats,
decryption included, since the content changed. Three attempts: past that the
other device is publishing in a loop and insisting would not help.
The editable field is marked in Delphi rather than recognised by its caption in
the page. A caption is prose; the day it changes, matching on it would silently
stop offering the edit.
The editor is inline rather than window.prompt: a JavaScript prompt needs a
WebChromeClient that FMX does not provide, and prompt() then returns null
without a word - the button would appear to do nothing. The value shows in
clear while typing, deliberately: you are entering a password you cannot read
back anywhere else, and masking it would mean confirming a keystroke blind.
This is the one command that carries a secret over the bridge, agreed
beforehand. It stays in the process, the iframe carrying it is destroyed, and
the handler logs the action only - never the query.
Only the password is editable. Creating or deleting an entry would need
tombstones and uuid generation, and changing a password covers the need
without either.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Section 4 step 4. TVault reads a subset of the fields, so re-serialising the
vault from what it understood would drop attachment contents, created_at, the
account avatar, tombstones, and whatever the desktop adds next - and the
desktop would adopt that amputated version on its next pull, the merge being
last-write-wins on updated_at. Silent, and final.
So nothing is rebuilt: the received JSON is edited in place and the rest is
handed back untouched. What the phone does not understand travels intact by
construction rather than by vigilance.
A corollary worth naming, because it removes the step the plan feared most:
since the patch applies to the snapshot just pulled, there is no merge to
write. Anything untouched is already the server newest version. The only
possible conflict is another device publishing between our read and our write,
which Gitea refuses on the sha.
The self-check tests what is PRESERVED, not what changes: a field unknown to
TVault, an attachment content_b64, the untouched sibling entry, the
tombstones. It reports "patch keeps what it does not understand" on the device
at every debug launch.
Also confirmed by this log: the write token now survives a restart, so the 304
settings fix holds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It said "gitea token", which is wrong for the other transport this client
speaks: the same field carries a Bearer token to the PHP endpoint. The URL
decides which - naming the field after one of them makes it false for the
other.
Worth stating plainly while here: this client does NOT do WebDAV. There is no
Basic auth in PMA.SyncClient and no username field; only the desktop still
speaks it. Supporting it here would need both.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The labels went in this afternoon to tell the token and the sync password
apart, both being masked and stacked. They are removed on request; the
placeholders carry that job again, which they do while the fields are empty -
and that is when it matters, since a filled field is one you just typed.
Each field gets a cross instead. Clearing a forty-character token with the
backspace key takes forty presses and leaves a character behind easily, which
shows up as a 401 whose cause is invisible.
The cross is a SIBLING of the input, laid over it - never a child. A child of
an input does not receive the tap; that cost a build earlier today when the
search field was given its own cross.
It also pushes the configuration immediately: the field keeps the focus, so the
blur that normally carries the value to Delphi never happens.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported as "the token field saves nothing", and it was exact: LockVault
called PushConfig, which pushes the STORED server settings back into the page.
Every lock therefore overwrote a freshly typed token with the old one -
including the three-minute auto-lock, so it happened with no gesture from the
user at all, and the field looked like it forgot on its own.
The user's own sequence isolated it: deleting one character gave a 401, which
proves the typed value does reach the wire; then open, lock, sync gave a 200
again, the lock having restored the old token in between.
PushConfig now runs at startup only. It was pointless in the other four places
anyway: PM.wipe clears the list and the detail, never the configuration fields.
Along the way, and part of the same confusion: the three settings fields had no
labels, only placeholders that vanish once filled - and since the token was
masked this morning it became indistinguishable from the sync password sitting
right below it. They are labelled now, and the log reports both fingerprints so
a value landing in the neighbouring field shows up immediately.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TSyncClient.Put publishes a container to the Gitea contents API: token header,
JSON envelope with the file base64 inside, sha omitted rather than empty when
creating, branch omitted so the repo default is used. 409 becomes its own
status, ssConflict, because it means re-read and re-apply rather than retry.
The first PUT is where a mistake costs data, so the probe writes to
write-test.json rather than the vault. Same repository, same token, same
envelope, same concurrency check - and the vault is never at stake. Reachable
from a debug-only button; the page asks Delphi whether to show it.
Two mistakes of mine on the way, both about replacing information with a guess:
- the probe did not push the config first, unlike Sync, so a token that had
just been pasted had not reached Delphi and the 403 blamed the wrong thing.
- 401 and 403 were rewritten as "token lacks write access", discarding Gitea's
body. That is an interpretation, not an observation: the same code covers a
read-only repository, a protected branch, a token from another account. The
body says which - here, plainly, "token scope=read:repository".
Also recorded: the sha comparison now short-circuits (304 where it used to
decrypt), so the difference was case or whitespace, absorbed by SameText on
trimmed values. The offending character itself was never seen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Section 4 step 2. EncryptContainer produces the exact container the desktop
writes: PBKDF2-SHA256, AES-256-GCM with the tag appended as WebCrypto does, a
32-byte salt and a 12-byte IV drawn per call from SecureRandom on Android and
BCryptGenRandom on Windows - never Random, since a predictable salt or IV ruins
the encryption that uses it and nothing in the output would show it.
The check matters as much as the code. A round trip with itself only proves
internal consistency, and it passed while the container carried two defects.
Node - the same primitives as WebCrypto - found both by reading a container the
phone had produced:
- Delphi's base64 wraps at 76 characters; WebCrypto's never does. Lenient
decoders accept it, but depending on their leniency is not a guarantee.
- the accented literal in the check came out of the COMPILER already broken:
this source has no byte-order mark, so Delphi reads it as ANSI. The defect
was in the test rather than the encryption, and only the cross-check could
tell those apart.
The check runs at launch in DEBUG and leaves selfcheck.json in the private
directory so it can be re-read from the PC.
Also fixed on the way: TPath resolves to FMX.Objects.TPath - a drawing shape -
because FMX.Objects comes later in the uses clause, and the error then talks
about an unknown Combine rather than a name collision.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Kept in settings.bin, because the silent refresh needs it and so does the
first download on a fresh device - there is no vault to hide it in yet. But it
was the only secret on that screen shown in clear, right beside a masked
password field, and it is also the least protected of the three: settings.bin
is sealed by the no-auth Keystore key, readable without authentication, unlike
the vault.
Masked now. It is typed once, and reading it back has no use - a wrong token
shows up as a 401 in the log, not by squinting at the field.
What it opens stays worth knowing: read access to the account's repositories,
whose only content is ciphertext.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Verified on device: opening the vault no longer calls the server on every
open. The throttle works.
What it does not do is explain the underlying defect, so the plan now carries
it as an open item with the measurement, what has been ruled out (the Gitea
transport is selected, SaveVault stores what it is given, vault.bin is
rewritten, the container decrypts), and the distinction between worked around
and fixed. Two fingerprints print identically and compare unequal; only an
invisible character explains that, and reading the path did not find it.
Written down rather than left in a commit message, because the cost is now
paid once a quarter of an hour instead of on every open - which is exactly the
kind of defect that stops being noticed before it stops existing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Asked plainly by the user: why does open trigger a sync? Because
OpenLocalNow displays the cache and then refreshes in the background, so a
change made on the desktop shows up without anyone thinking about it. Sound
intent, applied on EVERY open - including a lock followed by a re-open three
seconds later, which cannot learn anything new. Measured cost of one such
pointless refresh: 1 s of download and 5.5 s of key derivation.
A minimum interval between two background refreshes fixes that, whatever else
is wrong: the desktop does not publish several times a minute, and looking at
most once a quarter of an hour is enough for a change to arrive unnoticed.
Pressing Sync goes through another path and never waits.
The sha comparison that should already have made an unchanged refresh cheap is
still not firing - two fingerprints print identically yet compare unequal. It
is now compared with SameText on trimmed values, which cannot confuse two
distinct snapshots since a hex digest never differs by case or whitespace
alone, and the log reports both lengths so the invisible character shows up on
the next real refresh.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The translucent circle exists to back the initials of an entry without an
icon. A favicon brings its own transparency, so the circle showed through and
around it - a saucer under the drawing, plainly visible on the Gitea cup.
The circle is dropped as soon as the row carries an image, through a class on
the avatar rather than :has() so it does not depend on how new the engine is.
object-fit also goes from cover to contain: an icon is not a photograph and
has no business being cropped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The full round trip ran: the desktop pushes (second commit in pm_sync), the
phone reads what it published. The proof was one entry - "gitea token read",
created on the desktop, appeared on the phone without touching sync.php or
WebDAV, and the trash did not travel, which is what buildSyncSnapshot intends.
Recorded with it are the three details that would cost an evening to
rediscover: sha omitted rather than empty on a first push, branch omitted so
the repo default is used, and the desktop token needing write:repository while
the phone keeps read.
Also recorded, though unrelated to sync: settings sections are mapped to tabs
by their visible label text, so renaming one moves the section to the wrong tab.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>