Skip to content

feat: let the app remove itself, with everything it put on the computer - #3

Merged
shukiv merged 20 commits into
mainfrom
worktree-message-edit-history
Sep 28, 2026
Merged

shukiv merged 20 commits into
mainfrom
worktree-message-edit-history

Conversation

@shukiv

@shukiv shukiv commented Sep 26, 2026

Copy link
Copy Markdown
Owner

Leaving was undocumented and manual. An account, its message history,
gigabytes of cached attachments, a settings file, sockets and the
launcher entry an integrated AppImage leaves behind are in five
different places, and a reader who deletes the AppImage keeps all of
them - including the keys to a WhatsApp account.

The program knows where it put them, so it is the thing that takes them
away:

./WhatsAppGo-x86_64.AppImage --uninstall

It lists every path with its size, waits for the word yes, and says what
happened. --yes answers for a script, and --keep-accounts removes the
settings, the cache and the launcher entry while leaving the accounts
and their history. It refuses while a window is open, because the
background service holds those databases and quitting writes the
settings back afterwards.

Two things it says rather than hides. Removing the history cannot be
undone, because it exists nowhere else. And WhatsApp is not told: the
phone goes on listing this computer under Linked devices until somebody
removes it there, which no amount of deleting does.

Settings are cleared through QSettings rather than by deleting a file,
because on Windows they are registry keys that no folder removal
reaches. The AppImage itself is left alone: a program deleting the file
it is running from is a good way to leave half of it behind.

Covered by a test that builds a whole installation under its own
directories and removes it twice - once sparing the accounts, once not -
and mutation-checked: keeping nothing when asked to keep the accounts,
leaving the accounts, leaving the cache, and reporting success without
removing anything all fail it.

Claude-Session: https://claude.ai/code/session_013tGr7Gzn1f9tTSj8jtSje7

Leaving was undocumented and manual. An account, its message history,
gigabytes of cached attachments, a settings file, sockets and the
launcher entry an integrated AppImage leaves behind are in five
different places, and a reader who deletes the AppImage keeps all of
them - including the keys to a WhatsApp account.

The program knows where it put them, so it is the thing that takes them
away:

    ./WhatsAppGo-x86_64.AppImage --uninstall

It lists every path with its size, waits for the word yes, and says what
happened. --yes answers for a script, and --keep-accounts removes the
settings, the cache and the launcher entry while leaving the accounts
and their history. It refuses while a window is open, because the
background service holds those databases and quitting writes the
settings back afterwards.

Two things it says rather than hides. Removing the history cannot be
undone, because it exists nowhere else. And WhatsApp is not told: the
phone goes on listing this computer under Linked devices until somebody
removes it there, which no amount of deleting does.

Settings are cleared through QSettings rather than by deleting a file,
because on Windows they are registry keys that no folder removal
reaches. The AppImage itself is left alone: a program deleting the file
it is running from is a good way to leave half of it behind.

Covered by a test that builds a whole installation under its own
directories and removes it twice - once sparing the accounts, once not -
and mutation-checked: keeping nothing when asked to keep the accounts,
leaving the accounts, leaving the cache, and reporting success without
removing anything all fail it.

Claude-Session: https://claude.ai/code/session_013tGr7Gzn1f9tTSj8jtSje7
Link a phone and the chat list fills with unread badges - seventy-four
here, ninety-nine there - on conversations the phone and WhatsApp Web
both show as finished.

History sync delivers every incoming message as merely received: the
payload says nothing about which of them were read, because the reading
happened on another device. The one thing it does say is how many
messages of each conversation the phone still counts as unread. That
number was stored on the chat and then thrown away, because the unread
totals are recalculated on every connection from the newest incoming
message this device has seen a read receipt for - and for messages that
arrived while the app was closed, or before it was ever linked, no
receipt exists. Everything since the last one this device happened to
witness was counted as new.

So the snapshot's count is now written down where the recalculation can
see it. Every incoming message older than the newest unreadCount of them
was read somewhere else, and is marked read locally. The result is the
number the phone reports, and it survives the next connection.

Three things the boundary deliberately does not do. It never reaches
past the conversation's own last message time, so a message that arrived
after the phone assembled the snapshot stays unread. It marks nothing
when this device holds fewer messages than the phone counts as unread,
because none of those are messages it can vouch for. And it is applied
only to a sync that carries conversation settings: an on-demand page of
older messages says nothing about unread state, and its silence must not
be read as "all of this has been read".

Nothing is sent to WhatsApp. This only writes down what the phone
already said, so no conversation is marked read on anybody else's
device. A conversation the reader marked unread by hand keeps its flag,
which the same snapshot carries separately.

Covered by four store tests and two history-sync tests, and
mutation-checked: dropping the call, applying it to on-demand pages,
marking the unread messages too, ignoring what the snapshot covers, and
marking messages read when the device holds fewer than the phone counts
all fail them.

Claude-Session: https://claude.ai/code/session_013tGr7Gzn1f9tTSj8jtSje7
A freshly linked device shows the same contact twice: one row with their
name and today's messages, one row with a bare phone number and the
thread everything else is in. Opening either shows half the
conversation.

WhatsApp addresses a person two ways - the privacy-preserving LID and
the phone number - and a message may arrive under either. Whichever form
arrives first opens a conversation keyed by it, and the two were joined
in one place only: the directory sync, which works from the contact
table. A contact that table has never fetched is joined by nothing, so a
device that has just been linked is exactly where the split shows.

Messages carry the answer themselves. A message names the other address
of whoever is at the far end - the recipient's for one this account
sent, the sender's otherwise - so the two conversations are joined as it
arrives, before it is stored, and it lands on the conversation that
absorbs the other. A history conversation names one address only, so
there the device's own mapping table is asked instead.

The LID is the canonical side, matching what the directory sync already
does. Two rules would move rows back and forth between the two keys
forever. Groups and broadcasts have one address each and are left alone.

The mapping lookup is behind a field so a test can answer it: whatsmeow
owns that table and a test has no way to put anything in it.

Covered by two tests - a live message arriving under the other address,
and a history conversation naming only one of them - and
mutation-checked: dropping the live link, ignoring the address the
message carried, and dropping the history link all fail them.

Claude-Session: https://claude.ai/code/session_013tGr7Gzn1f9tTSj8jtSje7
The call list stops. On one account here the newest entry is from the
second of September, three weeks before the calls that are missing from
it, while messages, pins and everything else keep arriving.

Call history is not delivered with messages. It lives in an app-state
collection, and whatsmeow fetches a collection when the server sends a
notification saying it changed - which it only does to a device that is
connected at that moment. Every call taken with the app closed is
therefore never collected: the notification went to nobody, and nothing
asks afterwards.

That alone would leave gaps. What freezes the list for good is the other
half: when the patches the server sends stop adding up to the hash they
are signed with, whatsmeow writes the failure to a log and leaves the
stored version where it was. Every later notification then fails in the
same place, so the collection stays at the version it was on the day it
drifted - for that account, the second of September - and no part of the
program ever learns that anything is wrong.

So every collection is asked for what it missed on each connection.
Nothing is different when the collection is healthy: the fetch is the
same one the notification would have caused, and returns no patches when
there is nothing to send. A collection whose patches no longer verify is
replaced with a fresh copy, and if that still does not verify the phone
is asked for one, at most once a day - the same repair a refused pin
already uses, now driven by what arrives rather than only by what this
device sends.

A collection that could not be reached at all is left alone. A dropped
connection is not drift, and throwing away a good copy over one would
lose settings this device holds.

Covered by four tests: every collection asked once, a drifted collection
replaced, a fresh copy that still fails asking the phone once and not
again on the next connection, and an unreachable collection left as it
is. Mutation-checked, with one gap stated plainly: removing the call
from the connect sweep is not caught, because that sweep talks to
whatsmeow directly and has no seam a test can hold.

Claude-Session: https://claude.ai/code/session_013tGr7Gzn1f9tTSj8jtSje7
The name above a group message is often the only place somebody who is
not in the address book is named at all, and it did nothing. Writing to
them meant opening the group, opening its members, and finding the same
name in that list.

Clicking the name now opens that person's conversation, which is what
clicking a mention of them already did. Both go through the same signal,
so there is one way into a conversation from a message rather than two.

The control sits over the name itself rather than over the line it is
on. The label is as wide as the bubble, and a click on the empty half of
that line is not a click on anybody. It follows the text to the right
where the language is written that way, and it is only there where
WhatsApp shows a name: a one-to-one bubble carries none, and the
conversation is already that person.

Covered by the message-interaction test, which asks a group delegate and
a one-to-one delegate for the same thing and expects only the group one
to answer. Mutation-checked: a name that asks for nothing, no control at
all, a control that cannot be used, and a one-to-one bubble that opens a
conversation all fail it.

Claude-Session: https://claude.ai/code/session_013tGr7Gzn1f9tTSj8jtSje7
A message meant for a particular moment had to be typed at that moment.
Now it can be written when the words come, held, and sent when they are
wanted.

The clock beside the send button opens a dialog with the message in it:
quick choices for the times people actually pick, and a date and a time
for everything else. The moment chosen is shown in full - "Sends Friday
25 December, 07:45" - because a date typed into two fields is easy to
read wrong, and a message that goes at the wrong time cannot be taken
back.

What is waiting appears above the composer, with a way to open the list
and cancel any of it. Nothing else in the program shows these messages,
because they are in no conversation yet: they exist only as rows on this
computer, and WhatsApp is not told about them until they are sent. A
message nobody can see yet has to be cancellable, or scheduling one is a
decision with no way back.

The daemon owns the queue. It looks every ten seconds for messages whose
time has come and sends them through the same path as a message typed
now, so a scheduled message is an ordinary message that happened to be
written earlier. A send that fails keeps its place and its reason rather
than being dropped: there is no copy of those words anywhere else, and a
connection that is gone now is usually back later. A message already
sent keeps its row, so a second sweep cannot send the same words twice.

The honest limit, stated in the dialog rather than discovered: this only
works while WhatsAppGo is running. WhatsApp has no scheduling of its
own, and nothing here can send from a computer that is switched off. A
message whose moment passes with the app closed goes out at the next
start - late rather than never, which is the only thing a program that
is not always running can offer.

Everything that arrives is checked where it arrives: a conversation this
app is allowed to write to, text that says something, and a time that is
ahead of now and less than a year away. A year is past anything anybody
plans in a chat window, and it catches the date typed with an extra
digit that would otherwise sit in the queue forever.

Covered by six tests in the daemon - sent only when due, sent once,
overdue messages sent at the next start, a failure that keeps the words,
refusals for every bad field, and a cancelled message that never goes -
and by a desktop test that holds the clock at half past nine in the
evening and checks each control: the default hour ahead, "tonight at
20:00" meaning tomorrow, typed fields read as local time, a past moment
that cannot be confirmed, and a waiting message that can be taken back.

Claude-Session: https://claude.ai/code/session_013tGr7Gzn1f9tTSj8jtSje7
A followed channel listed its name and nothing else: clicking it did
nothing at all, because a channel's posts are the one kind of message
WhatsApp never sends a linked device. They sit where WhatsApp keeps
them until something asks. Nothing asked, so the channel was empty.

Opening one now asks for its recent posts and keeps them like any other
message. That is deliberate: a post is a message, and the conversation
view already draws a picture, a link preview, a forwarded note and a
reply correctly. Reading a channel in it means none of that had to be
written a second time, and a channel opened again reads from this
computer rather than the network. Only the people who run a channel may
post in one, so the composer is replaced by a line saying so, rather
than offered and refused on every send. The chat list is unchanged:
these conversations were already kept out of it, which is where the
Channels page comes in.

The honest difference from WhatsApp Web: it keeps you on the Channels
page with the posts beside the list, and this moves to Chats with the
channel open. What is read is the same; where you are standing is not.

Communities were empty for a reason with a wider reach. Only a
community joined in its own right was listed, and almost nobody joins
one that way - people are added to a community's group, and the
community is named only on the group. Each joined group's community is
now listed too, once however many of its groups you are in, and with
its own name and description read from it. A community whose details
cannot be read is still listed, because the alternative is the empty
page this fixes.

Whether either account here belongs to a community is not something
this could be checked against: both list none today, and only running
this build against them will say whether that was the bug or the truth.

Status was examined and left alone. Its list is built from the status
messages of the last day, the daemon answers with them on both accounts
here, the page refreshes when one arrives and when the section is
opened, and clicking a row opens the story viewer - which the existing
desktop-status-stories test covers. Nothing was found to fix.

Covered by seven tests in the daemon - a channel read into a
conversation under its own name, a second read that leaves one copy of
each post, every non-channel address refused, a failed read that stores
nothing, a community reached through a group, one listed once however
many of its groups you are in, and one whose details cannot be read -
and by a desktop test that clicks a channel row on the Channels page
and checks what happens: the conversation opens, its posts are asked
for and shown, there is no composer, and an ordinary conversation still
has one.

Claude-Session: https://claude.ai/code/session_013tGr7Gzn1f9tTSj8jtSje7
A post arriving while the channel is open was acknowledged like a
message from a person. Nobody is waiting to know that a follower read
one, and the address takes no receipt.

Claude-Session: https://claude.ai/code/session_013tGr7Gzn1f9tTSj8jtSje7
A recording had a waveform and nothing else: no mark of where playback
had reached, and no way to reach a different part of it. The handle
existed only while that recording was the one playing, so a recording
sitting in the conversation gave no sign it could be moved through at
all, and there was nothing to take hold of.

The handle is now always there, at the beginning of a recording nobody
has played. Dragging it moves it as the pointer moves, because a handle
that only jumps when the button is released cannot be aimed. Letting go
plays from that point: a recording that is not playing is started
there, which is the reason somebody moved the handle in the first
place. The position asked for is kept until the player knows the
recording's length, since a file that is not open yet has no length to
measure a position against.

The other half is what the sender cannot otherwise know. A recording
that reached somebody and a recording they listened to look identical,
and WhatsApp separates them by turning the play control and the handle
the blue of a read receipt. WhatsApp reports this as its own kind of
receipt, which the daemon already stores as "played", so this is a
matter of drawing what was already known.

Covered in the message-layout test: the handle is present and starts at
the beginning, it can be dragged across the full width of the waveform,
dragging to the middle puts it in the middle, and a recording of ours
marks both its handle and its play control in blue once it has been
listened to - while one that has only arrived marks neither.

Claude-Session: https://claude.ai/code/session_013tGr7Gzn1f9tTSj8jtSje7
A position asked for before the player had opened the file was placed
the moment the file announced a length, which is earlier than the point
at which it can be moved. Two things came of that.

The first was wrong behaviour that was easy to miss: the position was
thrown away rather than applied, so a recording dragged to the middle
and let go played from the beginning. The new test caught exactly this
- dragged to six tenths, started at 1408 of 20000.

The second was a way to run out of stack. Moving the position makes the
player re-announce what it knows, and that arrives back at the same
place: a position still waiting to be placed would be placed again, and
again. The share is now cleared before the position is written rather
than after, so the second visit finds nothing to do.

A position is placed only once the file is open, measured, and can be
moved within at all. Moving within a stream the decoder is still
reading is what makes it report the samples it had to skip, which is
what precedes this in the log.

A flick that begins on a waveform now scrolls the conversation again.
It used to take hold of the recording and start playing it on release,
because the handle refused to let the list have the press.

Covered by a test that drives the player against a recording - a real
one if named with --voice-seek-file, otherwise twenty seconds of tone
written on the spot, because the suite cannot carry an account's audio
and no encoder can be assumed present. It drags to six tenths on a
recording that is not playing and checks it starts there, moves through
it repeatedly, changes recording eight times in quick succession,
stops, and starts again from three quarters. Removing the readiness
check fails it.

This does not explain the crash that prompted it. The crash could not
be reproduced here in either of two headless attempts, there is no core
file to read - core dumps are switched off on this machine - and no
debugger is installed, so no faulting frame is known. What is fixed
here is a way for that code to run out of stack, which fits, rather
than a crash that was watched.

Claude-Session: https://claude.ai/code/session_013tGr7Gzn1f9tTSj8jtSje7
…like

The dock drew a blank mark where the icon belongs. The icon itself was
never missing: the window carries it, and a taskbar that reads the
window finds it. A desktop shell does not read the window. It reads the
name the window gives - org.whatsappgo.Desktop - looks for a launcher
entry of that name, finds none, and draws the mark it keeps for a
program it cannot identify.

A packaged installation ships that entry. A single downloaded file
cannot ship anything, so it now writes one for itself on startup, into
the user's own directories and nowhere else: the entry beside the
applications menu, and the icon, which is carried inside the program
and copied out rather than named and hoped for.

Three things about how it is written. It is skipped entirely when a
package manager has already installed an entry, because that entry
belongs to the package and a second one would shadow it and go on
naming a file an upgrade has replaced. It names the file the program
was started from rather than the temporary directory a downloaded file
unpacks itself into, which would stop working the moment it exits. And
nothing is written when what is already there is exactly right, because
this runs on every start and a rewrite makes the desktop reread its
menus for nothing.

The entry also carries the window class, which is what ties a window
the shell can see to the entry it has. Qt names the class after the
application, while the name beside it is whatever file the program was
started from - for a downloaded file, the download's own name, which
matches nothing. The packaged entry gets the same line, for the same
reason.

Uninstalling takes both away. It already removed any entry mentioning
the downloaded file; it now also removes this entry and this icon by
name, so an entry somebody has since edited by hand still goes.

Covered by a test given an applications menu of its own: the entry is
written with a name, an icon and a window class, the icon is the
picture itself rather than a name pointing at one, writing it twice
leaves the second one alone, a file that has moved has its entry
rewritten and not its icon, and a program with no path writes nothing.

Claude-Session: https://claude.ai/code/session_013tGr7Gzn1f9tTSj8jtSje7
Three things were wrong with a voice note, all of them read off the web
client rather than judged by eye.

A recording somebody sent stayed the same colour whether or not it had
been listened to. The blue was only put on our own recordings, on the
grounds that the sender has no other way to know - but the reader has
the same question about a conversation full of recordings, and the
answer was there all along: playing one is already reported, and the
message already carries it. The handle now turns blue whoever did the
listening.

The two colours were wrong as well. They are the web client's own, read
out of a screenshot: a green brighter than the interface green while a
recording waits, and a lighter blue than the one on a read message once
it has been heard. Using the read-message blue said the same thing
twice with one colour, and those are two different facts.

The play mark is now solid. It was the same outline every other play
mark in the interface uses, and a recording is the one place the web
client fills it in - sampled at the same near-black in both states,
which also settles something: the mark itself does not change colour
when a recording is played. Only the handle does. Our own recordings
keep the marked control, because nothing else on an outgoing message
reports that it was heard.

The list of chats carries the same mark. A recording waiting to be
listened to turns the microphone beside the chat green, and it goes
back to grey once the recording has been played - which is how a list
says which conversation is holding something unheard.

Covered in the two layout tests: the mark is the filled one, a
recording nobody has played is green, one we sent and they heard is
blue on both its handle and its control, one they sent and this reader
played is blue on the handle alone, one that only arrived is neither,
and the microphone in the list marks an unplayed recording and stops
marking it once it has been played.

Claude-Session: https://claude.ai/code/session_013tGr7Gzn1f9tTSj8jtSje7
…one when the bytes will not open

A sticker in one conversation would not download. Retrying it produced
the same thing every time: "hash of unencrypted media doesn't match".

Two separate faults sat behind that one message, and both are fixed.

The first is in the library. An attachment carries the key its bytes
were encrypted with and the hash of those encrypted bytes, and whatsmeow
reads a key with no such hash as a sign that the attachment is not
encrypted at all - so it throws the key away, downloads the bytes, and
hashes them against the hash of the decrypted file, which can never
match. The message that started this really is in that state: a
thirty-two byte key and no encrypted-bytes hash.

Nothing here can change what the library decides, but the hash it is
missing can be handed to it. Such an attachment is now fetched twice:
once with no key and no hashes, the one arrangement the library leaves
alone, only to learn the hash of what the host serves; and then again
the ordinary way, so the key, the message authentication code and the
hash of the decrypted file are all checked by the library exactly as
they are for every other attachment. Nothing is decrypted here and no
check is skipped; the only thing this adds is the hash. The second fetch
costs a round trip, and only for attachments in this state, which are
then cached like any other.

The refreshed path a media retry hands back went down the same road and
hit the same wall, so both now go through one function.

The second fault is ours. A download only asked the phone to refresh an
attachment when the path had expired - a 403, 404 or 410. Bytes that
arrive but cannot be opened are just as much a reason to ask: the key on
the stored message and the bytes on the host can drift apart, and the
phone is the only party that can say where the attachment really is now.
Failing to open the bytes now asks the phone too.

That is also what settles the message this started with. Asked directly,
the phone answers that it no longer has the media, so that sticker is
beyond recovery - but it is now said plainly instead of arriving as a
hash mismatch, and every wording along that path stopped claiming the
attachment had expired when it had not.

The re-send path took the same treatment, since re-sending a stored
sticker went through the byte-returning download with the same rule in
it. Its one megabyte limit now measures the file rather than a buffer.

Covered by five tests against a stand-in that answers the way the
library does, refusals included: an attachment with no encrypted-bytes
hash arrives in two fetches, one that decrypts to the wrong thing is
still refused, an ordinary attachment costs one fetch, a refreshed path
recovers the missing hash as well, and bytes that will not open reach
the phone while an unrelated failure does not.

Claude-Session: https://claude.ai/code/session_013tGr7Gzn1f9tTSj8jtSje7
…op overwriting the one that works

A message names its attachment twice: a url, and a direct path on the
media host. whatsmeow reads the direct path and nothing has ever read
the url. Normally the two name one upload and it makes no difference.

They can come apart. The sticker that started this carries a url for
/v/t62.15575-24/727834421_..._n.enc and a direct path for
/v/t62.15575-24/821212288_..._n.enc - two different uploads. Its key
opens one of them, and the direct path is the other, which is why every
attempt ended in a hash that would not match no matter how the download
was arranged.

So the url is now tried when the direct path fails, before the phone is
asked. It is turned into a path on the media host first - the scheme and
host come off, and so does the mms3 parameter, which belongs to the
whole url and not to a path the library is about to build a url out of.
One extra request, only on a failure, and it is the only copy left when
the direct path turns out to be the wrong upload.

Which leads to how a message gets into that state. A refreshed direct
path from the phone was written into the stored message before anything
had been downloaded with it. If that path then failed - as it does when
the phone re-uploads under a key this message does not carry - the
message had already lost the address it came with, and the url was never
read, so nothing was left to fall back to. The refreshed path is now
written down only after a download with it has succeeded.

The sticker this started with stays beyond reach, and that is now worth
stating exactly: the upload its key opens answers 410 Gone, the upload
its direct path names is encrypted under a key that never reached us,
and the phone answers a media retry - addressed by its LID and by its
phone number alike - with error code 2, no media. Both of the faults
above are real regardless, and either one on its own is enough to leave
a message in exactly this state.

Covered by two more tests: a message whose two addresses differ recovers
from the second one without the phone being asked, and the url is turned
into a path rather than used whole, while two names for one upload stay
one address; and a refreshed path that fails to open the attachment
leaves the stored message pointing at the address it arrived with.

Claude-Session: https://claude.ai/code/session_013tGr7Gzn1f9tTSj8jtSje7
… the library say what it sees

A message edited on the phone is still shown here as it was first written.

An edit usually arrives wrapped in editedMessage. The library unwraps that
and marks the event as an edit, and everything downstream asks only that one
question. There are two other shapes. A copy of an edit made on another of
our own devices can arrive as the bare protocol envelope, with nothing to
unwrap and so nothing marked. A newsletter carries the new text directly,
with the original's id and no envelope at all. In both, the stanza's own edit
attribute is what says what the message is, and nothing was reading it.

Missed, a correction is stored as a new message that matches no message kind,
so it is empty, hidden from the conversation, and the original keeps the text
it had. The stored message databases hold 867 rows in exactly that state.

So the question is asked in one place now, and it asks all three ways: the
mark the library sets, the stanza's edit attribute, and an envelope that says
MESSAGE_EDIT. Every caller - whether to record what the message said before,
whether to mark it edited, whether to unwrap the envelope, whether to raise an
alert - goes through that one function.

This is not yet shown to be the cause of the report that started this. The
message behind it left no trace at all: no row was written for the edit, in
any shape, so the event does not appear to have reached us. That is the other
half of this change.

The library was given no logger, so it has never recorded anything: not a
message that failed to decrypt, not one that never arrived. WHATSAPPGO_LOG_LEVEL
now takes DEBUG, INFO, WARN or ERROR and hands the library a logger writing to
stdout, which the desktop client already forwards when it is asked to show
backend logs. Anything else is refused and named, so a misspelling is not
silence. It stays off by default, and not only for the volume: at DEBUG the
library prints the contents of every message it handles.

A message that cannot be read is no longer discarded without a word either.
The library asks for it again, and until that answer arrives the message is
simply missing, with nothing in the conversation to show that anything was
lost - which is exactly how a correction would disappear. It is now recorded.

Covered by five tests: an edit carrying the stanza attribute lands on the
message it corrects, an envelope with no attribute does too, a corrected
newsletter message is recognised by its attribute alone, an ordinary message
and a message taken back are not mistaken for corrections, and end to end an
unwrapped correction replaces what the reader sees while keeping what the
message said before.

Claude-Session: https://claude.ai/code/session_013tGr7Gzn1f9tTSj8jtSje7
…y is waiting for

Turning on the library's logging showed what this program has been doing to
the phone. In eight hours it sent 7717 media retry receipts, 1716 of them for
a single conversation, in four bursts that line up exactly with the background
media sweep: 2591 at 16:00, 2532 at 13:00, 2532 at 22:00.

The sweep walks every message missing its attachment, and when it reaches the
oldest it clears its cursor and starts again from the newest. An attachment
WhatsApp no longer serves therefore comes round on every pass, forever. Since
60fd07d widened the retry to cover attachments that fail their integrity
checks - which is the right thing to do for a message somebody has open - each
of those passes turned into thousands of requests to the phone. The phone
answers almost none of them: of the ones it did answer, twenty came back with
no message in them at all.

Nobody is waiting for any of it. So the download now says whether somebody is.
An attachment somebody opened still asks the phone, which is the whole purpose
of a media retry and stays exactly as it was. The sweep asks for nothing and
lets the attachment fail quietly; it will be there to try again the moment the
message is opened. The same applies to a message with nothing stored for it at
all, which used to send the phone a request to deliver the message again on
every pass.

Covered by a test that the sweep leaves the phone alone while the same damaged
attachment, with somebody waiting, still reaches it exactly once.

Claude-Session: https://claude.ai/code/session_013tGr7Gzn1f9tTSj8jtSje7
…ut afterwards

Over twenty-eight hours the daemon logged "Exceeded maximum number of
notifications" 140 times, first at 17:15 and last at 21:43 the following day,
forty of them inside one busy hour. Each one is a message that arrived and was
never shown.

The bound on how many notifications this daemon leaves open was applied after
posting. Every message is delivered on a goroutine of its own, so a burst has
several of them in Notify at once: each reads the same count, each decides
there is room, each posts, and only then does each trim. The server is handed
the whole burst before anything is closed. Its queue is shared with every
other program in the session, and a second WhatsApp account runs a second
daemon against the same server, so the bound of eight was really sixteen from
this program alone, enforced late.

Room is now made before posting, and making room and posting are one action
under a lock of their own, so a second message cannot take the room the first
just made. The bound is four: a small share of a shared queue rather than most
of it.

Two smaller faults in the same bookkeeping. Closing to make room removed the
id from the list of open notifications but left the chat still naming it, so
the next message from that chat asked the server to replace a notification
that was no longer there. And the recovery from a refusal closed everything
this daemon held without forgetting any of it, so a second refusal closed the
same already-closed ids again while whatever was actually filling the queue
stayed where it was. Both now forget what they close.

Covered by tests that the count never goes past the bound at any point during
a burst rather than only at the end, that no chat is left naming a closed
notification, and that a refusal gives the whole queue back and leaves nothing
behind.

Claude-Session: https://claude.ai/code/session_013tGr7Gzn1f9tTSj8jtSje7
Reading a conversation puts a red warning across it: "a history page for this
anchor was already requested".

Scrolling to the oldest message loaded asks the daemon for more history. The
daemon keeps a thirty-second window per anchor and refuses a second request
for a page already on its way, so that the asker waits for the answer coming
rather than reading the silence as the end of the conversation. That refusal
is two askers agreeing. It is not a failure, and nobody asked for the page in
the first place - scrolling did.

The window reports whatever a request it made itself comes back with, unless
the request says otherwise, and this one never did. So it now says otherwise,
the way every other piece of work the window starts on its own already does.

Covered by the conversation test, which now scrolls past the oldest stored
message, answers the history request with a refusal, and fails if anything is
shouted at the reader.

Claude-Session: https://claude.ai/code/session_013tGr7Gzn1f9tTSj8jtSje7
Stamp the tree as v0.1.12 and write the release notes for the tag. The
eighteen commits since v0.1.11 let the app remove itself and everything it put
on the computer, hold a message until a chosen moment, read a channel you
follow and list a community joined through one of its groups, and move through
a voice note. They stop a freshly linked account inventing unread badges and
showing one person as two conversations, collect the account changes made
while the app was closed, recognise a correction made on another device, and
stop both the media sweep hammering the phone and message notifications going
missing during a busy spell.

Version is stamped in desktop/CMakeLists.txt, the flatpak manifest's ldflags,
the RPM spec and its changelog, and a new metainfo release entry.

v0.1.11 was tagged without a cut of its own, so the tree still called itself
0.1.10. This stamp brings it to 0.1.12 in one step rather than inventing a
0.1.11 nobody published.

No artifacts are built or published by this commit.

Claude-Session: https://claude.ai/code/session_013tGr7Gzn1f9tTSj8jtSje7
@shukiv
shukiv merged commit 0220700 into main Sep 28, 2026
8 of 14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant