• v2.0.0-rc3 a090693ca5

    v2.0.0-rc3
    All checks were successful
    release / appimage (push) Successful in 9m24s
    build / check (push) Successful in 10m43s
    release / rpm (push) Successful in 31m46s
    Pre-release

    Ghost released this 2026-09-26 12:58:50 +00:00 | 0 commits to master since this release

    Added in 2.0.0-rc3

    • A route to clearing the cache, in the sidebar directly above Sync. The two-step confirmation, the report of what was removed and the re-sync behind it have all been there since the pre-Fluent menu bar; the method survived the move to the Fluent sidebar and its route did not, so nothing in the application could reach it and there was no way to clear the cache short of deleting files under ~/.config/desecqt by hand. Read off the source, the sidebar's entire action vocabulary was Sync, Logout, the connection status and the last sync time. It goes above Sync rather than into Settings because it is not a setting: it changes nothing about how the application behaves afterwards, it discards the local copy and immediately reads it back, and it ends by running the very sync the item above it runs on its own. That keeps the two items that are about the local copy together, and leaves Logout as the last action before the status readouts. Giving the method a route is also what found it broken, in hand testing of this candidate rather than by the suite, and it is written here rather than as a separate fix because the route and the crash are one piece of work: there has never been a reachable Clear Cache for a user to have lost. Clearing the cache rebuilt the records slot as a plain dict where the constructor builds an OrderedDict, which the records cache needs because it evicts the least recently used domain by ordering, so the first zone opened after a clear died in its callback with AttributeError: 'dict' object has no attribute 'move_to_end', and so did every zone opened after it: nothing was cached again for the rest of the session, until the application was restarted. The structure was written out by hand in two places and the two drifted, and value equality cannot see that drift, because an empty OrderedDict compares equal to an empty dict. It survived the Fluent migration unnoticed because the method was unreachable from the moment the menu item went away, so the first person to click the new sidebar item would have been running code that had not executed since. The test that existed asserted that the route reaches the method, against a stand-in whose clear is a counter, so it was green throughout and would have stayed green: a test that proves a button is wired to a name proves nothing about what that name does. There is one builder now and both callers take the structure from it, held by three kinds of test: a behavioural one that clears and then caches a zone's records and reads them back, which is the re-sync the clear itself starts; a structural one comparing the cleared structure to the constructor's, key for key and type for type, so a seventh slot nobody has added yet is covered on the day it is added; and a source-level one that fails if the structure is written as a literal in more than one place, which turns the next drift into a failing test rather than a shipped crash. Six of the ten go red against the code as it stood. Clearing one domain never shared the fault, since it deletes in place rather than rebuilding and a delete works on both types, which is why it went on working while the rest of the cache was broken

    Fixed in 2.0.0-rc3

    • A bulk delete destroyed records the highlight said were not selected. This is one defect with one cause, and it reached ten modules. A qfluentwidgets table or list does not paint its selection from Qt's selection model: its delegate keeps a set of row numbers of its own, and the highlight and the accent bar are painted from that set alone. The table base class refreshes that set from six places and the list base class from four, and every one of them is a user action, a mouse release, a key press, Select All, selecting a row, clearing the selection, setting the current index. Nothing refreshes it when the rows themselves move. So the two halves of a selection could disagree, and the half every test read was the half that behaves. Reported from a live session: delete records 6 and 7 of nine in bulk, and records 8 and 9 slide up into those row numbers while the highlight stays where it was. Sorting is worse than rebuilding, because a rebuild at least empties the selection model and leaves the delete a no-op, while a column-header sort moves the selection model's rows and leaves the delegate's integers behind: measured on a real window against a ten-row records table, two picked rows read as model ['3', '5'] and painted ['3', '5'], and one header click made that model ['5', '3'] and painted ['2', '0']. The delete then ran, on records the highlight said were not selected. Three separate investigations had closed this as not reproducible, and all three had read Qt's selection model. The fix is not per call site, because the call sites are what kept being missed: one module now carries two entry points, one said once when a view is built, which wires all four routes by which rows can move so a highlight can never outlive the rows it names, and one said around a rebuild, which holds the selection by identity rather than by row number so it survives a refresh that changed nothing and does not come back for a row that has gone. A record is held by its name and type and only within one zone, so nothing that slid into a deleted record's row number is ever selected again
    • A token delete after sorting the table destroyed tokens other than the ones highlighted. Both tables on the Tokens page are sortable, and a header click is the same route as above. Driven against a real window with four tokens named after the numbers from the report: rows ['232', '234', '46', '49'] with 232 and 234 picked, one header click, rows now ['49', '46', '234', '232'], selection model holding ['232', '234'] and the highlight painting ['46', '49']. Delete would have destroyed 234 and 232 while the highlight named two others. On this page that is an API credential that cannot be recovered. The tokens table also loads twice for one visit, cache first and then again when the background refresh lands, and that second invisible rebuild took whatever the user had picked while the highlight stayed up, so Delete came back reading "Delete (2)" for two rows it would not touch. The selection is held by token id now, not by the row, which the sort moves, and not by the name, which the user can edit and which the table re-sorts itself by whenever one does. Policies are held by policy id
    • Select All on the Export page selected all 140 zones and highlighted none, and pressing Export exported nothing at all. A list view paints nothing for a selection written straight into the model, and typing in the filter box emptied that selection outright, because refilling the list is a model reset and Qt announces the change for neither the reset nor the restore. So the button went on reading "Export (140)" over a selection of none. The same reset defect sat on the DNS page, where a sync that dropped a single zone left the highlight on two rows that by then held other zones with Delete Zone greyed out beneath them, and on the DNSSEC page, where "Verify (2)" became "Verify all" under two rows that still looked picked. All three hold their selection by zone name now, which is unique within an account and the one thing about these rows that outlives a model reset; a zone that has left the account, or that the filter hides, has no row to hold a selection and is not put back
    • Search and Replace, the Queue page and version history all acted on rows the user had not chosen. Search and Replace rewrites and deletes records across every zone in the account and rebuilds its table from empty on every search, so a second search left the highlight standing over rows nobody had picked; it is held by zone, subname and type now, which is the unit Replace and Delete act on. The Queue page is the shape that only an identity can catch: neither of its tables empties itself, both write over the rows they already have, and when the row count comes out the same Qt moves nothing, so both halves of the selection keep their row numbers, agree with each other, and name items the user never picked. Measured: two failed items picked for a retry, one new item arrives, and Retry Failed came back holding the newly created item. It is held by queue item id. Version history refreshes its zone list on every showing of the page, so merely leaving it and coming back took the selection while the highlight stayed and Delete Selected went grey underneath it; it is held by zone name
    • The wizards painted rows that had gone. Picking twelve domains in the Add Zone wizard and then typing one letter into the filter left the wizard with none selected while all twelve were still highlighted. In the Custom Record Set builder, Remove Selected takes rows out from under the selection and the rows below move up into their numbers, so two rows the user had not picked were drawn as picked while the button that acts on them sat greyed out; there is no identity to hold a selection by there and none is wanted, so the paint simply follows the rows. In the Create Subdomain wizard a row picked before Re-check stayed painted through the whole round trip over a table the selection model had already emptied, and when the run ended early it stayed painted for good. Its selection aims nothing destructive, so this one is misleading rather than dangerous, and a completed re-check still lands on the first row
    • Import and export went around the API queue and its rate limiter entirely. The manager took no queue and had nine call sites straight against the API client, so a 12-record import reached the client 14 times with none of them queued and nothing at all showing on the Queue page. What that cost was the 429 handling: a queued item refused with a retry-after over a minute fails, pauses the queue and takes the application offline for the cooldown, and off the queue none of that happens. Driven against the unfixed code, a 200-record import refused on record 4 spent 198 more requests at a limit that could not clear for an hour, and then reported "Import completed: 2 records created, 198 failed" as a success. It stops now, counts what it wrote, what was refused and what was never attempted, and reports a failure. Each request carries a readable action and one of the Queue page's own categories, so it can be found and filtered there. Honest qualifier: the per-second pacing was not broken before this, because both threads already shared one lock inside the client. What was missing is 429 handling, Queue page visibility, priority and cancellation
    • A finished import left no restore point, and going back to the zone served the pre-import records again. The completion signal carried nothing and was wired to a plain zone-list reload, so the zone an import had just rewritten kept the cache entry it held before the import, and because that entry was fresh rather than stale, leaving the page and coming back served the old records. No version snapshot was taken either, which made an import the only mutation in this application with nothing to roll back to. Driven against the unfixed code, eight records imported into a zone holding one: nine record sets at deSEC, one in the cache, no cache invalidation and zero version history entries. The signal now names the zones the import wrote to and what it did to them, and the window takes the two steps every other mutation takes, per zone and in this order: record why the zone changed, then invalidate it and read it back, so the commit is taken from the authoritative state rather than from the list this process happens to be holding, which is the state from before the import. A run the queue stopped partway still names its zones, because a half-imported zone is exactly the state somebody wants back; a run that never reached the writing stage names none
    • A cancelled import claimed work it had not done. Every progress message was recorded as the last thing written, on a comment claiming the manager always reports after the change it describes. It reports before at five sites, all of them announcements of a phase about to start, so a cancel landing on the first of them was logged as "Cancelled after: Creating zone example.com..." for a run that had sent no request at all. The module log is the only record a cancelled run leaves and it was wrong in the worse direction. Progress calls say which kind they are now, only a report made after the change it names is remembered, and the cancellation message names the last change actually made or says plainly that nothing was written. A cancel is still seen at the first checkpoint after it is asked for, and still never lands mid-write
    • The Import page read as idle while it was still writing to deSEC. The confirmation drawer is a child overlay rather than a modal dialog and was measured not to cover Preview Import, which stayed enabled underneath it. Pressing Preview while the confirmation stood open and then confirming left the page's one worker slot pointing at the import thread with the preview thread running unreferenced, and the preview's completion marked the page idle while the import was still writing records. A third operation could then be started over the second, and leaving the page could only retire whatever the slot still pointed at, so the orphan outlived the page. The page is locked from the moment the drawer opens rather than when writing starts, with the progress bar left hidden for that window because nothing has been requested yet; cancelling the drawer by button or by Escape hands the page back. Both buttons follow one rule now, a file chosen and nothing running, so editing the path mid-import no longer switches them back on, and the worker slot retires whatever it already held whenever it is assigned
    • A replace wrote whatever the form held at the moment of confirming, not the values that were confirmed. The confirmation captured the checked rows and nothing else, and read Find, Replace, Subname and TTL off the live form when the callback fired, which is a different moment: the drawer is an overlay, not a modal, and none of the four fields is disabled while it stands open. Measured on a 1400-wide window, the drawer occupies x 389 to 1029 while the Replace field sits at x 1040 to 1403, so the form the confirmation is about is not even behind it. Driven: confirm replacing v=spf1 with read-and-confirmed, then type never-confirmed into the form, and "never-confirmed mx -all" is what goes to deSEC, past a two-step confirmation whose whole purpose is that the user has seen what they agreed to. The drawer never showed the values either, so "this cannot be undone" was a question the user had no way to answer. The four values are captured at click time now and the drawer lists the changes it is confirming, one line each. The change log had the same cause and would have agreed with the wrong write rather than catching it; it is built from the values the call actually sent. Honest scope: this needs the user to touch the form while the drawer is open, so the exposure is a user action and not a race
    • Two searches could run at once and the older one painted the table. The Search button greys itself out, but the four filter fields keep their Return key wired straight back to the search and are never disabled, so refining a filter and pressing Return, which is the natural thing to do once the button has gone grey, started a second search over the first. Nothing cancelled the older worker, nothing could reach it again, and the result handler could not tell whose answer it held, so whichever search finished last painted the table, which is usually the older one because it started on a colder cache. Measured: the second search answered with one MX record, the first answered after it with two A records, the Type field said MX and the table showed A. Apply Changes was enabled over that, the drawer offered to apply changes to 2 checked records, and a TTL of 60 went to two A records in two different zones; a subname replace would have renamed them instead, which is a create plus a delete. Leaving the page mid-search cancelled only one of the two and wrote two rows into a page nobody was looking at. There is one live search at a time now and one way it stops, and every result and progress report carries the generation of the search that asked for it. Separately, the search worker fetched the zone list itself when the zone cache was empty, off the GUI thread, around the queue and with no view of its pacing, and the page skipped its queued pre-fetch precisely when the queue was paused for a rate-limit cooldown, so the request went out at the one moment it must not: measured with the queue running and with it paused, 1 request, 0 of them through the queue, 0 queue history entries. The worker holds no API client at all now and reads only the cache; with nothing cached and the queue paused the search is refused and says why, and with the zones already cached it still runs, because reading the cache asks nothing of the API
    • Pressing Search on a profile that had never synced raised an error instead of searching. The zone cache answers with a pair of empty values when no cache file exists, and that pair is truthy, so it satisfied both halves of the guard in front of it and then failed on the GUI thread the first time Search was pressed. Every read of the zone cache on that page goes through one accessor now
    • A confirmation drawer's Delete button rendered as "De". The drawer applied its 340 pixel minimum width outside the clamp to the room its parent actually has, so a parent narrower than that floor, which the DNS page's zone list pane is at its own 220 pixel splitter minimum, still got a 340 pixel drawer centred on a negative x, and Qt clipped both ends of it with the destructive button's own label among them. The clamp to the parent is the outermost operation now, so the parent is always a hard ceiling. Four kinds of Settings card had the same shape: each gave its control a minimum width with nothing capping it, so a card narrower than the icon plus the title and description plus that minimum ran the control past the card's own right edge, taking a dropdown's arrow with it and squeezing the title on the way, since the title was the only other shrinkable thing in the row. The title and description column gets its room reserved first now and the control takes what is left, down to a 40 pixel floor, recomputed on every resize. The test that was meant to cover this only ever read the description label, which was never the widget actually breaking
    • A wizard run reloaded the records of a zone it had not touched. Both of the wizard's completion signals carried nothing in all three modes, and a signal that names nothing leaves its listener one safe answer, which was to refresh whatever was on screen. Measured with the on-screen zone's record cache older than the sync interval, which is the ordinary case for somebody who has just been through a four-step wizard, reads of the zone on screen that no run below writes to: Custom with one zone written 1 before and 0 after, Custom with three zones written 1 and 0, Create Subdomain 3 and 0, Reverse DNS 2 and 0. One wasted read per run rather than per zone, always landing on the zone the user was reading. Both signals name their zones now, so the records pane refreshes only when the zone being read is one of them, and the zone list is no longer repainted from a cache that is known to be wrong at that exact moment, since the signal fires because zones were created and none of them is in it
    • Opening a profile froze the window for a round trip before it had drawn anything. The API client ran a connectivity check from its own constructor, on the GUI thread, at every profile open, and saving a token ran a second one the same way. Measured against a 10 ms heartbeat with a 2 second stand-in round trip, the loop made 0 of the 200 ticks it owed, and that is the floor rather than the worst case: the rate limiter sleeps up to 2 seconds at the default 0.5 requests per second before the request is even sent, and the request itself is given a 10 second timeout. Neither call was a legitimate exception, the routine check having been queued at high priority all along. The constructor makes no request at all now, the window makes the same one check per profile open through the queue, and it respects offline mode, which the constructor's did not. The token slot goes the same way, which also means its answer reaches the sidebar instead of being discarded
    • Every sync sent the user back to the first zone in the sidebar. The five minute background timer, the sidebar's own Sync item and the sync a wizard asks for on finishing all did it. Measured on the shipped tree with the third of four zones selected, the selection and the records pane both came back on the first zone, and the run spent a record fetch nobody had asked for. The guard reading "only auto-select the first zone if nothing is currently selected" has been in place for a long time and could never work, because the line that hands the fetched zones to the model empties the selection three lines above it, and the answer to "is anything selected" after a model reset is always no. Both zone-loading paths read where the user is before the rebuild and put them back after it now, cursor as well as selection, and silently, so restoring the cursor does not itself trigger the records read this change exists to remove. Two cases are asserted rather than assumed: a sync that drops the selected zone from the account lands on the first zone, because the pane is showing something that no longer exists, and a first launch with nothing selected and nothing cached lands there too
    • Scrolling the zone list stuttered while a delegation and DNSSEC sweep was running, and went smooth again the moment the sweep finished. The cause is the spinner beside the zone being checked, not the worker doing the checking. The spinner ticks every 90 ms for the length of a sweep and every tick was announced to the view as a change of data, and a list view answers any change of data by laying the whole list out again and repainting the whole viewport: Qt's QListModeViewBase::dataChanged calls doDelayedItemsLayout, and neither that nor the repaint under it is narrowed by naming one row or two roles. So one spinning row cost a full relayout and a full-viewport repaint eleven times a second, whether or not that row was anywhere near the screen, and with one zone in flight out of hundreds it usually is not. Measured on a 400 zone list scrolled for 3 seconds with one zone in flight, against the same list idle: list layouts 0 per second idle, 11.7 before and 0.7 after; painted pixels 11,988 thousand per second idle, 2,141 thousand per second above that before and 268 thousand above it after. The extra paint worked out at 183,000 pixels per repaint for a row of about 14,000, so thirteen times the paint the frame needed, arriving inside a 16.7 ms budget. What it is not was ruled out with numbers rather than by inspection: the worker burns 0.15 ms of CPU per zone and, at the 0.3 second floor between zones, runs under 0.05% duty, where a background thread only begins costing the GUI thread frames at about 25%, and results landing, the sync timer and model rebuilds during a scroll were each driven and measured without degrading the heartbeat. The spinner hands back the rows it is animating now instead of announcing anything, and the widget repaints each of those rows through its own rectangle, skipping any row the viewport does not intersect; a genuine change of state still goes out as a change of data. Skipping an off-screen row is safe because the model reads the spinner and the padlock afresh on every call and caches no pixmap, so a row skipped while off screen is already correct the first time it is drawn. Only a real profile with the checks switched on reproduces this, and that setting is off by default, which is why it went unnoticed; a demo profile cannot reproduce it at all, because its sealed worker answers a whole batch on the GUI thread in one turn, so the timer starts and stops without ever animating. The three new tests are relations rather than constants, layouts against ticks, repainted area against the viewport, and nothing at all for a row off screen; reverting the one connection point turns four of them red
    • Flipping Debug Mode did nothing at all until Save Settings was pressed. It was reported as toggling debug mode leaving no log entry, and that was the smaller half of it: the switch wrote no setting and changed no log level either, so debug mode was never actually being enabled, while the card directly below it, Delegation and DNSSEC Checks, applies the instant it moves. Two switches side by side in one group, behaving differently, with nothing on the page to say which was which. The switch is live now, on the pattern the four DNSSEC settings here already follow, where the page persists the value and says the line and the window carries it out. That is a change in how the page behaves and worth knowing: flipping the switch raises or lowers the log level there and then, and a Save is no longer any part of it, which is the behaviour the rest of the group already had. The one method that sets the level takes the new state as an argument and is still the only caller of setLevel in the application, with the startup path folded into it, so the level is decided in one line of code rather than being set from the same configuration key in two files. Three tests hold it and the third is the one that matters: a runtime test drives a real settings page against the window's real handler bodies, wired from the connections parsed out of the window's own source rather than written down in the test, so deleting the connection turns the suite red instead of leaving a test that passes over a switch the user cannot see working
    • Changing a setting left nothing in the Log Console. The Settings page was the one page interface here without the signal that carries a line to the on-screen console, and nothing else feeds that console: a module logger writes to the log file and to the terminal, and the console is fed by that signal alone, so anything this page had to say went somewhere the user was not looking. It carries the signal now and the window listens to it, and each of these says one line when it actually changes: the API URL, the sync interval, the rate limit, queue persistence, history retention and the theme. Each is compared against the stored value first, so pressing Save having touched nothing still says nothing. Connecting the signal also exposed a doubling that had to go with it, because the debug mode and sync interval handlers were both writing to the console from the apply step unconditionally, on every Save and whether or not the value had changed; both of those lines are gone and the page's own gated line replaces them, and a test fails if any of the window's apply handlers writes to the console again. What is deliberately left is the connectivity check a Save triggers, which still logs, so a Save is not literally silent: those lines report a request the window went and made rather than claiming that a setting changed, and not re-checking is wrong when the API URL is the thing that changed
    • The documentation called each profile's version history shared. Version history stopped being installation-wide in 2.0.0-rc2 and the old wording survived in the passphrase panel, the roadmap, the README and three of the reference documents, which is the one word a user reads while deciding whether a passphrase is enough. It says per profile now, and names the read-only installation-wide archive beside it; the log genuinely is one shared file for the whole installation, so that half of the sentence is unchanged. A test pins the passphrase panel's wording, since the wrong word shipped through a whole release unnoticed. The README was cut back to an introduction in the same pass, from 377 lines to 215, with every section that left landing in the document that owns the subject rather than being dropped: the install reference to doc/INSTALL.md, the signing key and its fingerprint to SECURITY.md next to the hash-pinning it already documents, the privacy statement to a file of its own because somebody deciding whether to hand this thing an API token reads it, and the rate limit, DNSSEC and record-management material to the documents that already covered most of it. What you get now sits directly under the introduction instead of third down the page, and covers the demo account and the wizard's reverse DNS mode, neither of which the README had mentioned at all. SECURITY.md also gained the one fact nothing in the repository stated anywhere: every valid deSEC token can read every zone in its account, and RRset policies restrict writes only

    Technical Improvements in 2.0.0-rc3

    • Eight invariants are now enforced by detectors that run in the test suite. Seven audits swept the codebase for classes of defect and four waves of fixes landed what they found, but fixing the instances is the smaller half: without a check, the class reopens in code written next month, and the audits had left their detectors in /tmp. They live in scripts/ now, are discovered by the test runner and are enforced on every push. Six are hard gates throughout, which fail on any finding outside a reviewed table; a seventh gates the application source and ratchets its tests, pinning a count for a review queue rather than a list; the last is three ratchets and a report, for a rule whose output is mostly correct code and where failing on the count would fail the build for writing documentation. Each carries a negative control in both directions, because a detector that cannot recognise the fixed shape is how one of them arrived reporting 59 source sites for a class with 2 open instances, and a report that is mostly wrong is one nobody reads twice; each also carries a historical control that scans the tree from before the fix and requires the confirmed instances to still be found there, so a detector that has quietly stopped detecting fails rather than passing. A stale entry is a failure too: five suppressions were found still switched on for defects that had already been fixed, two reviewed sites were being reported as new findings because the allowlist was keyed on a line number that had moved, and an entry can now record the exact guard text it rests on and stops applying when that text leaves the module, so a fix that deletes the guard cannot be waved through by the review the guard was the whole reason for. doc/STYLE-GUIDE.md carries all eight, each with the shape it is about, the fix shape, and the file that enforces it. Porting them also corrected one of the measurements they make: the rule requiring a number stated in prose to be reproducible had been searching a window that mostly contained the prose itself, so nearly every number was counted as anchored by its own sentence; excluded properly, it is 39 anchored against 228 bare, and 228 is the baseline
    • The shared code survey now tells two same-named signals apart. Six audits asked which sites connect to a given signal and were handed every signal of that name in the tree: src/ declares twelve signal names on more than one class, and the worst of them collapsed twenty-one connect sites across five worker classes into one answer. The receiver expression is resolved to a class now and walked up its bases to the one that declares the signal, so a panel's cancelled no longer collects the four other cancelled signals, and every site the resolution cannot reach carries the reason, because a wrong identity is worse than a missing one. Of the 96 connect sites whose name matches a signal declared in src/, 59 resolve; the rest are receivers static reading cannot type and say so. A companion script runs the CI check job locally in the same container image the runner uses, against a clean export rather than the working tree, because the last release was verified on a machine with fonts and Python 3.14 and went red on the runner on one pinned pixel constant
    • Twenty-one unreachable methods removed. A consistency register found 25 members with no caller anywhere in the application, found by resolving calls to a class and iterating to a fixpoint, and each was independently reconfirmed before deletion rather than trusted from the list. Ten were leftovers from the menu bar the Fluent sidebar replaced, and one of those is the reason a fixpoint was needed: its only caller was another dead method. Eleven were accessors elsewhere, among them five configuration setters superseded by the generic pair, which wrote to the in-memory configuration without ever saving it, a silent no-persist trap for anyone who reached for one, and a zone loader that built a worker thread with no queue check at all, harmless only because nothing called it. Two exceptions are deliberate and both are written down: the cache clear is no longer dead, having been given a sidebar route in this release, and the keyboard shortcuts dialog stays unrouted by the owner's decision, the shortcuts themselves being live and documented, and now says so in its own docstring so the next sweep does not quietly finish the job. Two links of one chain are left in place with the product question they are waiting on recorded beside them, kept apart from the reviewed allowlist so that landing the decision is a deletion rather than an argument. A test fails if any removed name reappears
    • A test harness was leaving its git repositories behind. Each of the 54 tests in the version-snapshot file built a version manager over a fresh temporary directory, closed the manager and left the directory; four and a half thousand of them had accumulated, the oldest months old, and a full run of that file added 53 more. The harness takes its own directory away now, and not through a context manager, because the order matters: the manager runs git on a worker thread, and removing the repository under a live subprocess is a race this project has already hit twice, so the close is awaited and the worker is asked whether it actually stopped before the tree goes. The fix belongs in the harness and not in the manager's own close, since in the application the caller's directory is the user's entire version history
    • 1634 tests across 64 files, up from 1394 across 46
    Downloads
  • v2.0.0-rc2 ab78e1a299

    v2.0.0-rc2
    All checks were successful
    release / rpm (push) Successful in 15m7s
    release / appimage (push) Successful in 5m37s
    build / check (push) Successful in 7m20s
    Pre-release

    Ghost released this 2026-09-22 17:38:43 +00:00 | 55 commits to master since this release

    Added in 2.0.0-rc2

    • Every profile now has its own version history. Zone snapshots lived in one installation-wide git repository keyed by domain name, so two profiles holding the same domain shared a single history: restoring the oldest version of example.com in one account could write another account's records over it. Each profile keeps its snapshots in its own directory now, and the version manager refuses to be constructed without being told which one, so a writer over the shared repository cannot be built by leaving an argument out. The old repository is neither deleted nor merged, because folding two accounts' histories of one name together would invent a history neither of them had. It opens read only instead, through a class that has no method capable of running a writing git command, and the History page lists what it holds under Earlier shared history, hidden when there is nothing in it, with Restore disabled and the reason beside it
    • An account summary on the Settings page: the profile, the account email, how many domains it holds, how many of those are verified, and how many record sets are cached. The verified count is green only when every domain is verified and red when one is not, because deSEC stops raising the domain limit automatically while any domain is unverified, which the card's tooltip explains. The email and the domain limit arrive in a response the application already asked for on every sync and then discarded, so neither costs a request. Reading them needs a token carrying the manage tokens permission; without it the request is refused, which the card used to report as the account not having synced yet. That is a different thing and it was not true. It names the missing permission now, and stops asking: a refusal that reflects a permission the token does not have cannot answer differently a minute later, and it was being retried every sixty seconds forever. A server error or a network failure is still retried, and changing the token clears the latch
    • Qt's own messages now reach the Log Console. The Wayland and X11 platform plugins, QSslSocket and QProcess log through a channel entirely separate from Python's logging, and previously reached only the terminal that launched the process, which is nowhere at all for anyone starting this from a desktop menu. A handler is installed as the first thing done after the application object exists, buffering whatever is logged before the Log Console is built and flushing it once it is. Qt calls that handler on whichever thread produced the message and this application runs several, so it only ever emits a signal and the console is written on the GUI thread. One category is dropped outright rather than rate limited: typing a 43-character sentence into a plain text field on a GNOME Wayland session produced 589 qt.qpa.wayland.textinput messages in 0.67 seconds, and nothing else came close
    • A demo profile says what it is in the Log Console, as the first line there, ahead of the opening sync. It says the two things the banner across the top of the window does not: the data is generated, and the live delegation and DNSSEC checks do not run here, with every padlock derived from the stored records instead. Someone looking at a silent padlock column had nothing to explain it. "Retrieved N zones from API" was untrue on a demo profile as well, printed directly under a banner saying nothing here reaches the network; it names the demo store now, and is untouched on a real profile

    Fixed in 2.0.0-rc2

    • Help tooltips turned up anywhere on the screen under Wayland, nowhere near the question mark they belong to, with "This plugin does not support setting window opacity" repeating on the console. The tooltip was a top-level window, and on Wayland a top-level window is an xdg_toplevel surface, which the xdg-shell protocol gives a client no request to position: only a popup can be placed, through a positioner. So the move was a number Qt kept client-side and the compositor never read, and the box landed wherever the compositor's own policy put it. Reading the position back does not show this, on either platform, which is why the arithmetic always looked right. The tooltip is an ordinary child widget of the window now, positioned in that window's coordinates and clamped to the window's own edges rather than the screen's, which is Qt's own arithmetic with no compositor in it and behaves identically on Wayland, on X11 and on the offscreen platform the tests run under. Nothing else about the box changed: the theming, the gap from the icon, the drop shadow and the width cap are all as they were. Verified on a real session: zero opacity warnings where there had been eleven, and no second toplevel surface under Wayland protocol logging
    • A one-word help tooltip was drawn as a wide slab sitting to the left of its icon. Every tooltip label was forced to the same 320 pixel width and the box is centred on the button it belongs to, so a short tooltip's box began well left of a small icon. The label is sized from its own text now, capped at the same 320 so a long tooltip still wraps exactly as before
    • A help tooltip's background was black on some pages and right on others. Qt resolves a native tooltip's style against the widget it is shown for, so any ancestor carrying its own stylesheet defeats an application-wide rule, and one of them does; which tooltips looked correct therefore depended on where in the window they happened to sit. qfluentwidgets ships a themed tooltip and the filter that installs it, and this application used neither. Every help icon uses them now, so the styling no longer depends on the widget tree above it
    • An apostrophe in a help tooltip was painted as '. The text is escaped for HTML and the escape was quoting apostrophes too, which only matters inside an attribute and this text never is one. A single-line tooltip with no other markup is classified as plain text, so the entity was rendered literally; a second line hid the defect by making the same string read as rich text. Ampersands and angle brackets are still escaped, since those can genuinely start a tag or an entity anywhere
    • Every version history entry described a change it did not contain. The snapshot was taken from the record list the widget was already holding, which is the state from before the write: after a delete it still held the record being deleted, after a create it did not yet hold the one just created. So every commit carried the state from one mutation earlier under a subject naming a change that was not in it. Where that stale list happened to equal what was already committed, git found nothing to commit and the entry was skipped in silence, which is the "only the first record I created was ever recorded" of the report. Restore worked throughout, so the safeguard looked present and was not. The commit is taken from what the API hands back on the refresh that already follows every mutation, so it costs no extra request, and the reason it is filed under is keyed to the zone it names, so a fetch for a zone the user has clicked away to cannot commit the wrong records under this one's message
    • A zone emptied of all its records could not be recorded at all. Two guards tested the record list for truth rather than for existence, and an empty list is false, so deleting the last record in a zone wrote nothing. The state a user is most likely to want back was the one state the history could not hold
    • A bulk delete left no version history entry whatsoever, neither a record of the state before it nor anything to restore back to. It files one entry for the batch now, naming what was actually removed rather than what was attempted: up to three records by name, "Delete A record for 'mail', MX record for '@'", and past that a count with a sample, "Delete 5 records: www A, mail MX, git SSHFP and 2 more", because the History page shows a subject on its own and one running to a dozen names is one nobody reads. A batch the API refused in part records the accepted count with the refusals named beside it; a batch refused in full records nothing, since nothing changed. One record deleted through Delete Selected and the same record deleted from its own row used to leave two differently worded entries, and the one that said less came from the button most people use
    • A record opened for editing while its zone was still loading was saved into the wrong zone. Selecting a zone that was not cached queued the fetch and left the previous zone's rows on screen under the new zone's name, so opening one of them edited it against the zone now selected, and saving created the old zone's record in the new one. The window was however long the queue took, seconds normally and minutes behind a rate limit, and it was reachable on any uncached zone, which includes a fresh profile and anything after clearing the cache. A second fault made it permanent: a failed result carrying no message matched neither branch of the handler and did nothing at all, so the wrong rows stayed. Rows are dropped the moment the selection changes, an answer for a zone already left is discarded, and every row carries the zone it was built for, checked at the one place editing and deleting read a row
    • A bulk delete confirmed after switching zones deleted from the wrong zone. The confirmation drawer is a child overlay rather than a modal dialog, so the sidebar stays live while it stands open, and the callback read whichever zone was selected at the moment of confirming rather than the one the rows had been collected from. Records were then deleted from the new zone wherever a name and a type coincided. The per-row check could not catch it: it had already passed, against the right zone, before the drawer opened. The zone is captured with the rows now
    • Everything a queued mutation did afterwards followed the user rather than the write. A save, an edit, a single delete, a restore and a bulk delete all answer a round trip after the click, and clicking away while the queue sits behind a rate limit is ordinary rather than contrived. The zone that had actually changed kept a cache from before the change, so the next visit was a cache hit, not stale by the clock, showing the record as it used to be, which reads as the save having been lost. The zone the user had moved to lost its cache and was read again in full, a request spent on a zone nothing had happened to, its table blanked to "Loading..." on the way. And the history entry, correctly keyed to the written zone, had no read of that zone to be taken from, so it waited and was absorbed by the next change made there: one entry covering two actions taken far apart, under the timestamp of the later one. Every mutation names the zone it wrote to now, and that zone is the one invalidated and the one read back, off screen when it is not in view. A zone delete blanked the records pane unconditionally for the same reason, telling a user their view had been cleared as part of removing a zone it had nothing to do with
    • Two bulk deletes running at once shared one set of counters. A batch answers one record at a time, so it stays alive as long as its slowest request, and a second batch started before the first finished reset the zone and the three counters underneath it. The first batch's answers were counted into the second's tally, its completion fired on one of the second batch's answers rather than on its own last one, and fired again on every answer after that. The zone that was emptied got no history entry at all and the zone that was not touched got several, saying three, four and five records deleted for a batch of three. Each batch owns its own state now, and a second is refused while one is running, said out loud in a notification and in the Log Console with the busy zone named and how far along it is, because a keystroke that silently does nothing is what makes somebody select the rows and press Delete again
    • A multi-row Delete went past the API queue and its rate limiter entirely. The toolbar button and the Delete key ran two copies of one flow and the key's copy had drifted, reaching a worker thread directly instead of the central queue that orders and paces every other write in this application. The button's guard was not a guard either: it greyed the bulk buttons out, and the handler that runs on every change of selection re-enabled them from the selection alone, so they came back by themselves the moment the table was touched. Both ways in run one implementation behind one guard now
    • Saving a token could write its name and permissions onto another token's row, after which deleting that row deleted the wrong token. The save goes through the API queue, which handles one item at a time and holds everything behind a rate-limit pause, so the answer arrives seconds to minutes after the click and the user is free to select something else in between; the callback read the selected row at that moment instead of the token it had been given. Delete builds its list out of exactly what was written there, so the next Delete destroyed the token that had just been renamed and left the selected one alone. An API token cannot be recovered once deleted. The answer is matched to the row carrying the id it was saved for, and does nothing at all when no row carries it. Reloading the list instead would have been simpler and costs a request through the same limiter to fetch what the answer already contains, and clears the table, dropping the user's place mid-edit. A token's policy panel had the same shape and now carries the token it was opened for
    • A refused policy list was displayed as a token having no policies. In deSEC a token with no policy is unrestricted, so a tightly scoped token whose policies could not be read showed as having no restrictions at all, which is the opposite of the truth for anyone deciding whether it is safe to use. A refusal says so now, and is structurally a different thing from an empty list rather than the same thing differently worded. Creating a token also creates its default policy, and that second request was the only one in the application built with no callback: if it failed, the secret was shown and the token reported as created while it in fact had no policy, and therefore unrestricted write access to every zone. That failure is reported now, stays on screen until it is dismissed rather than fading, and says what state the token is in and what to do about it
    • Leaving Search and Replace mid-search froze the window for two seconds and then carried on searching. The page asked a thread with no event loop to quit, which does nothing, then waited two seconds for it: measured at a full 2.00 seconds of frozen interface, after which the search continued anyway. Its callbacks were never invalidated either, so one landing afterwards started a fresh search on a page nobody was looking at, and the progress bar was still running on return. Import and export used a terminate instead, which stops a thread wherever it happens to be, and for an import that is potentially between two writes, leaving the account half changed with nothing saying where it stopped. All three cancel between items now and report what was actually written. A cancelled merge import was also reported as nothing written, because the cancellation was caught by a handler meant for real errors
    • Text on the Queue page was unreadable on the light theme. It used one set of colours for both themes where every other module pairs them: measured against the light background, Completed read at a contrast ratio of 2.10 and Pending at 1.95, where the floor for the weight they are drawn at is 4.5 and 3.0. They reuse the pairs the record editor already defines, each checked against the floor that applies where it is actually drawn. Links were a flat blue at 2.51 on light. They stay blue rather than taking the accent, which is now a strong red: a red link reads as an error beside ordinary text, which is the same reason the version number is not accent coloured. One pair serves the About page, the notification drawer and the confirmation drawer, which had been three copies of the same wrong blue
    • Toasts in the profile window cut their own message in half. Deleting a profile raised a success message with its second line clipped off the bottom of a 50 pixel bar. Two wrapping mechanisms were fighting and nothing recomputed the height: the library wraps the text itself and then sizes the bar to it, which is what every other toast in the application uses, and a second pass layered on top of that turned on the label's own word wrap and pinned the bar narrower than the first had assumed, so the label wrapped again at the new width, needed more height for it, and nothing asked for any. Measured in a 720 pixel window, the shipped delete message had 20 pixels of room for text that needs 34, and a four-line message 34 for 102; only a one-line message was ever safe. The bar sizes itself to its text first now, exactly as the rest of the application's toasts are sized, and caps its width only when that does not fit, measuring the chrome off the bar it has just laid out rather than counting up the library's margins. A short message is no longer stretched to the cap, and a display name with no space or hyphen in it is broken across lines rather than pushing the bar out past both edges of the window, which a 60-character name did at 644 pixels inside 600
    • The raw body of a failed API response was printed in the log, where a JSON error object reads as line noise to someone looking for what went wrong. The line names the subsystem, what was attempted and the cause now, a 404 names the URL that was not found, and the body moved to a debug line, one setting away
    • A zone created or delegated by a wizard waited up to ten minutes to be checked. Two subdomains created with the Create Subdomain wizard, with both checks switched on in Settings, still had no verdict a minute later; verifying by hand worked. The wizard cleared the zone's stored override and said in its own docstring that the zones were due for a recheck, but never asked for one, and nothing reads the store looking for work except the background sweep. The plain Add Zone path has checked its new zone at once all along, so the two ways of creating a zone behaved differently for no reason a user could see. A newly created zone is also registered rather than merely enqueued, because it is not in the cached zone list until the next sync lands and a result for a zone the checker does not know about is discarded, which would have left the blank padlock exactly as reported. The other two wizards had the same gap: Reverse DNS mode reported nothing at all, so a batch of reverse zones carried no padlock until a sync and then a sweep had reached them, and Create Subdomain left out a zone it created but could not delegate, which with no icon reads as a zone nobody can resolve rather than the undelegated one it actually is. Each zone is named once per pass, so a row created and then delegated is one check rather than two
    • Nine pixels of misalignment on five pages, wherever a help button faced a bare caption: Tokens, Queue, History, Search and Replace, and Import and Export

    Technical Improvements in 2.0.0-rc2

    • Every record mutation read its zone back twice. The mutation's own callback invalidates the zone and reads it back, which is where the history entry is taken from, and the signal it then emitted carried no zone, so the window refreshed whatever was on screen. The first read is still queued at that point, so the cache is still empty, the second misses as well and a second request goes out. One wasted request on every save, edit and delete, against a daily rate limit this application does reach, and it grew worse once the read started following the mutation rather than the user, because a save to one zone while another was on screen then re-read the zone on screen. The signal carries its zone now, and carrying it is the promise that the emitter has already invalidated that zone and read it back
    • A restore read the account on the GUI thread. Working out which record sets a restore has to drop was done straight against the API client from the thread that was about to repaint, so the window froze for a round trip and the request never passed the rate limiter every other call goes through. It is queued now, with the write built and enqueued from the read's callback, so the read still precedes the write
    • The record selection is cleared deliberately when the zone changes, rather than by accident. It was cleared in fact, but only as a side effect of the table being rebuilt, so a property that guards a destructive action rested on how a widget happens to drop its rows. Whoever later made a rebuild preserve the selection, which is a reasonable thing to want for a plain refresh of the zone already in view, would have handed Delete Selected a set of rows nobody chose without ever seeing the connection. Removed alongside it: two guards reading attributes that are assigned nowhere in this application, and so had been false on every run there has ever been, and the two methods under them, which nothing called and nothing was connected to
    • The guard that keeps raw response bodies out of the log could not see most of them. It recognised a payload only when it was bound by unpacking a method call and then interpolated into an f-string. Fed nine variants of the same defect it caught one, and the miss that mattered is that every queue callback in this application takes its payload as a parameter, which the guard was structurally unable to see, so the next person to write one would have shipped it green. It catches all nine now, with the callback rule narrowed against the real call sites first, because a guard that reports false positives gets switched off
    • Geometry assertions no longer assert which fonts are installed. A build failed on two comparisons a single pixel out, both of heights Qt computes from a font, in a container that has fontconfig and no font packages. The ten text-derived comparisons allow two pixels now, which is useless to a real defect and sufficient for a font: every misalignment these tests were written to catch was an order of magnitude larger, between 11 and 45 pixels. Pixmap arithmetic is left exact, since rendering at a height times a ratio has a right answer no font can change
    • 1394 tests across 46 files, up from 976 across 35
    Downloads
  • v1.8.0-beta d46a5b5cb9

    v1.8.0-beta
    Some checks failed
    build / check (push) Has been cancelled
    release / appimage (push) Successful in 5m39s
    release / rpm (push) Failing after 7m48s
    Pre-release

    Ghost released this 2026-09-15 18:33:51 +00:00 | 114 commits to master since this release

    Added in 1.8.0-beta

    • A profile window, shown before the main window every time. Firefox and Thunderbird open a profile chooser first and this is the same idea for the same reason: the profile decides which token, cache and settings the rest of the session is built from, and a protected profile has to be unlocked before any of that exists. Everything to do with profiles now happens there, so the sidebar's Profile page is gone; switching profiles re-executes the process, which is why it belongs where the process starts. Closing the window quits, and there is no cancel to nowhere
    • A profile's API token can be locked behind a passphrase. Scrypt (n=2^15, r=8, p=1) derives the key, Fernet encrypts the token, and a verifier blob tells a wrong passphrase from a corrupt file. Set a passphrase when creating the profile and the key exists before the token does, so the token is never written unprotected even once. The parameters are stored per profile rather than read from a constant, so raising them later does not strand what is already encrypted
    • The window says exactly what a passphrase protects, in the window and not only in the documentation: the API token, and nothing else. Cached zone and record names, the queue history, the shared version history and the shared log stay readable on disk. Overstating that would be the real failure of the feature
    • Forgotten your passphrase? There is no recovery, by design, so the escape replaces the passphrase panel in the same slot and asks twice before clearing the stored token. The profile, its cached zones and records and its settings are all kept, and the old token keeps working until it is revoked at deSEC
    • One instance per profile. Opening a profile takes an exclusive flock on a file in its directory, held for the life of the process, so a second window on the same profile is refused by name. Two windows on two different profiles work, which is the point: two accounts at once. The kernel drops the lock however the process ends, so a crash leaves nothing to clean up
    • Reverse DNS wizard mode. Turns IP prefixes into the reverse zones that cover them, 192.0.2.0/24 into 2.0.192.in-addr.arpa and 2001:db8:1:2::/64 into its ip6.arpa name, and optionally fills each one with PTR records from a naming pattern. Reverse delegation cuts on a label boundary, so a prefix that lands on one is a single zone however large it is, and one that does not is covered by several at the boundary below it; a /22 is four /24 zones. A /25 to /31 is refused by name, since sharing a /24 needs RFC 2317 delegation from whoever holds it, and IPv6 prefixes below the last nibble are refused for the same reason
    • The wizard's preset library reworked against current vendor documentation. 70 presets in 18 categories becomes 61 in 16. Every preset holding a hardcoded third-party address was checked against that vendor's own documentation rather than memory
    • Six presets added: Stalwart Mail with the MTA-STS trio and DANE, a null MX "Disable Email" set for a domain that should neither send nor receive mail, a current Google Site Verification, a Bluesky handle, an HTTPS service binding, and Apple iCloud+ custom email
    • Every multi-select list now says so. Twelve views accept Ctrl+click and Shift+click and not one of them advertised it, so the bulk actions beside them read as though they applied to whatever single row was highlighted. A question mark beside each list carries the gestures in its tooltip
    • The Variables step groups each variable under the preset that asks for it, in the order the presets were picked, instead of one alphabetical run of unfamiliar names with no way back to the previous screen
    • A logo, in the four places the application introduces itself: the title bar's upper left corner and the task bar entry beside it, next to "Choose a profile" in the window that opens first, and above the name and version on the About page and in the About drawer. It follows the theme, and follows a theme change as it happens rather than waiting for the page to be reopened. The artwork is chosen by measured luminance rather than by its file name, because the names describe the colour of the drawing and not the theme it belongs on: picking the wrong one does not fail, it renders the logo nearly invisible
    • One name everywhere: "deSEC Qt DNS Manager". The window title and the About page said "deSEC DNS Manager" while the package, the desktop entry, setApplicationName and the documentation said the longer form, so the application introduced itself under one name in its title bar and another in the menu that launched it. Both windows now take the name from about.APP_NAME, which is where the version already lives, so there is no literal left to drift
    • The About page leads with the licence, ahead of the author and the credits. It is what a reader goes to that page to find, and it had been the last line of a panel that has to be scrolled

    Fixed in 1.8.0-beta

    • A reverse DNS run could overwrite somebody else's PTR records. If a zone planned as "create" turned out to exist, Retry Failed probed it and treated any answer as proof its own earlier create had gone through, then wrote 254 generated PTRs into it. A create that timed out after succeeding and a zone belonging to somebody else look identical to that probe. The zone's contents decide now: any PTR record in it means the zone is left alone, which is the rule the review step already applied
    • Unlocking a profile with the Enter key did nothing except silently clear what had been typed. The window is a dialog whose Open Profile button is the default one, so Return re-ran Open Profile, which reopened the panel and emptied the field. An open panel owns the Return key now
    • Cancelling a queued item from the Queue page could wedge a wizard permanently, with Back, Next and Start Over all refused and nothing to do but restart. A cancelled item never invoked its callback, so anything counting outstanding callbacks waited forever. Every enqueued item's callback now fires exactly once, whatever becomes of the item
    • Records in reverse zones sorted 1, 10, 11, 2. Names and content sort the way a person reads numbers now
    • A profile's cache directory was created shared and then repointed. The cache manager's constructor creates its directory, restricts it to the owner and deletes stale files from it, and it did all of that against a path shared by every profile before being given the profile's own. No data crossed between profiles, but the directory should never have existed
    • Error toasts in the profile window ran past the edge of the window and lost the end of the message. They are capped, centred and wrap to a second line
    • Confirmation drawers spanned the full width of the window, putting a single sentence across roughly two thousand pixels, which read as far heavier than the thing being confirmed. They are capped, centred cards now, and logging out asks once rather than twice: it takes nothing away
    • The application had no icon at all, in any window or any task bar. Startup asked for QIcon("icon.png"), a relative path to a file that has never existed in the repository, which Qt answers with an empty icon and no complaint, so every window carried whatever placeholder the desktop draws for an application it cannot identify

    Technical Improvements in 1.8.0-beta

    • The blocking work is off the UI thread. The DNSSEC status store rewrote its whole file once per finished check and a sweep finishes 25, so a background pass cost 178 ms in one burst at 800 zones; it batches now, at 7 ms. Zone snapshots ran three git subprocesses on the thread that was about to repaint, at 14 ms each; they run on a worker now, at 0.07 ms. The paint path copied a dictionary per visible row per repaint, measured at 33,000 copies in a single scroll
    • The records held in memory are bounded at fifty domains, least recently used evicted first. Every domain visited used to stay for the life of the session, which measured 43 MB after 500 zones of 200 records, and a single reverse /24 holds 254 PTR records. Nothing is lost by an eviction: the file cache still holds it, and a miss costs about a millisecond
    • The queue keeps a thousand history entries by default, up from 500
    • Counted nouns go through one helper, so nothing in the interface says "1 records"
    • No em dash appears in any tracked file, enforced by a test that walks them all
    • Every example in the repository is documentation or private address space (RFC 1918, RFC 3849, RFC 2606). Real prefixes, reverse zones and domain names from a live account had reached test fixtures, docstrings, UI copy and the documentation
    • The logo is drawn at the display's device pixel ratio, so a HiDPI screen gets the artwork at its real resolution instead of a logical-size pixmap stretched to fill twice the pixels. The ratio is part of the cache key, or moving a window to a second display with a different ratio would be served the stale pixmap. The source assets were regenerated larger to cover it: 614x512, enough for the largest size drawn at a ratio of 3, from the untouched originals
    • The logo assets ship inside the application, under src/assets rather than img/. The RPM copies src/ wholesale and the AppImage bundles only what its spec lists, so reading from img/ would have worked from a source checkout and left a gap in both installs; the frozen build reads them through sys._MEIPASS, since PyInstaller rewrites __file__ to a path inside the archive
    • The old logo/ directory is gone and the packaged desktop icon is generated from the new artwork, square at 256 px because it installs into hicolor/256x256/apps/. It is 46 KB against the old icon's 1 MB. A test now walks every file path the release workflow, the RPM spec and the README point at and fails if one stops existing, because a rename does not fail at the point of the rename, it fails in CI part way through a release
    • 657 tests across 24 scripts, up from 524, including new suites for the version manager, cache isolation, the memory bound, the selection hints and the logo
    Downloads
  • v1.7.0-beta a6bbf8d34f

    v1.7.0-beta
    All checks were successful
    build / check (push) Successful in 2m25s
    release / appimage (push) Successful in 4m11s
    release / rpm (push) Successful in 11m0s
    Pre-release

    Ghost released this 2026-09-13 16:36:57 +00:00 | 176 commits to master since this release

    Added in 1.7.0-beta

    • A delegation indicator beside each zone's padlock. A chain link, drawn in front of it, saying whether the parent actually delegates the zone to deSEC's nameservers. The two findings are independent: changing a domain's nameservers at a registrar does not touch its DS, and a clean migration to another provider removes the DS on the way out, so with the padlock alone a zone that has left the account reads as an unremarkable grey "unsigned", identical to a domain that was never signed. Four states: at deSEC (joined, green), partly at deSEC (joined, amber, for deSEC's nameservers with a stranger's alongside them or only one of the two), elsewhere (broken, amber), not delegated (broken, red)
    • The delegation costs no extra query in the default lookup method: the referral the walk already follows carries the parent's NS RRset. The resolver and DNS-over-HTTPS methods have no referral, so they send one NS query per zone and read the zone's own RRset instead, which the tooltip says plainly: it catches a domain that has moved away, but not a parent listing deSEC alongside a stranger. A zone whose parent is in the same account is answered from the parent's own records through the API
    • Checks now run in the background. Before, nothing was checked unless the DNSSEC page was open, and then five zones at a time, so a padlock on the DNS page was as old as the last visit there. A pass now runs every 10 minutes whatever page is in front, taking up to 25 zones whose answers have expired, oldest first, with the first pass shortly after startup rather than during it
    • A zone is checked the moment it is created, instead of carrying no icon until something happened to reach it
    • The zone you select is checked first when its answer has expired, on both pages, and costs nothing when it has not
    • Recheck Interval setting: how long a healthy answer is kept, 1, 3, 7 (default), 14 or 30 days, from a dropdown rather than a number to type, since a DS record does not change hourly. The 6 hours a broken, undelegated or erroring zone waits is deliberately not offered: that one belongs to somebody mid-fix. Changing it rewrites nothing and takes effect at once
    • An icon legend, from the info button beside the domain list: every icon next to what it means, in one popup
    • Tooltips lead with the verdict. "Nameservers OK, DNSSEC verified" in colour and bold, joining both findings, then the evidence, then how old it is in grey. The headline takes the colour of the worse half, so a green delegation cannot dress up a broken chain beside it
    • doc/DNSSEC-CHECKS.md, a document of its own for the feature: the icons, every trigger that starts a check, how long answers are kept, the lookup methods, and the one case the check cannot settle

    Fixed in 1.7.0-beta

    • A tooltip about a zone appeared over the records pane. Qt shows an item view's tooltip wherever the pointer is, so hovering the right-hand end of a long zone name put it past the splitter, where it read as though it belonged to whatever was under it. Tooltips are now anchored under their row, in both lists
    • Tooltips used the system's pale yellow styling on a dark window. They now follow the app's theme, and the status icons repaint when the theme changes rather than keeping the colours they were painted with
    • A zone whose delegation had just been fixed could keep a red padlock until the next background pass, while the Chain of Trust card beside it already read "NS: in place". Lifting that verdict now asks for the recheck it just made due
    • A delegation finding could outlive what it rested on, in three places: lifting an override carried through its own "publishes no NS records", the wizard writing a delegation left the same claim behind, and a co-hosted zone with a leftover DS could read as delegated to deSEC in the resolver modes while resolving nowhere. All three are now settled against the parent's own records
    • A walk that failed after seeing a referral discarded the delegation it had already established, which is exactly the lame-delegation case where it matters
    • Settings showed three dead dropdowns while the feature was off. The lookup method, provider and recheck interval are details of that feature and now go away with it, as the provider card already did when a method that does not use it was selected
    • A new zone's result could be discarded seconds after it was checked, if the DNSSEC page was opened before the zone list finished syncing: the page rebuilt its list from the cache, which did not know about the zone yet, and dropped the entry its own check had just stored
    • Walking the zone list with the arrow keys could queue the whole account. Selecting a zone checks it when its answer has expired, and on a cold cache that is every zone; the check now waits for the selection to settle, so only the row stopped on is checked
    • The "DNSSEC checks unavailable" warning could appear over an unrelated page twenty seconds after launch, with nothing on screen to connect it to. It now interrupts only when its own page is in front; the log line and the note under the domain list record it either way
    • The broken link's outer round caps were clipped square by the edge of the canvas

    Technical Improvements in 1.7.0-beta

    • Each status icon carries a 2x pixmap, so a HiDPI row is painted for its screen rather than stretched
    • Entries written before the delegation finding existed are due at once, so they fill in after an upgrade instead of waiting up to a week. Absent, not False: a check that ran and could not settle the delegation waits its ordinary turn, which keeps those zones out of every sweep for ever
    • After a batch finds DNS unreachable, the background pass waits an hour and tries again on its own. The page-show path still waits to be asked, which is right where somebody is present to press Verify all
    • delegation.classify_ns() decides what a set of nameservers means, beside DESEC_NAMESERVERS, so this feature and the subdomain wizard cannot disagree; dnssec_probe reports the names it found and nothing more
    • The cache stores those names rather than a verdict about them, so a file written today still reads correctly if the provider's nameserver list ever changes
    • 355 tests across 9 scripts, up from 315
    Downloads
  • v1.6.0-beta 44e565c656

    v1.6.0-beta
    All checks were successful
    build / check (push) Successful in 3m8s
    release / appimage (push) Successful in 5m10s
    release / rpm (push) Successful in 14m59s
    Pre-release

    Ghost released this 2026-09-13 03:16:16 +00:00 | 187 commits to master since this release

    Added in 1.6.0-beta

    • Per-zone DNSSEC chain checks, off by default. Turn on DNSSEC Chain Checks in Settings, Advanced, and a padlock appears beside every zone, on the DNSSEC page and on the DNS page's zone list. It answers the one question deSEC cannot: does the parent publish a DS whose digest matches a DNSKEY the zone really serves, anchored the whole way to the root? A stale DS after a key rollover leaves a zone perfectly signed at deSEC and unresolvable everywhere else. Hovering the padlock explains the verdict and says how old it is
    • Three lookup methods, chosen in Settings and never switched automatically: the direct walk of root, TLD and authoritative servers (the default, which needs outbound port 53), this machine's own resolver, or DNS-over-HTTPS through Cloudflare, Google or OpenDNS. The setting takes effect immediately
    • Verify all, or Verify (N) for just the domains picked out of the list, the same way the DNS page's Delete button already works
    • A DNS-access preflight before each batch, and a breaker. A network that answers nothing is reported once, instead of timing out zone by zone; a run that collapses after it starts stops itself after eight consecutive transport failures and says so
    • Results cached per profile in cache/dnssec_status.json, with a recheck policy: broken, undelegated and error zones after 6 hours, secure and unsigned after 7 days, dropping to 6 hours when the last check failed so a stale padlock is never left for a week. Creating or completing a subdomain delegation in the wizard marks that zone due immediately
    • Every check narrated in the log console, so a slow or failing lookup can be seen rather than guessed at
    • A parent in the same deSEC account is settled by the API, because the wire cannot tell a delegated co-hosted child from an undelegated one: when both zones are served by the same nameservers, a leftover DS with no NS records looks exactly like a working delegation. The API's answer wins and is cached with the result

    Fixed in 1.6.0-beta

    • An empty non-terminal counted as a break in the chain. Labels like 2.ip6.arpa are not zones and cannot carry a DS, so proving nothing by not carrying one; reverse zones read as broken when they were fine. An NS query now tells a real delegation point from an empty label
    • A zone could show a padlock it had not earned. A co-hosted parent with a DS but no NS records could read as secure through the background trickle or Verify all. A verdict is now stored only once the delegation is confirmed, the parent's records are trusted only while they are less than 5 minutes old, and a failed read never counts as confirmation
    • Switching the lookup method could freeze the window for about 3 seconds and still return with the worker thread running; a check in flight is now cancelled where it waits
    • A stop requested before the worker thread started was swallowed, leaving a worker that would not stop
    • The breaker's error streak leaked between batches, so an earlier outage could stop a later healthy run
    • Loading the stored setting looked like the user toggling it, which started a sweep nobody asked for
    • Settings overflowed and clipped its dropdowns. The page is rearranged, the lookup dropdowns are sized to their contents, the DNS-over-HTTPS provider card appears only when that method is selected, and the lookup method greys out while checks are off
    • The duplicate Token Manager card is gone from Settings; Tokens has had its own sidebar page since the Fluent rework

    Technical Improvements in 1.6.0-beta

    • Queue history no longer rewrites the whole file on every API call. Each entry is serialised once, when it enters the history; writes are coalesced to at most one every 3 seconds, with forced flushes on stop and on clear; and a single response is capped at 32 KB on disk. At the 5000-entry limit that was a 14.8 MB file and about 500 ms per request, 355 ms of it holding the lock the UI needs. It is now about 0.1 ms per request plus a periodic flush
    • dnspython added to the hash-locked dependencies
    • Four new modules: dnssec_probe.py (the walk, with no Qt and no app state), dnssec_status.py (the per-profile cache), dnssec_worker.py (the background thread) and dnssec_icons.py (padlock painting and tooltip wording, shared by both zone lists)
    • The feature is documented in doc/ARCHITECTURE.md, doc/CONFIG.md and doc/UI-FEATURES.md; the queue history path (per profile, not shared) and the Settings layout were corrected in the docs at the same time
    Downloads
  • v1.5.3-beta c130c4f458

    v1.5.3-beta
    All checks were successful
    build / check (push) Successful in 2m29s
    release / appimage (push) Successful in 4m39s
    release / rpm (push) Successful in 11m22s
    Pre-release

    Ghost released this 2026-09-12 00:15:40 +00:00 | 234 commits to master since this release

    Fixed in 1.5.3-beta

    • A second wizard run tried to re-create records that already existed — after a run the wizard dropped each written zone's cached records, so a later run against the same zone saw nothing cached, marked every RRset as new and failed on create; the post-run fetch now puts the fresh records back into the cache (and runs even without version history, where it used to be skipped entirely), so conflicts are detected again and Merge, Replace and Skip apply
    • Retrying a subdomain whose existence check had failed reported "Create zone failed" — it now reports the failed check, because no create was attempted
    • The delete-zone reminder claimed too much — when the parent's records are not cached the app cannot know whether NS and DS records are still there, so it now asks you to check instead of telling you to delete them
    • Clicking an Override cell beside its checkbox cleared the row selection — which hid the Details box; the cell is selectable now

    Technical Improvements in 1.5.3-beta

    • The wizard's post-run queue item is called Refresh <zone> after wizard, since it refreshes the cache as well as taking a snapshot
    • The test fake for the cache now really drops a domain's records, so stale-cache regressions are caught; three new tests cover the refresh, including with version history off
    • Corrected stale defaults in the documentation: API rate limit 0.5 req/sec, sync interval 15 minutes
    Downloads
  • v1.5.2-beta 6f568ec5ec

    v1.5.2-beta
    All checks were successful
    build / check (push) Successful in 2m44s
    release / appimage (push) Successful in 5m12s
    release / rpm (push) Successful in 9m0s
    Pre-release

    Ghost released this 2026-09-11 22:19:16 +00:00 | 243 commits to master since this release

    Security in 1.5.2-beta

    • New API tokens' secrets were kept in the queue history — creating a token stored deSEC's response, including the token's one-time secret, as is: it was shown in the Queue page's detail panel and written in plain text to queue_history.json (readable only by your user). The queue now keeps a copy of each response with token values replaced by (redacted); only the token dialog gets the secret, once. History files written by earlier versions are scrubbed when the app starts, even with queue history persistence switched off. If a token created with an earlier version is still in use and your ~/.config/desecqt has been backed up or synced anywhere, consider replacing it

    Changed in 1.5.2-beta

    • README rewritten — install routes (AppImage, RPM, from source), use, rate limiting, DNSSEC and subdomains, privacy with what is kept on disk, documentation and support sections
    • Security reports go to support@jdneer.com — named in SECURITY.md and the README
    • Project links point at git.jdneer.com — the About page's project link and the changelog link opened GitHub

    Fixed in 1.5.2-beta

    • README license link — pointed to LICENSE; the file is LICENSE.txt

    Technical Improvements in 1.5.2-beta

    • scripts/test_api_queue.py (9 tests) runs in CI with the other headless unit tests
    Downloads
  • v1.5.1-beta 3e65515e44

    v1.5.1-beta
    All checks were successful
    build / check (push) Successful in 1m27s
    release / appimage (push) Successful in 3m30s
    release / rpm (push) Successful in 8m8s
    Pre-release

    Ghost released this 2026-09-11 18:54:35 +00:00 | 251 commits to master since this release

    Fixed in 1.5.1-beta

    • Confirmation drawers cut off their text in narrow panes — the red/amber/blue confirmation drawers sized themselves from the text's preferred width and never grew past 280 px, so in a narrow pane (like the zone list) with a large desktop font the message and the delete-zone reminder were clipped; they now size to the wrapped text at the pane's actual width (up to the pane's height) and re-measure when the pane is resized
    Downloads
  • v1.5.0-beta 0ccc13f4a4

    v1.5.0-beta
    All checks were successful
    build / check (push) Successful in 1m40s
    release / appimage (push) Successful in 3m35s
    release / rpm (push) Successful in 7m59s
    Pre-release

    Ghost released this 2026-09-11 18:38:10 +00:00 | 254 commits to master since this release

    Added in 1.5.0-beta

    • Create Subdomain wizard mode (subdomain_wizard.py, delegation.py) — third mode in the DNS Record Wizard. Pick a parent zone and enter one or more subdomain labels; the wizard creates missing subdomain zones in the account and writes the NS (deSEC nameservers) and DS records into the parent zone in one atomic request per subdomain, completing the DNSSEC chain of trust. Existing subdomain zones are detected and only their missing or outdated delegation records are written.
      • Review step checks live zone data first and blocks a new subdomain when the parent still has records at or below its name (an existing subdomain zone, where those records are already hidden, gets a warning and runs only after you tick Proceed anyway), when its NS records point to another provider, or when a name above it is delegated elsewhere — with instructions for resolving it manually and a Re-check button
      • Account domain limit is checked before any zone is created
      • Execution with per-subdomain progress and Retry Failed (resumes at the failed stage, never creates a zone twice)
    • Chain of Trust card on the DNSSEC page — for zones whose parent zone is in the same account, shows whether the parent has the NS and DS records in place; colour-coded like the page's migration hint (amber when NS or DS is missing or outdated, green when complete, red when delegated to other nameservers); Complete in Wizard opens the Create Subdomain mode pre-filled
    • Delete-zone reminder — deleting a zone whose parent zone is in the same account shows a short amber line in the confirmation when the parent still holds NS or DS records for it, so they can be removed there too (deleting them stays a deliberate step on the DNS page)

    Fixed in 1.5.0-beta

    • Stale records after a wizard run — zones written by the DNS Record Wizard (Preset, Custom and Create Subdomain) kept showing their old cached records on the DNS page for up to 5 minutes, even after a manual sync; the wizard now drops the cache of every zone it wrote, so the page reloads them from deSEC
    • Wizard History entries repeated the zone name ([example.com] [example.com] Wizard: …); new entries carry the prefix once

    Changed in 1.5.0-beta

    • Default API rate limit lowered to 0.5 requests per second (was 1.0) — one request every 2 seconds, fewer 429 responses during bulk work; profiles that already store a value keep it. The default now lives in one place (ConfigManager.DEFAULT_API_RATE_LIMIT) instead of three differing fallbacks

    Technical Improvements in 1.5.0-beta

    • Wizard navigation follows a per-mode step sequence (Preset/Custom: 7 steps; Create Subdomain: 4 steps)
    • APIClient HTTP error results include status_code
    • Headless unit tests scripts/test_delegation.py and scripts/test_subdomain_wizard.py, run by the build workflow
    Downloads
  • v1.4.0-beta 7baad2e1e9

    v1.4.0-beta
    All checks were successful
    build / check (push) Successful in 2m34s
    release / appimage (push) Successful in 4m51s
    release / rpm (push) Successful in 10m35s
    Pre-release

    Ghost released this 2026-07-13 16:07:53 +00:00 | 276 commits to master since this release

    Technical Improvements in 1.4.0-beta

    • License headers on every source file — all Python and shell source files now begin with a two-line SPDX header (Copyright (c) 2025-2026 JD Bungart <me@jdneer.com> / SPDX-License-Identifier: GPL-3.0-or-later), placed after the shebang. This makes the GPL-3.0-or-later license and authorship explicit and machine-readable in each file.
    Downloads