Repository navigation
feat: let the app remove itself, with everything it put on the computer - #3
Merged
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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