Skip to content

Ubuntu gtk4 - #561

Closed
makeafide wants to merge 85 commits into
linuxmint:masterfrom
makeafide:ubuntu-gtk4
Closed

Ubuntu gtk4#561
makeafide wants to merge 85 commits into
linuxmint:masterfrom
makeafide:ubuntu-gtk4

Conversation

@makeafide

Copy link
Copy Markdown

No description provided.

makeafide and others added 30 commits August 29, 2026 15:49
Snapshots can now be stored on a remote host instead of only on a local
block device. rsync talks to the remote directly, so --link-dest is
resolved by the receiving rsync and hardlinked incrementals stay cheap
over the wire.

Core:
- New RepoBackend abstraction with local and SSH implementations.
  SnapshotRepo no longer *is* a Device; repo.device may be null.
- Snapshot control-file IO, retention, symlinks and free-space queries
  all route through the backend.
- Remote targets are probed for writability, hardlink support, extended
  attributes and whether ownership can be preserved, so an unusable
  location is reported instead of producing silently useless backups.

Setup:
- --setup-ssh-key installs a generated key using a one-time password,
  after showing the host fingerprint for confirmation. The password is
  never stored, logged, or placed on a command line. Keys this machine
  previously installed are pruned once the new one is verified.

GUI:
- Location page gains a Local/Remote selector with an SSH form.
- Snapshot browsing opens remote snapshots over sftp://, falling back to
  an sshfs mount.

Also fixes several pre-existing defects encountered along the way:
- /swap.img (Ubuntu's default) and /etc/timeshift were not excluded, so
  an 8 GB swap file and the SSH private key were being backed up.
- estimate_system_size() discarded its own result via save_app_config(),
  leaving the progress bar dead for the whole first backup.
- rsync's exit code was ignored, so a partial transfer could be recorded
  as a valid snapshot.
- Session logs were created world-readable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8Fe33pm61siHCV66GFGHa
Repackage as timeshift-ssh: snapshots can now be stored on a remote host
over SSH (rsync with hardlinked incrementals, --fake-super ownership,
password-based key enrolment) and browsed via an sshfs mount using
Timeshift's own key. Also detects swap files dynamically from /proc/swaps,
excludes /etc/timeshift/* from backups so the SSH key is never copied in,
and never deletes browse mount points recursively.

Also adds snapshot size reporting for both rsync and btrfs snapshots, in
the GUI (Size/Unshared columns) and CLI (--list). Btrfs sizes come from
live qgroup queries; rsync sizes are computed once via du/find over the
repository backend (local or SSH) and cached in each snapshot's info.json,
with cached values invalidated on a neighboring snapshot's deletion since
hardlink counts can shift.

Includes build-deb.sh/check-deb.sh for building and verifying the
timeshift-ssh package.
The GTK layer is rebuilt on GTK4 with a modern layout and one app-wide
stylesheet that follows the desktop live.

Theme engine
- ThemeStyle: light/dark palettes and nine accent presets emitted as
  @define-color tokens (works on GTK 4.6-4.22) plus a scoped .ts-* rule
  set; stock widgets are left to the user's GTK theme.
- AppTheme owns the single CssProvider, reads color-scheme/accent-color
  from the settings portal or, as root, from the user's gsettings, and
  updates live through the portal SettingChanged signal or a file
  monitor on ~/.config/dconf/user (a dbus-daemon session bus refuses
  root, so the portal is usually unreachable under sudo/pkexec).
- Settings > Appearance page: Theme (System/Light/Dark) and Accent,
  persisted as theme_mode / theme_accent in timeshift.json.

Layout
- Ui spacing scale and class-based text helpers replace Pango markup
  and literal margins; containers own margins, boxes own none.
- New widgets: Clamp, StatusPage, StatusCard, StatTile, TaskProgressBox,
  SummaryBox, WizardWindow; Banner replaces Gtk.InfoBar.
- HeaderBar on every window. MainWindow is non-modal with Create plus a
  linked Restore/Delete/Browse group, Settings and the wizard in the
  menu, an empty-state page and card-style status. SettingsWindow uses
  the switcher as its title and saves on close. Wizards derive from
  WizardWindow with a "Step n of m" subtitle and Back/Next/Finish in
  the header; the close button is hidden while work is in flight.
- All pages migrated; progress and finish pages unified; banner types
  corrected (INFO for guidance, WARNING/ERROR for problems).

Cleanup
- Remove unreachable RestoreExcludeBox/ExcludeAppsBox, the four
  *FinishBox pages and the never-instantiated ExcludeMessageWindow.
- Replace legacy PNG icons with themed symbolic names so they recolour
  in dark mode; drop the unused images.
- Regenerate timeshift.pot; document the new architecture in CLAUDE.md.
A validation pass over the redesigned GUI (legacy-pattern sweep, CSS and
contrast review, per-window UX review) turned up functional bugs,
accessibility failures and consistency gaps. This addresses all of them.

Bugs
- Drop escape_html() on labels that are plain text now; a path with '&'
  rendered as '&amp;'.
- Actually disable the snapshot context menu in the delete wizard; the
  gesture and key controllers stayed live after hide_context_menu().
- Exclude List Summary cast a GtkViewport to a ScrolledWindow (critical);
  use Ui.add_boxed_list.
- AppTheme: a gtk-theme-name notify could clear dark mode for any theme
  without a -dark suffix; only raise it. Read the user's dconf path and
  theme names on every path, so the live channel and icon-theme adoption
  no longer depend on whether the portal answered.
- Hide widgets that GTK4 shows by default until they mean something:
  wizard header buttons before initialize_tab(), the log filter and
  list while parsing, the SSH form in local mode, the empty help card,
  the status-page action slot. Hide the stat tiles in Live-USB mode and
  the status card while the empty state is showing the same text.
- TerminalWindow: HeaderBar, closable once the script has exited, fix a
  guard that tested a toplevel's widget parent.

Accessibility
- Light success/warning text, the success banner and the info banner
  now clear WCAG AA; text-on-accent is chosen per preset by luminance
  and red/slate are darkened a step. dim_fg gains headroom. Focus ring
  on the accent swatches; tabular numerals on live counters.

Consistency
- No authored Pango markup left: bullets via Ui.add_bullets, style
  toggles via Ui.set_text_style, plain-text tooltips, add_checkbox and
  the DropDown factories without use_markup.
- destructive-action on Delete, Remove, the delete wizard's Next and the
  Yes of delete / abandon-restore confirmations; in wizards only Next is
  suggested, Finish only on a last page and never when labelled Close.
- Every page opens with a title and description; forms live in cards
  (SSH form and the restore mount table are Gtk.Grids, no SizeGroups);
  ScheduleBox rhythm fixed and the Keep spin follows its checkbox.
- All spacing through Ui.Spacing; gtk_inputbox matches the dialog
  chrome; RsyncLogBox uses a SearchEntry and always shows its title;
  dead code, dead helpers and dead icon constants removed.

Additions
- The accent paints app-owned surfaces: boxed-list selection, progress
  bars and spinners. Buttons stay on the desktop theme.
- High-contrast palette, following the portal 'contrast' key or the
  gsettings a11y setting, live.
- Back in the restore wizard from Confirm and Summary; RsyncLogBox
  open_log() is idempotent so a second dry run reuses its widgets.
- ExcludeBox gets an icon toolbar (Add menu, destructive Remove,
  Summary) in place of the six-button row.
- The four progress pages are TaskProgressBox subclasses.

meson.build now requires GTK >= 4.6 for -gtk-icon-size.
Three audits (GTK4 correctness and lifetime, behaviour against the GTK3
original, build/packaging/i18n) found crash-class defects, two bugs that
could destroy data or target the wrong disk, a set of regressions from
the port, and three release blockers. This fixes them.

Crashes and corruption
- BackupDeviceBox built a button into a throwaway box and unparented it.
  Vala drops the temporary at the end of the statement, so the box was
  finalized with the child still attached and the unparent then wrote
  into freed memory - on every Settings/wizard open. Ui.make_icon_button
  now returns an unparented button.
- BackupBox read App.space_check_task after a null test that the worker
  thread could invalidate in between.
- RsyncLogBox could start a second parser thread over the same App.task
  and loglist, because parse_log_file() pumps the main loop while the
  wizard's navigation stayed live.
- BackupDeviceBox dereferenced find_device_in_list() without a null test.

Wrong disk, lost work
- Gtk.DropDown's selection model autoselects and cannot be cleared, so
  opening Bootloader Options overwrote App.grub_device with the first
  block device lsblk returned, and "no device" was unrepresentable. Both
  drop-downs now guard population and carry an explicit sentinel row.
- Cancelling during an estimate, dry run or delete closed the window but
  left the state machine running: a cancelled backup still created a
  snapshot. Wizards now abort on close.

Regressions from the port
- Whole disks and LUKS parents are selectable as a snapshot location
  again; the radio clears on the previous row; clicking the selected
  radio no longer leaves a row with nothing set.
- Include/exclude patterns can be reordered again (Move Up / Move Down);
  rsync applies the list first-match-wins, so order is meaningful.
- Escape closes dialogs and the input prompt; Bootloader Options and
  Exclude List Summary regain a Close button.
- Editing the live snapshot's comment no longer accretes "[LIVE] ".
- Live-USB with no location keeps its status line and an action.
- Log filter matches case-insensitively; a reopened log rebuilds its
  filter list and column title; step indicators match the route walked.
- Retention counts can be set before their level is enabled again.

Release blockers
- GETTEXT_PACKAGE was the empty string, which overrode meson's define
  and disabled every translation: the package shipped 68 catalogues the
  binaries never opened. Verified fixed with LANGUAGE=ja and strace.
- Declare the real GTK floor (4.12, for load_from_string) in meson and
  debian/control.
- Depend on libglib2.0-bin: gsettings is the only path by which the
  theme engine can read the desktop's preference when running as root.

Leaks and robustness
- Page boxes held an owned reference back to their window, a cycle that
  leaked the whole widget tree on every Settings/wizard open; the back
  references are now weak, and SnapshotListBox unparents its popover.
- The dconf watch no longer runs blocking gsettings calls while a task
  is pumping the main loop.
- Clamp renamed to ContentClamp: the generated CLAMP() macro shadowed
  glib's own.

Also: the status bar no longer renders "(]" from a C trigraph, all Vala
warnings are gone, makepot output is reproducible, and the metadata,
PKGBUILD, copyright and README describe this fork rather than upstream's
GTK3 app.
Verified against the running app under Xwayland (screenshots at each step)
rather than by inspection alone.

- The Delete button in the header rendered as a solid red block:
  .destructive-action is for the point of no return, which is the
  confirmation dialog, not a permanent toolbar icon. Same reasoning
  applied to Remove on the Filters page.
- Filters was effectively unusable with a mouse: the editable pattern
  cell consumed the click, so no row was ever selected and Remove /
  Move Up / Move Down stayed insensitive. A capture-phase gesture now
  selects the row first and still lets the label take the click.
- The selection was then dropped on every commit, because save_changes()
  reaches UsersBox.refresh(), which rebuilds this list. refresh_treeview()
  now restores the selection by pattern.
- Move Up / Move Down were offered for rows whose position can never be
  saved: save_changes() drops anything already in Timeshift's built-in
  default or home lists (/root and friends), and those are re-appended
  afterwards, so the move silently reverted. The buttons are now enabled
  only when both rows involved will keep their position.
- The Appearance page's System swatch showed the resolved accent, so with
  a preset chosen it could not preview what System would actually give.
  It now paints the desktop's own accent.
- ExcludeBox.save_changes() guards the users_box back-reference, which is
  null for the throwaway instances the wizards construct.
- Clearer icon for the exclude-list summary.
A remote repository has no Device: SnapshotRepo owns a RepoBackend rather
than being one, and available() returns early for remotes without ever
touching the field. The status dump in check_status() predated the remote
backend and still assumed a local device, so every SSH location logged
itself as `Snapshot device: ' UNKNOWN'`. Every other caller had already
gained a backend.display_name fallback; this was the site that was missed.

RepoBackend grows a type_id beside is_remote and display_name: "local",
or "remote-ssh-rsync" for SSH. That is a constant rather than a function
of btrfs_mode, because btrfs needs local subvolumes and is forced off for
remotes in four places. It is a diagnostic token, so it is deliberately
untranslated - matching the "RSYNC"/"BTRFS" labels and the status enum
name logged beside it - and it is never persisted, leaving
backup_location_type as the only config key describing the location.

- check_status() logs `remote-ssh-rsync (user@host)` for a remote
  location. The local branch is untouched, including the UNKNOWN text
  for a device that genuinely has not been selected yet.
- print_status() swaps the hardcoded `Mode : RSYNC` row for
  `Type : remote-ssh-rsync` in its remote branch; the local branch keeps
  Mode. "Type" and "Mode" are both existing msgids, so the catalogues
  are unaffected.

Verified against a live SSH repository and, via the local-run config
override, against a local device and an unselected one.

debian/changelog: 25.12.4+ssh8.
Restoring over the running system handed the whole job to a modal
full-screen VTE and let raw `rsync -avir` output scroll past, followed by
grub-install, update-initramfs and run-parts. No progress bar, no ETA, no
indication of which step was running -- on the one screen where the
machine is overwriting itself.

Everything a real UI needs was already in that stream and already parsed
by RsyncTask; it was just being thrown at a terminal.

RestoreProgressBox is the new view: a warning banner, a shield card
naming the snapshot, the progress bar with ETA and current file, a
PhaseList checklist, live created/changed/deleted counts, and the raw
output behind a collapsed LogPane. RestoreBox is the single driver -- its
existing main-thread polling loop writes into either itself (the wizard
page) or the RestoreProgressBox inside the full-screen
RestoreProgressWindow. That also moves all GTK work back to the main
thread: restore_current_gui() used to build a window and pump the main
loop from the restore worker thread while two other loops pumped the
same context.

RestoreScriptTask subclasses RsyncTask and supplies the script instead of
building one, so every field the progress loops poll keeps working and
the inherited finish_task() writes the "-changes" sidecar this path never
produced. create_restore_scripts() announces each step with an
untranslated @@TS_PHASE marker (GUI mode only) and publishes the steps
that will actually run, so a skipped bootloader step never appears in the
checklist. restore_other_gui()'s sh_finish runs through the same task,
which puts the bootloader and hook steps on screen for the first time --
exec_script_sync() had been printing them to a stdout no GUI user sees.

Three fixes fall out of the same seam. App.task now points at the run in
progress rather than the finished dry run, so the counters and exit code
on the current-system path are this restore's. The progress bar means
something: the script itemises with -aiir, matching RsyncTask, and the
denominator is the dry run's measured line count instead of a guess that
fell back to a literal 500000. And io_nice is off for the restore script,
which the terminal it replaces never had.

Also fixes a crash in AppTheme's portal read: g_variant_new("(as)", ...)
wants a GVariantBuilder, not a string array, and aborted the process the
moment a session bus answered -- masked because the GUI usually runs as
root with no reachable bus, but it meant portal theming never worked.

The old terminal is kept: TIMESHIFT_RESTORE_TERMINAL=1 under --debug
brings it back unchanged, the same LOG_DEBUG-gated env-var convention
ThemeStyle already uses. libvte stays a dependency.

The Restore wizard grows to 700x680 and its progress page scrolls
internally; the checklist above the counts had pushed it past 900 px.

Verified by building both binaries and the man pages, checking --help is
unchanged, rendering both views under Xvfb in light/dark and with a
non-default accent, and running a real RestoreScriptTask over a script
speaking the same dialect (phases advance, markers stay out of the log,
counts and exit code correct). A real current-system restore has not been
run and still needs a pass in a VM.

Claude-Session: https://claude.ai/code/session_01A4j3pr9QpnXpRUuD5UZP2n
Retry-with-reconnect around the restore rsync, per-exit-code handling,
restore outcome reporting, ESP selection normalization, aliased-mount
folding, target-relative restore logs, SSH mux socket lifecycle, and
the supporting Gtk changes.
New timeshift-recovery-shell binary (GTK4 rescue UI booted inside the
recovery image), --recovery-status/-enable/-disable CLI flags, and a
Recovery page in Settings that wraps the external timeshift-recovery
tool with streamed install output.
timeshift-recovery provisions a bootable Press-R rescue environment
(mmdebstrap rootfs, GRUB entry with native-resolution splash, refresh
trigger). apt-snapshot-guard forces a Timeshift snapshot before any apt
package change, fail-closed with a GUI prompt. build-all.sh builds all
three debs into dist/.
Security: the built squashfs and recovery.img embed credentials and were
world-readable -- created 0600 now, tightened on upgrade by postinst, and
'remove' deletes the cached image instead of leaving it for purge. The
build's staging rootfs is 0700 and cleaned up by the failure trap.

Bugs: sgdisk was fed bytes where it expects sectors (dedicated-partition
target asked for ~512x the size; verified fixed on a loop device); the
guard's dedup stamp recorded the pre-snapshot time; its GUI prompt path
was dead when timeshift was missing; nmcli escaping, classic .list apt
sources and local mirrors are now handled; the medium image stages next
to the cache instead of a tmpfs /tmp; status reports what the image
actually embeds (recorded in build-info) rather than what the config says.

Packaging: debian/copyright, a timeshift-recovery(8) man page, logrotate
for both logs, the guard's undocumented APT_SNAPSHOT_GUARD kill switch
documented, the apt-timeshift-guard directory renamed to match its
package, and build-deb.sh rolls the version bump back when a build fails
instead of burning the number. gui-prompt no longer assumes /home or
gnome-shell.
The SSH mux socket cleanup globbed inside single quotes and never
matched; restore-script echo lines now escape translated apostrophes;
concatenated msgid fragments in the restore outcome header become whole
sentences; the diagnostics ssh probe honours backup_ssh_key and
backup_ssh_port; five doc comments stranded by an earlier reordering are
re-homed; dead code removed (device_ip, has_saved_connection,
term_running, .rs-btn, ts_partial_fails); the recovery shell documents
its deliberate English-only strings; new files carry the right copyright
header.
…ogether

dpkg clears a pending file trigger for a package it is about to configure,
so upgrading both in one apt transaction silently skipped the rebuild.
postinst configure now compares build-info's TS_VERSION with the installed
timeshift and starts the refresh unit itself. Also normalise classic
one-line apt sources to deb822 for the mmdebstrap stream: mixed formats in
its single stdin-derived .sources file are rejected by apt (found live when
the first refresh under the new source filter failed).
…release workflow

README: enable/disable and --machine, the CLI/GUI integration, the boot
splash and SCALE, an apt-snapshot-guard section, and build-all.sh with the
apt-mark hold reminder. CLAUDE.md: the swallowed-trigger case and the
deb822 normalisation of the mmdebstrap source stream.
The recovery environment was split across two trees: the provisioner deb under
os-plugins/timeshift-recovery/ and the launcher UI it ships as a single Vala
class in src/Recovery/. They are one feature, so they get one directory.

The package needed no internal edits. build-deb.sh already anchors on
`cd "$(dirname "$0")"`, every debian/install source is package-relative,
check-deb.sh works on the extracted deb, and the maintainer scripts only ever
name runtime /usr paths. Three things outside it referenced the old location:

  * build-all.sh's recovery_dir. Only that one line: line 62 computes the deb
    path as "$recovery_dir/../", which now resolves to the repo root -- exactly
    where dpkg-buildpackage drops it.

  * .gitignore. dpkg-buildpackage writes into the PARENT of the package being
    built, so artifacts that used to land in os-plugins/ now land in the repo
    root, which had no rules for them.

  * CLAUDE.md's section heading.

Nothing else in the tree names the old path, and neither check-deb.sh nor
build-rootfs does: both reference /usr/bin/timeshift-recovery-shell, an install
path, not a source path.

Verified with ./build-all.sh: all three debs still build and pass their
check-deb.sh.
RecoveryShell.vala was one 2018-line class holding five stack pages, a
hand-rolled modal layer, a 200-line stylesheet, process plumbing and parsing
helpers, where every method could touch every field. It is now 13 files, the
largest 514 lines.

The shape follows the main app's own src/Gtk/*Box.vala idiom: RecoveryWindow is
the hub that owns the window, the page stack and the services pages share, and a
Page holds a reference to it and to nothing else -- so the only way one page
reaches another is a method named there. Pages that read machine state refresh
in on_shown() instead of making every caller remember to. Sh, SysInfo and
Tailscale are namespaces with no GTK in them at all.

What did NOT change, because each is load-bearing:

  * No sources_core, no src/Utility. Linking Main would make the launcher fail
    to start for every reason Timeshift itself fails to start, which is the
    situation this environment exists for. The build carries no sources_* list
    so that stays true by construction.
  * The theme is still restated rather than shared -- ThemeStyle reaches Main
    through the global App.
  * The modal is still hand-rolled -- Gtk.AlertDialog spawns its own toplevel,
    which under bare labwc gets no decoration and none of this stylesheet.
  * Still English-only with zero _() strings: the image ships no locale data,
    so gettext could never translate them at runtime. It stays out of POTFILES.

Three build traps, all hit:

  * The meson file goes in src-recovery/shell/, NOT src-recovery/. The
    provisioner's debian/rules is a bare `dh $@`, and debhelper autodetects a
    build system by looking for meson.build in the source root -- it would try
    to `meson setup` a shell-script package.
  * vala_args is dropped. vala_opts points --vapidir at src/vapi and adds
    --pkg ioprio; from here that directory does not exist, and the shell uses
    neither Posix nor ioprio.
  * subdir() shares variable scope, so docs/man needs no edit -- but ordering
    does matter, and getting it wrong only fails with -Dman=true.

The dependency list is now the six it actually uses, dropping gio-unix, posix
and m. The binary grows 396K -> 520K from GObject boilerplate for 13 classes.

Verified by driving the real binary under Xvfb: all five pages render, Storage
parses live lsblk -J output, an Unmount surfaces the failure through the modal,
Network's on_shown fires its async refresh, Diagnostics reads the configured
repository, and Terminal spawns bash with the palette applied.
First phase of extracting the core out of Main.vala. Nothing here changes
runtime behaviour: the Vala binaries are untouched and still own /usr/bin. What
lands is the module, four host-level packages, a corpus of real captured system
output to test against, and the packaging path -- deliberately early, because
the packaging is where the surprises were.

Standard library only. No vendor tree, no network during a package build, and
exactly one new Build-Depends. Keep it that way.

sysexec fixes the defect the whole port hangs off. exec_script_sync() could
never return a real exit code: its wrapper appended `echo $? > status`, so the
last command in every script was a successful echo and the function returned 0
regardless. Callers that needed the truth read a sibling file or appended an
explicit `exit $?`. Here the exit status is the exit status. Commands are argv
slices, so nothing needs quoting and escape_single_quote() has no equivalent;
signals go to the process group, because rsync-over-ssh forks a child that
survives a signal sent only to the leader; and Stop() sends SIGCONT before
SIGTERM, or cancelling a paused backup would hang forever.

config reproduces json-glib byte for byte -- the `" : "` separator, 4/2 array
indentation, the collapsed empty array, the absent trailing newline -- and keeps
every value a JSON *string*. The GUI reads this file; a writer that improves the
format breaks it silently. A native JSON `true` is rejected for btrfs_mode on
purpose: only the string "true" is what Timeshift ever wrote.

The corpus in src-go/testdata is real output from this machine, not invented:
lsblk --pairs, df -T -B1, /proc/mounts, fstab, a config the Vala build wrote,
and three rsync -aiir passes covering created, modified, deleted and unchanged
items. What could not be produced here -- a LUKS-on-LVM disk, the checksum and
owner itemise columns -- is hand-written and labelled as such in each README.
The config corpus has its host and home path replaced; only characters inside
string values changed, so the structure the round-trip test checks is still
exactly what json-glib wrote.

Four packaging traps, each hit for real before it was fixed:

  * GOCACHE and GOPATH are forced into the build directory. dpkg-buildpackage
    runs with an unwritable HOME and the default ~/.cache/go-build fails there
    with an error that reads like a compiler problem.
  * meson expands @output@ relative to the TOP build dir, so a script that cds
    into the source tree must resolve it first -- otherwise the binaries land
    in src-go/src-go/ and meson reports a missing output.
  * dh_dwz cannot read Go's compressed DWARF and fails the whole build rather
    than skipping the file (Debian #931891), hence -Xtimeshiftd.
  * Go links statically whenever nothing in the import graph needs libc, which
    lintian raises as an ERROR. -buildmode=pie with -linkmode=external keeps
    the result dynamic and position-independent.

timeshiftd installs to /usr/libexec/timeshift/timeshiftd and today only serves
--check-config. The Go CLI is built but not installed; the Vala timeshift still
owns /usr/bin/timeshift until the consumer cutover.

go test -race ./... is green (47 tests), go vet is clean, all three debs build
and pass check-deb.sh, and lintian reports nothing about the new binary.
Phase 4 of the port. Both commands are implemented in Go and produce output
byte-identical to the Vala binary, checked by running the two side by side
against a live SSH repository and a throwaway local one -- not by reading the
Vala source and hoping.

The engine seam is the point of this commit. The Vala core has no storage
abstraction at all: the mode is the boolean App.btrfs_mode and every
mode-sensitive operation is an `if (btrfs_mode)` branch repeated some forty
times, while local-versus-remote is a second, unrelated axis handled by
RepoBackend. A third way to store a snapshot would mean a third axis through the
same forty sites. internal/engines replaces both with one named Engine per
strategy, and internal/engines/timeshift is the rsync/btrfs/SSH behaviour every
existing installation is already using.

Four boundaries keep it honest: Reporter is the only way an engine talks
outward, so it never learns whether a GUI is attached; the engine restores the
payload while the host restores the system, which is the sh_sync / sh_finish
split that already exists; Caps drives the UI rather than `if engine ==`; and
engine-specific metadata rides in Snapshot.EngineData so the host never has to
know what a subvolume is. Tags and retention stay out of the engine -- they are
Timeshift policy, not storage mechanics.

Fidelity details that only surface by diffing real output:

  * print_grid() pads every cell with two trailing spaces INCLUDING the last on
    a line, so every row has trailing whitespace, and the separator is a fixed
    78 dashes that does not track the table width. Both reproduced.
  * MIN_FREE_SPACE is `1 * GB` where TeeJee's GB is decimal, so it is 1e9 --
    which is not GREATER than unit_g and therefore renders as "1,000 MB", not
    "1 GB". That comma also settles a question the source could not: the Vala
    printf "%'" flag really is grouping under the user's locale, so FormatSize
    groups by default.
  * A size of -1 means "not computed yet" and is not the same as zero; both
    render empty.

Corrections to the Vala behaviour, each deliberate:

  * ParseLsblk tokenises key="value" instead of matching one 18-group regex,
    which silently dropped any line whose value contained a quote.
  * IsLVMPartition compares against the NORMALISED fstype. Device.vala tests
    for "lvm2_member" after the parse has already rewritten that value to
    "lvm2", so its predicate can never be true.
  * The SSH control-file prefetch emits a marker only for files that exist, so
    a missing file and a file holding one newline stay distinguishable -- an
    exclude.list that reads as present-but-empty would mark an rsync snapshot
    valid when it is not.
  * df's filesystem size is kept separate from lsblk's device size rather than
    overwriting it; --list-devices prints the device size.

is_safe_host_component is ported with its tests: the host and user reach ssh as
arguments and a value beginning with "-" is parsed as an OPTION, so
ssh://-oProxyCommand=.../path runs an arbitrary command as root.

Repository mounting lives in the engine rather than the CLI, because both the
CLI and the daemon reach a repository through Open() and one that only works
when somebody else happened to mount the device is not a working repository.
Unlike Device.mount() it keeps an existing mount rather than unmounting first:
harmless when only one process could exist, wrong for a daemon that expects
several.

Two output differences are intentional. The Go CLI does not echo core log lines
to stdout, and it does not run cron_job_update() on exit -- so listing snapshots
no longer rewrites /etc/cron.d as a side effect.

go test -race ./... green, go vet and gofmt clean, all three debs build and pass
check-deb.sh.
The mechanism the whole port exists for. In the Vala build the state of a
running backup lives in one process's Main object and AppLock refuses a second
instance, so a snapshot started by apt-snapshot-guard cannot be watched at all:
opening the GUI gets "Another instance of timeshift is currently running" and
exit(1).

A job is now an object with an id, and its progress is broadcast. A client that
attaches halfway through is handed the current state and the tail of the log
BEFORE the live stream starts, so joining late looks the same as having been
there -- there is no gap, because the subscription is registered before the
snapshot is taken. TestSecondClientWatchesRunningJob does exactly that over a
real socket, and TestWatcherDisconnectDoesNotStopTheJob kills the watcher and
checks the work carries on.

Two rules make it safe, and both are tested:

  * One worker runs one mutating job at a time, FIFO. That single writer is
    what replaces AppLock, enforced in one place instead of by every caller
    remembering to take a lock. A second create is QUEUED and its id returned,
    where AppLock could only refuse.
  * A subscriber that stops reading is dropped, never waited for. If publishing
    blocked, a GUI that stopped reading -- killed, or just busy redrawing --
    would stall the backup it was watching. Watching must not be able to affect
    the thing being watched.

Also: a panic in an engine is contained and fails just that job, because every
other client's view of every other job depends on the daemon staying up.

The rsync parser is ported with the dispatch order intact -- created, deleted,
unchanged, modified -- which matters because Modified's column patterns accept
"+" and " " and would otherwise swallow the other two. Progress is a LINE
count with the denominator from a separate dry run, so the flags in Args() and
in the restore script have to stay in step or every progress bar skews. Tested
against the captured corpus, including the detail that even a first transfer
reports one modified entry: rsync syncs the existing destination root's mtime
and permissions.

The IPC layer is newline-delimited JSON on a unix socket -- no bus, because
there is none under pkexec, in the recovery environment, or over an ssh
forward. Responses and events share one connection and one write mutex.
SO_PEERCRED decides access: root gets everything, a member of the timeshift
group gets the read-only subset, anyone else is refused. The policy is an
injectable Authorizer rather than a hard-wired rule, which is also what makes
it testable as a non-root user.

Three bugs found while writing the tests, each fixed here:

  * A job that merely failed was reported as cancelled, because ctx.Err() was
    read after the deferred cancel had already fired.
  * A connection accepted while Close() was walking the connection map was
    never closed, so its client waited for an EOF that never came.
  * wg.Add in the accept loop raced with wg.Wait in Close -- the documented
    WaitGroup misuse, caught by the race detector.

go test -race ./... green, go vet and gofmt clean.
Phase 5 completes. timeshiftd now serves the read methods, runs create, delete
and estimate as jobs, and streams their progress; the CLI submits work and
watches it rather than doing it.

The exit criterion is met, demonstrated against this machine rather than argued:

  * A snapshot was started from one client and a SECOND client attached to it
    mid-transfer -- "attached to j-1, already in progress" -- then followed the
    same job to the same result. That is the case AppLock refuses outright.
  * The VALA binary lists a Go-written snapshot correctly: name, tag,
    description, and the Size and Unique it computes itself with a du walk. The
    on-disk format holds in both directions, which is the point of writing the
    control file byte-compatibly.
  * A second snapshot of a 9.3 GB system added 156 MB. --link-dest is working,
    which is the whole of Timeshift's rsync mode.

What landed:

  * internal/rsyncx builds the argv and classifies exit codes. The flag set is
    load-bearing -- progress is a LINE count with the denominator from a
    separate dry run, so changing -aiiXH changes how many lines a transfer
    emits and skews every progress bar. -X specifically is what keeps
    snap-confine's security.capability; without it every snap silently stops
    working after a restore.
  * The exclude list, whose ORDER is behaviour: these are rsync filter rules,
    first match wins. /etc/timeshift/* is excluded because it holds the SSH
    private key, and backing it up would put the key inside the repository it
    unlocks.
  * Repo.Create, Estimate and Delete. Delete's verdict is whether the directory
    is gone, not the exit code: rm -rfv reports success for paths it never
    touched. A failed transfer removes its partial snapshot directory, because
    a half-copied tree that looks like a snapshot is the worst thing to restore
    from.
  * internal/distro, so a snapshot records which system it came from.

Two bugs found by running it, both fixed here:

  * The CLI rendered 223,000 entries as "223". It was using FormatSize with
    Unit "k" to get thousands separators, which divides by 1000. fsutil gains
    GroupDigits, which is what was actually wanted.
  * sys-distro was written empty, because nothing detected the distribution.

Console progress moved out of the core, where AppConsole printed it from inside
create_snapshot_with_rsync even under --scripted -- the reason
apt-snapshot-guard redirects everything to its log. The core reports numbers
now and the client decides whether to draw them.

go test -race ./... green, go vet and gofmt clean, all three debs build and
pass check-deb.sh, lintian reports nothing new.
Phase 6, first half. NOTHING is wired up: there is no --restore command and
there should not be one until this has been exercised in a VM. It is the one
path where a mistake destroys data rather than producing a wrong answer.

The split follows the one the Vala code already has. create_restore_scripts()
emits sh_sync, which transfers files, and sh_finish, which fixes the system --
chroot, GRUB, initramfs, hooks, reboot. The second does not care which engine
produced the files, so it lives outside the engine. Both stay generated shell:
they run under chroot and must survive the reboot boundary, and on a
current-system restore the script ends by rebooting the machine it is running
on.

Three independent safety layers, because the first two reason about intent and
the third about reality:

  * FoldAliasedMountEntries collapses entries that would mount one device twice
    at nested points, using MountPointIsUnder rather than a prefix test -- a
    prefix test makes /boot-backup a child of /boot. The ESP is never folded:
    folding it means no ESP is mounted, the payload lands as ordinary files on
    ext4, and grub-install fails with "cannot find EFI directory".
  * Validate blocks on exactly two things, the root and the ESP. A missing
    /home device gives a bootable system with an empty home; a missing root
    gives a system that does not boot.
  * VerifyNoAliasedMounts stats the mounted result and refuses if any nested
    mount point IS the target root, comparing (st_dev, st_ino) rather than the
    mount list so it catches an alias arriving by any route. Its test symlinks
    a mount point onto the target and confirms the restore is refused.

The tests RUN the generated scripts with real rsync against real directories,
which is the only way to catch a quoting mistake, a flag rsync rejects, or a
marker that never fires. They restore a miniature snapshot including a path
with spaces, confirm --delete removes what the snapshot lacks, confirm a dry
run changes nothing at all, and confirm an excluded path survives --delete.

The exit-code policy is tested by stubbing rsync, because driving the real one
into each state is unreliable: 0 and 24 succeed, 23 warns and CARRIES ON to the
finish steps, the transport codes retry after dropping the ssh master, and
anything else touches the failure sentinel and aborts before the bootloader
steps ever run. A transport failure is shown recovering on the second attempt
and re-announcing its phase, which is what clears the reconnect banner.

One inconsistency found and fixed while writing the tests: FinishPhases
promised chroot_bind and cleanup whenever the target was not the running
system, but the script only emits them when there is also a target path. A
checklist that promises a step the script will not run is worse than no
checklist.

Still outstanding before restore can be offered: the golden diff against
create_restore_scripts() for the same inputs, and a VM restore to the running
system, to another device, and from btrfs.

go test -race ./... green, go vet and gofmt clean.
Phase 7. Subvolume snapshot, delete, restore, layout validation and the qgroup
cleanup, tested against a REAL btrfs filesystem on a loopback file rather than
against a mock -- mocking btrfs would only prove the mock agrees with itself.
The tests need root and skip without it.

What the real filesystem caught that a mock would not:

  * Copy-on-write actually behaves: writing to the source after a snapshot
    leaves the snapshot unchanged.
  * A subvolume containing a NESTED subvolume cannot be deleted in one call,
    which is why the nested one is looked for first -- and why it is verified
    with `subvolume show` rather than assumed, because an ordinary directory of
    the same name must not be deleted as though it were a subvolume.
  * Restoring over a live subvolume is refused. The caller is expected to have
    moved it aside first, which is what the pre-restore snapshot is for, and
    silently replacing it would destroy whatever it holds.

`btrfs subvolume sync` BLOCKS for about thirty seconds: it waits for the
kernel's cleaner thread, which runs on the commit interval. Measured at 31.0s
for one empty subvolume on a 512 MB filesystem. That is worth writing down
because it looks exactly like a hang, and it exposed a real flaw in my own code:
the cleanup deadline was checked between retries, so a single blocking call
would have ignored it entirely. It now goes on the context, where sysexec's
process-group kill can actually enforce it.

btrfs and a remote repository are mutually exclusive, and ValidateLocation now
reports that rather than the Vala behaviour of turning btrfs off silently during
config load -- someone who chose both got rsync snapshots and was never told.
Open still forces it off as a backstop, so the wrong combination is harmless as
well as reported.

Also renamed the control-file Subvolume to SubvolumeRecord. Two different things
had one name: what is recorded in info.json, and a live subvolume on a mounted
filesystem.

Still outstanding, as for the rsync restore: a VM test. Nothing here is wired
into a command.

go test -race ./... green, go vet and gofmt clean, all three debs build.
Phase 8, first half. cron ran the whole CLI once an hour to ask a question whose
answer was almost always "nothing", and a cron-driven run had nowhere to report
to -- no client could attach to it, which is the defect this port exists to
remove. A scheduled backup now goes through the same job queue as any other, so
`timeshift --watch` follows it exactly like a hand-started one. Verified: a
separate client attached to the scheduler's own in-flight backup and followed it
to completion.

Ticking every ten minutes is safe because of a property of the decisions rather
than luck. Every test is an age comparison, so it gives the same answer whether
asked once an hour or twelve times, and only one of those twelve can find it
true. Firing on the hour boundary instead -- the obvious design -- silently
skips an hour whenever the machine sleeps or the daemon restarts across it.

Three behaviours ported verbatim because each looks wrong until you know why:
the grace minute (without it a check at 10:00:02 finds the 09:00:01 snapshot
59:59 old and skips the hour); tag rotation (five levels due at once produce ONE
snapshot tagged BHDWM, not five copies); and retention removing TAGS rather than
snapshots, so exceeding the daily limit cannot delete a copy that is also this
week's weekly.

Retention returns a plan and the daemon logs it with reasons before applying it.
It is the only routine here that deletes backups on its own initiative, and
being able to read afterwards what it decided is the difference between a bug
that can be diagnosed and one that can only be regretted. Verified against a
seeded repository: it untagged and removed the two old uncommented snapshots and
spared the commented one, which is nine hours old and well beyond the count.

Two deliberate differences from the original, both fail-safe, both pinned by a
test. An invalid snapshot no longer counts towards a level's limit -- counting
it inflates the total and can delete a good snapshot to make room for a broken
one. And rotation picks the newest eligible snapshot rather than depending on
the caller's sort order, which is what the original did.

Found while testing, and silent: `created` in info.json is unix seconds, and a
value that would not parse left the snapshot dated to the epoch while still
marked VALID -- so retention read it as older than everything and deleted it.
The date now falls back to the directory name, which is the timestamp, and a
snapshot datable from neither is marked invalid rather than dated to zero, since
an invalid snapshot is never pruned without evidence that it is incomplete.

Consumers cut over as far as they can while the Vala core still ships:

  * Main.cron_job_update() only removes now. It must not write, or cron would
    start a second timeshift alongside the daemon. It is kept and still called
    because it is the only thing that sweeps what older versions wrote.
  * crontab left the Vala dependency check, which refused to start without it,
    and cron left Depends.
  * The recovery image enables timeshiftd and drops cron. The enable hook sits
    after the deb-install hook -- the unit must be unpacked before systemctl can
    see it -- and both daemon and unit joined the verify loop.

check-deb.sh used to fail on the mere presence of a maintainer script. The unit
forces one to exist, so it now checks the invariant that guard was really
protecting: no maintainer script may mention /etc/timeshift, where the SSH keys
that open the repository live.

Still to come in this phase: DaemonClient.vala and the Main shim, so the GTK GUI
reads the event stream instead of polling its own core.

go test -race ./... green, go vet and gofmt clean, all three debs build and
verify.
Phase 8, second half. This is the scenario the whole port was for: apt is
blocked waiting for a snapshot, and opening Timeshift shows it happening.

Three pieces, and none of them is required.

apt-snapshot-guard now prefers /usr/libexec/timeshift/timeshift when the daemon
socket exists, so an apt-driven snapshot becomes a job with an id instead of a
private process nothing can see into. It falls back to the `timeshift` on PATH
otherwise, and that fallback is not politeness: this hook is fail-closed and
blocks dpkg, so it must never depend on the daemon being up. USE_DAEMON=0 pins
the old behaviour.

DaemonClient speaks the socket from Vala. It opens TWO connections on purpose.
A response carries the request's id and an event carries none, so one connection
could carry both -- but then every synchronous call would have to read past
events that arrived first and queue them, and a bug in that interleaving looks
like one call returning another call's answer. So `conn` does request/response
and `event_conn` only ever reads.

It also needs its own JSON accessors, which looks like duplication and is not.
Two dialects live in this tree: timeshift.json and info.json store every value
as a JSON *string*, which is exactly what TeeJee.JsonHelper's json_get_* parse,
while the socket uses real JSON types. Reaching for the familiar helper reads
every number as a null string.

MainWindow polls for a running job and shows a banner; JobMonitorWindow attaches
and draws the stream. The window has no Cancel, deliberately -- the job belongs
to the daemon and outlives every client watching it, so closing it while apt
waits on a snapshot must not abandon the snapshot apt is waiting for. Opening it
late is not degraded either: jobs.subscribe replies with the job's current state
before streaming, so a window opened eight minutes in draws what one opened at
the start would show.

Verified against a live daemon and a real 15 GB snapshot on a disposable
loopback repository. The GUI, started mid-backup, logged:

    DaemonClient: connected to timeshiftd dev
    MainWindow: the Timeshift service is running j-2 (create)

and then, once the job ended, reported no job and refreshed the list -- the full
loop, not just the attach.

Packaging: the Go CLI is now installed, at the private libexec path rather than
as /usr/bin/timeshift, because the Vala binary still implements flags it does
not and help2man builds the man page from whatever answers
/usr/bin/timeshift --help. dh_dwz now excludes both Go binaries BY PATH: one of
them is called `timeshift`, so -Xtimeshift would also have excluded the Vala
binary, which compresses fine.

go test -race ./... green, go vet and gofmt clean, all three debs build and pass
check-deb.sh, lintian clean apart from the pre-existing warnings.
config.set, snapshots.update and repo.reload. These are the precondition for
the GUI becoming a pure client, which is what Phase 9 needs and cannot have
while the daemon can only be read from.

config.set is a PARTIAL update and that is the whole design. A whole-config
write would mean a client one version behind silently reverting every key it did
not know about -- which is exactly the failure the Vala GUI already has against
timeshift.json, and the reason startup_delay_interval_mins had to be read-only.

Three refusals, each otherwise a silent loss:

  * An unknown key is refused, not ignored. A typo that is quietly accepted
    looks identical to a setting that does not work, and comes back as
    "Timeshift ignores my schedule".
  * A wrongly-typed value is refused with an error that names the format. Every
    scalar in this file is a JSON string, so a client sending a real `true`
    would have Unmarshal keep the old value: accepted and dropped.
  * The read-modify-write is under one lock. Several clients at once is the
    normal case here, and two saving different settings would otherwise each
    start from the config as they found it.

The merge goes through Marshal and Unmarshal rather than a second key table. A
separate table would be a second place to add a setting, and the one that got
forgotten would fail silently.

Fixed while here, and it was my own bug from the previous commit:
startup_delay_interval_mins was never written, so a hand-set value was deleted
the first time any setting changed. Writing it unconditionally is wrong the
other way, because the Vala GUI drops keys it does not know and the key would
churn. It is now written only if the file already had it -- correct in both
directions -- and a client can still introduce it deliberately.

config.changed and snapshots.changed carry no job, so the hub delivers them only
to subscribers following everything. They exist so a second window redraws
instead of showing state that is no longer true, which is the obvious failure
once several clients can attach at once.

Verified over a real socket: a valid update landed in the file in json-glib
format with no trailing newline, a misspelled key and a real JSON boolean were
both refused with the file untouched, and the Vala binary then read that
daemon-written config and rewrote it preserving both changed values. Compatible
in both directions, which is the only test that matters while two programs write
this file.

Also covers the engine's tag, description and delete-marker editing, which
landed unused with the scheduler and had no tests until now.

go test -race ./... green, go vet and gofmt clean, all three debs build.
The restore path has existed unwired since Phase 6. This gives it a plan, an
executor, two IPC methods and a command, and then runs it: a full restore of a
7.5 GB system onto a partitioned loopback target, ending in a clean filesystem
whose fstab named the TARGET's uuids rather than the source machine's.

Planning is a separate step and a separate method. restore.plan decides
everything and changes nothing, so the device table can be read before anything
is written. That is not ceremony: the failure that matters here is not a crash,
it is a restore that works perfectly onto the wrong disk, and someone
recognising the disk in a list is the only thing that catches it. The console
prompt requires the word "yes" rather than a bare Enter for the same reason.

Three faults the first real restores exposed, none of which a unit test would
have produced:

  * The ESP check FAILED OPEN. It rejected an ESP only when the ESP's disk and
    the root's disk were both known and different -- so an unknown root disk
    kept whatever the snapshot's fstab named, which is the ESP of the machine
    the snapshot came from. Restoring to another disk would have installed the
    bootloader into the RUNNING system's EFI partition. Caught by reading the
    plan, before anything was written. It now requires proof that the two
    match, and Device.DiskPath() exists because ToplevelParent() returns nil for
    a device with no parent and every caller read that as "unknown" when a whole
    disk, or a loop device, is its own disk.
  * The target was mounted INSIDE ITSELF. It lives under /run and the chroot
    rbinds /run, so it appeared beneath itself recursively, kept the filesystem
    busy, and the final unmount failed -- silently skipping the fsck that
    unmount gates.
  * Unmounts PROPAGATED OUT of the chroot. systemd mounts / as shared, so an
    rbind joins the original's peer group; tidying up the bind unmounted the
    target's own /boot/efi. Every bind is now --make-rprivate.

Also found: the source-readability probe ran AFTER the exclude list was written,
and I had put the exclude list in the directory the probe lists -- so the file
we had just written counted towards the probe's "at least two entries" and an
empty snapshot passed the check that exists to catch an empty snapshot, then got
copied over the target with --delete. The probe now runs first, and the exclude
list lives in the snapshot directory while the probe lists the payload beneath
it. Two independent reasons, because this check stands between a typo and an
erased disk.

And fsck was documented but never implemented. It now runs only when the restore
succeeded AND the target really unmounted AND the device is not mounted anywhere
else, checked per device against /proc/mounts. "fsck -y" answers yes to e2fsck's
"The filesystem is mounted. If you continue you WILL cause SEVERE damage".

The restore is a job, so it can be watched from another client and outlives the
one that started it. The Go CLI stays at the private libexec path: a restore of
the RUNNING system, and a btrfs restore, have still not been done in a VM, and
those are the two cases that differ from what has been tested here.

go test -race ./... green, go vet and gofmt clean, all three debs build.
makeafide and others added 28 commits September 1, 2026 08:22
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HsdsKZHSkDgHzXHmqNSZ97
An audit of what the port had actually retired found the answer was
almost nothing: 35,635 lines of Vala, one file deleted over the whole
effort (AppLock.vala), one method genuinely gutted (cron_job_update),
and Main.vala grown from 4,400 lines to 6,565. Meanwhile the Go CLI had
quietly reached full flag parity -- every flag AppConsole accepted plus
fourteen more, the only refusal being --clone -- and both reasons
src-go/meson.build gave for parking it at a private path had expired.
--restore was implemented and VM-verified in +ssh39, and help2man builds
a clean page from its --help.

So the CLI takes the public path and the Vala console binary goes. With
it goes Main.parse_some_arguments(), which re-parsed a subset of the
command line a second time from the constructor: a new flag had to be
added in two places or it worked by accident. Only --debug is left,
because only --debug has to take effect before initialize() runs.

The move deletes a property nothing would have missed until it
mattered. --create has no in-process path -- connect() fails hard on
ErrNoDaemon, and only --list falls back to opening the repository
itself. That was survivable while apt-snapshot-guard's fallback ran the
Vala binary, which needed no daemon at all; once both branches are this
binary, the hook's documented promise that it "must never depend on the
daemon being up" is only true if the client makes it true. It is a hook
that blocks dpkg, so the failure is every package operation on the
machine.

connect() therefore starts the daemon when the socket is absent:
systemctl first, so socket activation and the unit's dependencies are
respected, then the binary directly with setsid for a machine whose
units are masked or that has no systemd -- which is what a recovery
environment is. Two refusals keep it from doing harm: only as root, and
only for the compiled-in socket, because --socket names a daemon the
caller is running themselves and starting the system one would point the
command at the real repository. It waits by CONNECTING, not by stat-ing:
a daemon that died leaves the socket file behind, and both units being
stopped on this machine left exactly that.

Verified by running it, not by reading it. With both units stopped the
CLI brought them back; with both MASKED it spawned the daemon itself,
SID equal to PID, while systemd still reported the service inactive.

A second defect the move introduces, fixed here rather than found later:
a timeout now leaves the job running on BOTH of the guard's branches.
That used to be true only of the socket branch, because the PATH binary
did the work in its own process and timeout(1) stopped it. The Go client
only watches a job the daemon owns, so killing it stops nothing -- apt
gets refused while rsync keeps writing and holding the repository write
lock, which was observed for real before the cancel existed. The cancel
is no longer conditional on the branch, and a hook test that fails
without the change covers it (checked by reverting the fix: 1 failed).

Also found by building rather than reasoning: the recovery image's
verify loop still required usr/libexec/timeshift/timeshift, so building
an image would have failed. It now checks the socket unit too, which
that environment depends on to start the daemon at all.

Packaging: dh_dwz gains -Xusr/bin/timeshift. -X is a substring match with
no anchor, so it also excludes timeshift-gtk and
timeshift-recovery-shell, which are Vala and compress fine. That is the
accepted price of not putting a program users type behind a /usr/bin
symlink into /usr/libexec. check-deb.sh now asserts the CLI is shipped
once and is the Go one -- by size, because the file list cannot tell a
4 MB Go binary from a 500 KB Vala one, and a reverted install_dir would
otherwise look entirely correct.

--help lists the refused flags, generated from the refusal table and
sorted, so the man page names them and the two cannot drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LjAGcZih8GYDRdT5paHfhN
DaemonClient could reach seven of the daemon's thirty-seven methods, and
it reached them by hand-decoding Json.Object at each call site. That is
the bottleneck for turning the GUI into a socket client: the remaining
thirty are wrapper-shaped work, not protocol work, because call_object()
already does the hard part.

DaemonApi is those methods with the decoding done once, and DaemonTypes
holds the results as GObjects so they can go straight into the GTK4 list
stores that Snapshot and Device sit in today. They are separate from
DaemonClient deliberately: the transport has about five reasons to change
and all of them are about sockets, the method surface has thirty and all
of them are about the protocol, and merging the two is how this codebase
got its first god object.

DaemonBridge gains begin_restore(). Mode.RESTORE has existed since the
bridge was written -- on_finished and on_phase both branch on it -- but
nothing could set it except watch(), which attaches to a restore somebody
else started. So the bridge could SHOW a restore and not run one, which
is a capability declared and not implemented: worse than absent, because
the surrounding code reads as though the path works.

Two defects found on the way, both silent by construction.

devices.list dropped everything HasLinuxFilesystem() rejected -- the
filter --list-devices applies. But the CLI does not use this method at
all, it scans in-process, so the filter was shaped for a caller that did
not exist and made the method useless for the one that will: a disk
carries no filesystem, so every DISK was dropped, and a device tree with
no disks has nothing for the partitions to hang from. Unlocked LUKS
containers and unformatted partitions went the same way. On this machine
it answered with one device where the scanner had found twenty-one. It
now reports the whole layout with pkname naming the parent, and reports
has_linux_filesystem rather than applying it. Safe to widen because
nothing consumed it -- checked, including the tray applet, which uses
repo.status and snapshots.list but not this.

And six of the parameter sets in this file were wrong when I first wrote
them, from memory rather than from the struct tags: repo.select has no
btrfs_mode, repo.ssh.setup_key takes a url rather than host/user,
log.parse uses name, log.entries uses kinds, snapshots.update uses
comments, schedule.check returns {requested} and not a job id.
json.Unmarshal IGNORES fields it does not know, so not one of those would
have been rejected -- every request would have succeeded, about something
else. repo.select would have silently ignored the btrfs choice.

That is why TIMESHIFT_IPC_SELFTEST exists. Every reader here defaults
rather than throwing, which is right for a daemon that may be absent or
skewed, but it means a wrong member name produces an empty string and a
zero and the first symptom is a GUI drawing "Not Selected" over a
perfectly good repository. The selftest calls every READ method against a
live daemon and fails on the specific silent losses: a snapshot with no
name, a snapshot with no date, a protocol mismatch, a device list with no
disks. It mutates nothing -- a diagnostic that changed the machine would
be a poor thing to reach for when the machine is already suspect -- and
TIMESHIFT_SOCKET lets it check a daemon built from the tree before that
daemon is installed, which is the right order.

It earned itself immediately: run against the INSTALLED daemon it
reported "1 devices, 0 disks" and failed, which is the version skew
correctly detected; against the new one it passes with 21 devices and 1
disk, and every other method decoding -- tags, sizes, RFC 3339 dates,
a 13-phase restore plan.

Two wire facts worth keeping. config.get answers in the ON-DISK dialect,
every scalar a JSON string, while every other method uses real JSON
types -- hence wire_bool, which accepts both, and which did not exist.
And Go's zero time.Time marshals as 0001-01-01T00:00:00Z rather than
being omitted, so schedule.status's "never ran" arrives as a real date in
the year 1; read naively it sorts before everything and displays as a
snapshot taken two thousand years ago.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LjAGcZih8GYDRdT5paHfhN
The first step of the GUI becoming a client, and the shape the rest will
follow: Main keeps its fields, and what changes is who WRITES them.
SnapshotRepo.load_snapshots() and check_status() now ask the daemon and
fill the same objects the polling GUI already reads, so all 29 GUI files
are untouched and the list, the status card and the two stat tiles are
daemon-fed.

Verified under Xvfb against a live repository: 29 snapshots with their
dates, tags, sizes, unshared sizes and comments, and a status card
reading "Timeshift is active / 29 Snapshots / 29.9 TB Available".

What that replaces is not one thing but five. The daemon has already read
every control file, already decided which snapshots are valid, already
computed the sizes -- a du(1) walk of the whole repository for rsync, a
qgroup query for btrfs -- and already settled the `live` flag. Over SSH
the local path is four round trips PER SNAPSHOT, which is why the remote
prefetch exists, plus a remote du. Status is four more probes: does the
directory exist, is it writable, does it support hardlinks, can the
account preserve ownership. All of it cached behind caps_checked
precisely because it is expensive, and the cache then another thing to
invalidate correctly. None of it runs now; the debug log shows no probe
at all.

It also makes listing a READ. The old path re-derived `live` against the
system's boot time and called update_control_file() when it disagreed --
so merely listing a repository could write to it, on a remote one over
the network, while something else might be mid-backup.

Snapshot.from_wire() opens nothing, which is the point, and the field it
sets are documented as such: a wire-built snapshot has read no
info.json, no exclude.list and no fstab, so its lists are empty because
nothing has been read, not because the snapshot has none.

Two details found on the way.

Snapshot had no way to date itself except info.json's `created`, falling
back to the UNIX EPOCH while still marking the snapshot valid -- the same
defect the Go side fixed, where retention then reads it as older than
everything and deletes it. The directory name IS the timestamp, so
date_from_name() gives a real answer, with the ranges guarded because
scanf is perfectly happy with 2026-99-99 and DateTime returns null for it.

And repo.status reported free space only inside a formatted string,
"29 snapshots, 29.9 TB free". That is the whole answer for a console
header and useless to a GUI, which has to compare free space against a
snapshot's size and render its own units; parsing it back out is the kind
of thing that works until a unit or a locale changes. StatusView carries
the numbers now.

Adding that field additively created a skew hazard worth naming, because
it is the mirror of the one that bit the typed client last time. JSON
drops what it does not know, so a daemon older than the field simply
omits it and a client reading the default reports a healthy 30 TB
repository as having no space -- confidently, because the status call
itself succeeded. Protocol version cannot catch an additive field. So the
client asks whether the answer was GIVEN, not what it was, and falls
through to probing locally when it was not. Checked both ways against
real daemons: the new one serves it, the installed old one falls back for
status while still serving the list, which is the right granularity --
degrade only the part that cannot be answered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LjAGcZih8GYDRdT5paHfhN
Device.get_filesystems() asks the daemon and builds the same Device
objects the GUI already reads, so BackupDeviceBox, BootOptionsBox,
RestoreDeviceBox and Main's own system detection are all fed from one
request instead of three subprocesses -- lsblk --pairs, df, and
/proc/mounts -- and their three parsers.

Verified against a live daemon: 21 devices, and detect_system_devices()
resolves / and /boot/efi to the right partitions and UUIDs from them,
which is the strictest consumer there is.

The interesting part was that it did not work at first, and the reason
is structural rather than a typo. update_partitions() runs from Main's
CONSTRUCTOR, and the global `App` is not assigned until that constructor
returns -- so any code reaching the daemon through App.daemon is
unreachable from exactly the routines that set the system up:
update_partitions, detect_system_devices, load_app_config. It failed the
way this kind of thing always fails here, by falling back silently and
looking like it had simply chosen lsblk.

So the connection no longer hangs off Main. DaemonApi.get_shared() is a
process-wide client that anything can reach, and Main.daemon now just
returns it. That is the right place for it independently of this bug:
the client is a resource, not application state, and keeping it on the
object being dismantled would mean moving it again later anyway.

Two fields had to be added to devices.list, and one of them is a
correctness matter rather than a convenience. Device.free_bytes returns 0
whenever used_bytes is 0 -- the guard that distinguishes an unmounted
device from a full one, since df cannot answer for something not mounted
-- so without used_bytes the whole tree would report no free space
anywhere, silently, and SnapshotRepo's space check reads size - used to
decide whether to delete old snapshots to make room. serial and revision
came along because the disk tooltip shows them.

That is the third additive field in this migration and the third chance
to make the same mistake, so it gets the same guard: the client asks
whether used_bytes was SENT, not what it was, and falls back to lsblk
when it was not. Checked against both daemons -- the new one serves the
tree, the installed old one produces an identical tree from lsblk and
says so in the log.

The device tree is proven in the selftest rather than by clicking through
the GUI, deliberately. Reaching the Location page means selecting a
location and the Settings window saves on close, so driving it to look at
the tree can change where the next backup goes; a diagnostic must not be
able to do that. The check exercises Device.get_filesystems() including
the pkname linking that turns the flat wire list back into disks with
partitions under them, and fails on the two silent losses: nothing linked
to a parent, and nothing reporting free space. On this machine: 21
devices, 19 top-level, 1 with children, 2 with free space.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LjAGcZih8GYDRdT5paHfhN
save_app_config() sends config.set rather than generating the whole
file. Two things were wrong with writing it directly, and both silent.

This build writes twenty-nine keys and the format has more --
startup_delay_interval_mins and engine today, and whatever a newer daemon
adds tomorrow. Generating the file from those twenty-nine DELETES the
rest, so a hand-set startup delay disappears the next time anyone opens
Settings and closes it. And the daemon holds the config in memory, so a
file written behind its back leaves it acting on a stale copy: the
schedule it runs and the schedule the Settings window shows can disagree
with nothing saying so.

Proved on an isolated config carrying a key this build does not know:
startup_delay_interval_mins survived the save. engine went away, which is
correct -- the Go writer omits it at its default deliberately, so
upgrading does not rewrite every config with a key the Vala GUI ignores.

Clearing a pause needed care. Unpausing works by leaving pause_snapshots
OUT of the file, and a merge cannot clear a key by omission, so it is now
sent as "" -- which the daemon's writer then omits, making the file
identical either way.

That immediately hit a defect on the Go side: a key that could be READ
could not be SET. Apply validates against Marshal(reference), and Marshal
emits pause_snapshots only when non-empty and engine only when non-
default, so neither appeared in the reference and both came back as
"unknown setting". Worse than a missing feature -- pausing could never be
turned ON through the daemon, because turning it on is exactly the case
where the current value is empty. Both keys are added to the reference
with their real values, not a placeholder, so a config genuinely carrying
"" does not come back with something invented in its place. Two tests
cover it and both fail without the fix (checked).

Falling back to the file write when the daemon refuses is deliberate. It
is exactly today's behaviour, so this is never worse than not trying, and
refusing to save would lose the change outright. The reason is logged,
because a refusal means the two key tables have diverged.

Which is how the second bug turned up: the fallback fired, the log named
it, and diffing the config afterwards showed snapshot_size had gone from
19,165,868,579 to 0. That is NOT caused by any of this -- the no-daemon
file-write path zeroes it too, which I checked before going further.

Opening the Settings window zeroes the system size estimate. Building the
Type page toggles the RSYNC radio, and the toggled handler treats that as
a person choosing a backend: it clears Main.first_snapshot_size so the
size is measured again. Closing the window then persists the zero. The
estimate is not decoration -- SnapshotRepo reads it as the room a first
backup needs, and Main.create_snapshot only compares free space `if
(first_snapshot_size > 0)`. So looking at the settings quietly switched
the space check off, and made the next backup wizard re-measure the whole
filesystem to get it back.

The guard starts TRUE rather than false, which took a second attempt to
get right: add_opt_btrfs() sets opt_rsync.active when btrfs tools are
missing, and grouping two CheckButtons settles which is on, all before
refresh() is ever called. A flag covering only refresh() still let
construction zero it, and the estimate was still 0 after the first fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LjAGcZih8GYDRdT5paHfhN
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LjAGcZih8GYDRdT5paHfhN
…ly IPC

A StatusNotifierItem plus com.canonical.dbusmenu applet in Python with
GLib/Gio and no GTK: the shell draws the menu from the data, so there is
nothing to render locally. It runs as the desktop user and is the first
consumer of the timeshift group's read-only subset outside the CLI. It
shows a verdict ("Protected - last snapshot 12 minutes ago"), the schedule,
the location in the daemon's own words, live progress for whatever job the
daemon is running - including the apt-snapshot-guard one - and offers one
action, Create snapshot now, through a fixed-argument pkexec wrapper.

Packaged as its own native deb (build-all.sh stage 4) with a polkit policy
whose grant check-deb.sh asserts in full, no maintainer scripts, and a
rate-limited create wrapper so a cached auth_admin_keep cannot be looped
into pushing older restore points out of retention.

controller.py owns every decision and takes its collaborators as
constructor arguments; app.py is wiring. tests/ drive the controller
through recorders and a fake clock, and the transport against captured
daemon replies: 162 tests, no bus, no display.

Icons are Timeshift's own shield with the state cut out of it, generated by
tools/make-icons.py in a symbolic set and a brand-colour set; the default
policy is monochrome until something needs attention.

The daemon side: jobs.Event now carries the job's Kind on every event, via
(*Job).event(), so a client following everything learns what started
without a jobs.get race that a short job wins.

Two findings from running it against the real desktop, both fixed here:
register_object_with_closures2 is GLib 2.84 and killed the applet on
Ubuntu 24.04 (register_object is the same call under the older name); and
a connect refused during systemd's bind-then-chown gap on
timeshiftd.socket parked the applet in "not permitted" for five minutes,
so a refusal is now believed only if it survives one retry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUPvdgL4tAQwV3x3Udr5oC
The palette is shadowmorph.com's, read from its stylesheet rather than
described: ink #070e16, green #22c55e, yellow #eab308, red #ef4444, blue
#3b82f6, muted #888ea8, and the six-stop purple-to-blue brand gradient,
which the running state wears. The voice follows too: one separator, the
middle dot, as in "Shadow · Morph · Focus".

The panel icon now shows progress itself. BUSY with a fraction resolves to
busy-<0..7>, the shield's ring filled in eighths, so a snapshot's progress is
visible on GNOME without opening the menu (the host draws no tooltip).
Eight steps is what 16px can show and bounds NewIcon to eight signals per
backup. Watched live through a real snapshot: busy-0 to busy-7 in order.

The menu's status rows carry a coloured 16px disc as dbusmenu icon-data --
green fine, yellow wants a look, red fault, blue information, the gradient
for work in progress -- while action rows keep a themed icon-name. Never
both on one row: the GNOME host draws icon-data only when icon-name is
absent. menutree names a dot key and never touches bytes; dbusmenu resolves
the key from a registry app.py loads once, and drops a key it has no bytes
for rather than raising.

A launcher icon of our own, timeshift-tray, in scalable plus eight sizes:
the shield in ink on the brand gradient, the tick cut through to the ground,
the composition of the site's own mark. Both .desktop entries name it and
check-deb.sh now proves Icon= resolves, alongside a check that every dot key
menutree can emit is installed.

All of it comes out of tools/make-icons.py: gradients are plain
linearGradient, which librsvg and QtSvg both implement; the progress arc is
an annular sector that flips the ring's parity back to filled.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EUPvdgL4tAQwV3x3Udr5oC
BuildBackupExcludes takes four inputs. Three of them describe the machine --
the active swap files, the fstab mount points that are not part of the system,
and the accounts -- and nothing produced any of them. The daemon passed
UserPatterns alone, so every snapshot since the Go write path landed was taken
with SwapFiles, ForeignMountPoints and Users empty.

Two of those cost real bytes:

  - A swap file is copied in full and is dirty on every run. DefaultExcludes
    names /swapfile and /swap.img, so the common layouts were covered by
    accident; a swap file anywhere else was not.
  - A foreign mount point is descended into. rsync is not given
    --one-file-system, so a data disk mounted outside /media, /mnt or /data was
    copied into the snapshot entire.

The third, Users, turns out to be masked: HomeExcludes already carries the
blanket /home/*/** and /root/**, and an operator's "+ /home/u/**" is emitted
ahead of it either way. It is fed here for symmetry, not because it was
breaking anything.

What made this invisible is worth naming, because the same shape exists
elsewhere in the tree. The package tests construct ExcludeInput themselves, so
they exercised fields the product never filled, and all of them passed. A test
whose only caller is the test proves the function, not the behaviour. The tests
added here therefore drive d.buildExcludes() against a fake system root and
assert against the finished list -- and the first draft of them, which called
the scanner directly, passed with the fix reverted. That draft is why the
scanner is a daemon field rather than a package function: the seam has to be
injectable or it cannot be crossed in a test.

Verified end to end rather than by reading. With a 64 MB swap file at
/srv/swaptest active, the exclude.list handed to rsync was captured from both
the installed 25.12.4+ssh41 daemon and this one. The diff is one line:

    34a35
    > /srv/swaptest

Nothing else in the list moved, and the operator's "+ /home/osouf/**" stayed at
line 1 ahead of the blanket rule, which is what keeps an included home included.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHJ5412NSFSyh1TD61qrRA
Phase 3 step 4b/4c: the GUI stops running the ssh toolchain and cryptsetup in
its own process and asks the daemon instead. That retires two of the reasons
the core needed a Gtk.Window -- the LUKS passphrase dialog raised from inside
Device.luks_unlock(), and the key-setup flow driven straight from
SshRepoBackend.

Wiring the GUI up first meant checking what would be lost, and one thing would
have been: remove_stale_keys(). ssh-copy-id APPENDS, so a reinstalled machine
leaves every key it ever installed in the remote's authorized_keys -- each one
still granting access, none of them holdable to account because the private
half is gone. The Vala backend cleaned those up; src-go had no equivalent, so
switching the GUI over would have dropped the feature silently. That is the
same shape as the exclude-list defect fixed in the previous commit, so it is
ported rather than lost.

RemoveStaleKeys keeps the original's safety rules, which are the whole point:
never truncate the original (temp file, rename over it), refuse to write an
empty result, mktemp rather than $$ because a predictable name in a writable
~/.ssh is a symlink target and a leftover authorized_keys.* is itself live
under "AuthorizedKeysFile .ssh/authorized_keys*", and a sentinel on stdout so a
login banner or a non-POSIX remote shell cannot read as "removed 0". The awk
finds the key type by PREFIX, so a line carrying options still parses, and it
rebuilds the whole comment to compare, so a key whose comment merely ends in
our marker is not deleted. KeyMarker() already matched Vala's byte for byte, so
keys installed by either build are mutually recognisable.

The tests run that script through a real shell against fixture authorized_keys
files rather than comparing it to an expected string: a mistake in the awk does
not look like a mistake, it looks like a working function that also deleted the
key somebody else's machine uses. Eight cases, including another machine's key,
an options-carrying line, a comment that merely ends in the marker, and the
empty-result refusal.

Two deliberate decisions:

  - The tidy now runs on the already-working path too. The Vala flow had no
    such shortcut and reached the tidy on every press of the button; without
    this a machine whose key already works could never clear what it left
    behind on an earlier install.
  - A tidy failure is logged and swallowed. The setup succeeded, and reporting
    it as failed would send someone chasing a key that is installed and working.

repo.ssh.test is now consulted on Test connection, for the reason only: the
repository status already says whether the location works, but as a status
code, so an unreachable host, a refused key and a missing path all arrived as
"not available". It answers in a sentence. The repository is still built either
way -- the probe is diagnostic, not a veto.

Verified against the live remote, read-only calls only: repo.ssh.test returns
"key-based login works", repo.ssh.scan_host returns the ED25519 fingerprint,
repo.select dry-run reports usable=true saved=false, and a bogus device is
refused by name rather than by boolean. The Vala IPC selftest passes against
this daemon, all nine methods.

NOT exercised live: repo.ssh.setup_key, because reaching RemoveStaleKeys means
writing to the real backup host's authorized_keys. The script itself is covered
by the shell-backed tests; only the ssh transport carrying it is untested, and
that is b.remote(), shared with a dozen proven methods.

Also drops three fields in Main left dead when the daemon client became a
process-wide singleton in the previous step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHJ5412NSFSyh1TD61qrRA
The "File and directory counts" panel rendered as ten captions with nothing
beside them: No Change, Created, Deleted, Changed and the six change kinds, all
blank, while the status line and the current file updated normally.

The daemon was sending them the whole time. Counters ride on their own
job_counters signal rather than on job_progress -- ten numbers only a progress
page wants, so widening job_progress would make every other subscriber carry
them -- and JobMonitorWindow connected job_phase, job_progress and job_finished
and not that one. DaemonBridge does connect it, which is why BackupBox has
always shown counts and this window never did.

A missing key now reads as "0" rather than blank. Zero is a fact about the
transfer; an empty label is a fact about the window, and only the first is
worth showing a person.

Verified from the live daemon that all ten keys arrive populated on a real
create -- unchanged 256683, modified 4346, timestamp 3898, created 144,
checksum 250, size 198 -- and that the key names match. Not verified as pixels:
every job short enough to arrange safely finishes inside MainWindow's
five-second poll, so the banner never appears.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzskcWZVUkLyLunk9W3z3t
The second character of an itemise line says what the thing is, and all three
matching regexes captured it into a group they then discarded. A client could
not tell a directory from a file.

That matters in one place and it is not cosmetic: the log view hides
directories on a dry run, because a restore lists every parent directory of
every changed file and on a real system they outnumber the changes. The Vala
parser recovered the type by stat'ing each path, which only works for a local
repository -- for a remote one it gave up and called everything a regular file.
Reading the column works for both.

A deleted path still reports false. rsync's "*deleting" line carries only the
name, so there is no type column to read, and the file is already gone.

Tested against the captured corpus as well as hand-written lines: 20
directories and 845 files across the three real rsync passes in testdata. If
the column stops being read, that count goes to zero.

Groundwork for moving RsyncLogBox onto log.parse / log.entries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzskcWZVUkLyLunk9W3z3t
`timeshift --create --snapshot-device X` wrote to the CONFIGURED repository. So
did --estimate and --delete. Worst of all, `--delete-all --snapshot-device X`
listed X, printed X's snapshot names, asked the operator to confirm deleting
those, and then deleted snapshots of those names from the configured
repository -- and because snapshots are named by timestamp, the names commonly
exist in both places, so it did not fail. It removed the wrong copies.

Every piece was present and individually correct. LocationOverride was defined,
the CLI parsed --snapshot-device into it, applyOverride turned it into a config
and openRepoOverridden opened it. Only two READ methods, repo.status and
snapshots.list, ever passed one. CreateParams.Location and
DeleteParams.Location existed as strings the daemon never read -- worse than
absent, because a client could set one and be silently ignored.

The irony is on the record: ProtocolVersion 2's own comment names
`--delete-all --snapshot-device X` as the case it was bumped to protect, and
that path was never wired.

What changed:

  - CreateParams.Location and DeleteParams.Location become
    *LocationOverride, matching the read methods; EstimateParams is new.
  - create, delete and estimate open the repository the request named.
  - The scheduler passes nil explicitly. Retention is the configured
    repository's own housekeeping and has no requester.
  - --delete-all sends the SAME override to the delete that it used for the
    listing. That one line is the whole data-loss bug.
  - A mode that cannot honour an override now refuses it. --restore is absent
    from the allow-list deliberately rather than accidentally: restoring from
    another repository is reasonable to want and nothing on that path reads an
    override today, so it is refused until implemented.
  - --help no longer claims the flags apply to every run; help2man still
    produces a clean page.

The refactor is the part that stops it recurring. d.openRepo(ctx) is DELETED.
It is how a handler came to look complete while silently ignoring the location
it was given -- the call read correctly and did the wrong thing. There is now
one opener, openRepoFor(ctx, ov), and all eight callers that mean "the
configured one" say nil where a reviewer can see it. A new handler cannot
forget the parameter, because there is no overload without it.

Protocol 3. Adding these fields without a bump would leave the upgrade window
-- new CLI binary, old daemon process, both labelled 2 -- writing to the wrong
repository silently. Both ends refuse a mismatch, so the bump turns that window
loud. DaemonClient.vala and the tray's constants move in step.

The tray's tests hard-coded 2 as "the current protocol" and 3 as "a different
one", so the bump broke thirteen of them for reasons unrelated to what they
test. They are relative to PROTOCOL_VERSION now. The exception is
test_model.test_real_reply, which keeps a literal 2 with a comment: it reads a
captured daemon reply, and its job is to prove the parser reads what was
actually sent.

Verified end to end against a disposable loopback repository, with the real
remote configured. Before: the create landed on the remote. After: the create
went to the loopback and the remote was untouched at 48 snapshots. The CLI
tests drive a recording daemon and assert on the request that leaves the
client, because a daemon-side test cannot catch this -- the daemon always
behaved correctly on the override it was given, and it was never given one.
Reverting the one-line delete-all fix fails that test with the message naming
the consequence.

Also resolved an open question from the previous session: the `rsync failed
(10)` seen on a local loopback is ENOSPC, not a defect. Reproduced with plain
rsync outside Timeshift, and the filesystem was at 100% with 0 available. The
receiver dies and the sender reports a broken pipe as socket IO.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzskcWZVUkLyLunk9W3z3t
…emote

Phase 3 step 5. RsyncLogBox stops parsing rsync logs in this process and asks
the daemon, which retires the last use of RsyncTask.parse_log outside the
restore wizard.

The old path DOWNLOADED a remote log in full before parsing it -- a 22 MB file
pulled over ssh so that a parser on this side could stat paths that do not
exist on this side. That download is gone.

Two things had to be fixed before the migration could work at all.

log.parse could not read a remote snapshot's log. It refused with "browse the
snapshot first", which no caller could act on: the only other way in is by
PATH, and that is confined to /var/log/timeshift, so a browsed snapshot's log
was still unreachable. The capability was declared and had nothing behind it
for every remote repository -- which is precisely the configuration the
refusal was written for. It now mounts the snapshot itself, parses, and
releases when the job ends. Mounted as root rather than as the caller: nobody
browses this, and the client's previous behaviour was to fetch the whole file
over the same connection anyway, so it is no more traffic and no more
privilege.

The entries are flat, which is what the view has always wanted. I had claimed
this step needed a tree rebuilt from paths; that was wrong and I should have
checked before saying it. RsyncTask.parse_log returns a flat ArrayList, there
is no add_child anywhere in it, and the widget is a ColumnView. FileItem is
used as a per-row record, not as a tree node. Nothing needed rebuilding.

What the entries did need was the itemise type column, added in the previous
commit -- without it the dry-run view cannot drop directories, and on a remote
repository the old parser could not tell either, because it recovered the type
by stat'ing a path that is on the far side of an ssh connection.

Deliberately not migrated: the restore wizard's own log. It is written by the
restore running in this process and lives on the restore target or in TEMP_DIR,
neither of which log.parse will accept by path -- and widening that guard would
turn a root daemon's log reader into an arbitrary-file-read primitive. That
caller moves with the restore itself in step 9, and until then it is the only
remaining user of the local parser. A snapshot's log has NO local fallback: the
path names a file in the repository, so falling through would replace a clear
failure with an empty list.

Known limitation, not introduced here: the queue runs one job at a time
regardless of kind, so opening a log while a backup runs waits for the backup.
The box says "Waiting for the Timeshift service..." rather than sitting on a
still progress bar. Fixing it properly means separating read jobs from the
single worker, which is queue surgery and does not belong in a UI step.

Verified against the live remote repository: 260,852 changes parsed from a
288,702-line log, the mount released afterwards, log.entries served from the
cache once the job had finished, and the viewer opened in the GUI showing
directory icons on "./" and "boot/" -- which is the type column arriving for a
repository the old code could not have read them from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzskcWZVUkLyLunk9W3z3t
Phase 3 step 6. MainWindow stops mounting anything itself: the daemon mounts,
this side opens. That split is not arbitrary. The ssh key lives in
/etc/timeshift/ssh and only root can read it, so the person at the keyboard
usually cannot reach the backup host at all -- while opening a file manager
needs their session, which the daemon does not have.

187 lines of local mount machinery go with it: the sshfs invocation on its own
thread with four fields of thread state, the fusermount3/fusermount/umount
cascade, the rmdir-not-dir_delete guard, the sshfs-install offer, and the GVFS
probe that preferred the user's own sftp:// mount when they happened to have
one. The GVFS path was an optimisation rather than a capability -- the daemon
mounts as the desktop user's uid either way, so the outcome is the same and
there is now one path instead of three.

snapshots.browse takes the uid and gid to mount for, because this process is
root under pkexec and the file manager it spawns is not. A LOCAL snapshot is
not mounted at all: it is already a directory on a mounted filesystem, and the
daemon says so with mounted=false, which is also what keeps a release from
unmounting the repository. Only paths reported as mounted are tracked, and the
release goes back over the socket -- browse_release refuses anything not under
the daemon's own browse root, resolving symlinks first, which is what stops it
being an unmount-anything call.

One behaviour change: the Browse button with NOTHING selected used to mount and
open the repository root. For a local repository it still opens the directory
directly, needing neither daemon nor privilege. For a remote one it now says
"Select a snapshot to browse its files." There is no "browse the repository"
method, and inventing protocol to list a directory of snapshot names is a poor
trade when the useful operation is one click away.

Verified against the live remote repository: clicking Browse mounted the
snapshot at /run/timeshift/<pid>/browse/<hash>, the tree was readable as the
desktop user rather than only as root, the GUI spawned nautilus as osouf at
<mount>/localhost, and closing the window released the mount and left no
browse directory behind. The no-selection case shows the dialog and mounts
nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzskcWZVUkLyLunk9W3z3t
Phase 3 step 7. RecoveryBox stops shelling out to /usr/sbin/timeshift-recovery
and asks the daemon, which is the last GUI page that reached neither the Vala
core nor the daemon -- it ran the tool itself, on its own worker threads,
parsing --machine output inline.

Status, enable, disable and install all move. RecoveryToolTask is deleted with
them: it had no other caller, so the file, its meson entry and its AsyncTask
subclass go entirely.

The status page needed no rendering changes at all. The daemon passes the
tool's own key=value output through verbatim as `fields`, so render() reads
exactly the keys it always read -- INSTALLED, DISABLED, STALE, GRUB_OK, HOTKEY,
TARGET_DEV, TARGET_KIND, ENV_VERSION, HOST_VERSION, INSTALLED_AT. Taking the
map rather than the typed booleans beside it keeps one parser instead of two
that can drift apart.

Three details worth keeping:

  - An absent tool is an ordinary state, reported as available=false, so this
    no longer probes for the binary with file_exists. It also no longer
    conflates that with an absent DAEMON: saying "recovery tool not installed"
    when the package is installed and the service is merely down sends someone
    to install what they already have.
  - enable and disable read `ok` rather than "the call returned". Enabling can
    fail for a reason the method itself succeeds through, most often
    GRUB_TIMEOUT being 0 -- GRUB then reads no keyboard at all and the hotkey
    that reaches the environment can never be pressed.
  - install streams over its OWN connection pair rather than the shared
    client's. A subscription is one per connection, MainWindow watches whatever
    job the daemon is running, and this build takes minutes; sharing would mean
    one of the two silently losing its stream. Same reason DaemonBridge owns
    its own.

Verified against the running daemon: recovery.status returns all twenty fields
including every key the page reads, and the Recovery page renders from them --
"Disabled" with the amber shield, target /dev/nvme0n1p2 (root), version
25.12.4+ssh41, the install date, and Enable offered rather than Disable.

NOT exercised: enable, disable and install. All three rewrite GRUB or run
mmdebstrap for minutes on this machine, and the environment is deliberately
disabled here, so pressing them to watch them work is not mine to do. They are
two-line calls onto daemon handlers that already existed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzskcWZVUkLyLunk9W3z3t
Phase 3 step 8. Six places cancelled work by calling App.task.stop(), and one
paused it with App.task.pause(). After DaemonBridge started mirroring daemon
jobs into App.task, that object is a bag of numbers nobody executes -- so on a
daemon-run snapshot the Pause button set a flag on nothing while rsync carried
on copying, and Cancel stopped nothing at all.

DaemonBridge now exposes pause_active/resume_active/cancel_active over a single
static handle to the bridge currently mirroring a job. A single slot rather
than a list, because the daemon runs one mutating job at a time -- the same
invariant that lets pausableRunner hold one process. Each returns false when
nothing is being mirrored, which is the caller's signal to act on the local
task instead; during the migration both are real, since a restore still runs
in this process.

MainWindow is the exception and deliberately so: closing it DETACHES rather
than cancels. That is the scenario the port exists for -- apt waits on a
snapshot, and the window that happens to be watching it must not abandon it.
Only work running in this process is stopped there.

The pause button also stops reading App.task.status to decide its label. For a
mirrored job that status arrives from the job and does not change the instant
the button is pressed, so the button would have needed a round trip to know
what it had just done.

Verified against real processes rather than by reading. With a create running
against a disposable repository under /mnt (excluded from backups):

  jobs.pause  -> paused    rsync states became "T T T" -- all three
                           processes SIGSTOPped, and still T two seconds later
  jobs.resume -> running   states back to "S S R"
  jobs.cancel -> the rsync processes were gone

App.task.pause() cannot produce any of that: it sets a flag on an object with
no process behind it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzskcWZVUkLyLunk9W3z3t
Phase 3 step 9. RestoreBox tries the daemon before falling back to the local
core, through the DaemonBridge.begin_restore() that has been sitting with no
caller since Phase 2. The bridge already handled Mode.RESTORE end to end --
phases, counters, and the three-way outcome that distinguishes ok from warnings
from failed -- so what was missing was the call.

The protocol had to be widened first, and the reason is a defect rather than a
gap. ipc.RestoreParams carried only skip_grub, while the daemon hard-coded

    UpdateInitramfs: true,
    UpdateGrubMenu:  true,

which made two of BootOptionsBox's three checkboxes decorative: the page
offered "Update initramfs" and "Update GRUB menu" and then did both whatever
the person chose. Both are optional wire fields now, and POINTERS, because a
plain bool cannot tell "the caller said no" from "an older caller could not
say" -- and those need opposite answers. An absent flag still defaults to true;
a restored system whose initramfs names the old machine's devices does not
boot, so that is the only safe default for a client that cannot express it.

Verified over IPC against the real repository, with restore.plan, which decides
everything and touches nothing:

  nothing said     ... Re-installing GRUB2, Rebuilding initramfs, Updating GRUB menu ...
  both declined    ... Re-installing GRUB2, Syncing file systems ...
  grub skipped too ... Preparing target system, Syncing file systems ...

The steps appear and disappear with the flags, which is what the checkboxes
were always supposed to control.

*** THE GATE IS NOT MET. ***

The plan gates this step on a VM restore of the RUNNING system driven from the
GUI, and that has not been done. It cannot be done on this machine: the test is
a restore that ends in a reboot. What is proven here is the planning path and
the flag plumbing; what is NOT proven is a restore actually executing through
the daemon rather than through Main.restore_snapshot(). Treat the local core as
the tested path until the VM run happens.

Two things left for Phase 4 rather than done here. The dead clone code
(mirror_system, one writer that assigns false, ~20 unreachable branches) was
listed as prep for this step on the reasoning that it takes an axis out of the
three files being rewritten -- but the rewrite did not touch those branches, so
removing them now would edit Main.vala's restore internals for no benefit while
Phase 4 deletes most of that file anyway. And the local restore path stays,
because it is still the fallback when no daemon is running.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzskcWZVUkLyLunk9W3z3t
Phase 4, first cut. The read paths verified over the last few steps lose their
local implementations, and the GUI gains the autostart that makes depending on
the daemon reasonable.

Three fallbacks go:

  - SnapshotRepo.load_snapshots() walked the repository itself, reading every
    snapshot's info.json -- four ssh round trips each for a remote repository,
    which is why it had grown a prefetch. Two listers that can disagree about
    what a repository contains is worse than one that can be absent, and
    auto_remove() acts on that answer.
  - SnapshotRepo.check_status() re-ran the writability, hard-link and
    ownership probes the daemon already runs, doubling the round trips to
    reach the same verdict. The capability cache built to hide that cost goes
    with them; invalidate_capability_cache() survives as a no-op because two
    pages call it before re-testing, and the daemon re-probes on every
    repo.status, so "test again" already means what they want.
  - Device.get_filesystems() parsed lsblk, then df, then /proc/mounts and
    stitched them together -- the scan block.Scanner was ported from. On
    failure it now returns an empty list rather than rescanning locally: a
    caller that sees no devices shows "no location", which is recoverable,
    while one that sees a differently-built list may act on it.

BootOptionsBox stops running its own lsblk and reads App.partitions. The
bootloader target has to come from the SAME model the restore plan reasons
about; two scans that can disagree is how GRUB ends up installed on a disk the
plan was not describing.

The GUI now starts timeshiftd when the socket is absent, the way the CLI does.
Socket activation covers "not running"; it does not cover a masked unit, a
container with no systemd, or a first run before the unit was enabled -- and
"absent" has stopped being a state this GUI can work around. Root only, default
socket only, and it waits by CONNECTING rather than stat'ing, because a daemon
that died leaves the socket file behind.

Verified with the units MASKED, which is the case socket activation cannot
help with: the GUI reported "no daemon at /run/timeshift/daemon.sock; trying to
start it", spawned the binary at PID 210823 with SID 210823 -- a session leader,
so it outlives the window that started it -- connected, and was still running
after that window closed. Then with the daemon up: the snapshot list drew all
50 snapshots with sizes and tags, and the Location page drew the device tree
with the disk, its ext4 partition and 406 GB free, none of it from lsblk or df
on this side.

The local restore path is untouched, per the unmet VM gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzskcWZVUkLyLunk9W3z3t
An audit of what the GUI still does locally that the daemon already serves,
and the removal of it. 1,734 lines go, and src/ shrinks for the first time
since this port began -- 36,258 to 34,524.

The three boxes tried the daemon and fell back to the local core. That
fallback is now a refusal, because all three are repository WRITES and the
daemon holds the flock that serialises them against a scheduled run and against
apt's own guard. A second writer on this side could take a snapshot into a
repository the scheduler was pruning.

Removing them made a long chain dead, which the compiler walked one warning at
a time: create_snapshot, create_snapshot_locked, create_snapshot_for_tag,
create_snapshot_with_rsync, create_snapshot_with_btrfs, delete_begin,
delete_thread, estimate_system_size, get_space_needed_for_rsync_snapshot,
run_post_backup_hooks, set_tags, save_exclude_list_for_backup.

Two findings from the audit are worth recording, because both were invisible:

  - SnapshotRepo.auto_remove() was already UNREACHABLE. Both call sites sit
    behind `if (app_mode.length != 0)`, and app_mode is always "" now that the
    GUI is the only caller. So the GUI has never run a second retention pass
    beside the daemon's -- which is a relief rather than a fix, and it means
    remove_untagged, remove_marked_for_deletion, remove_invalid and remove_all
    were dead with it. Snapshot.remove/remove_rsync/remove_btrfs went the same
    way: 32 apparent callers were all collection .remove(), none of them a
    snapshot.
  - RsyncSpaceCheckTask lost its last assignment with the create path, so
    App.space_check_task was permanently null and BackupBox's whole "checking"
    branch was drawing a phase that could no longer occur. The file is deleted.

That last one is a real behaviour change and not just cleanup. The space check
was a link-dest-aware rsync dry run against the destination, sizing the
snapshot about to be taken; the daemon's estimate measures the whole system
with no --link-dest, so it answers a coarser question. The counts panel is
simply always the transfer's now. Worth restoring on the Go side eventually --
it is already on the deferred list from the audit.

Verified by driving the GUI against the daemon after each removal, not by
trusting the build: the snapshot list drew all 51 snapshots with sizes, tags
and comments, and the Location page drew the device tree with free space.
A clean rebuild reports no unused methods.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzskcWZVUkLyLunk9W3z3t
The RepoBackend tranche: 672 lines, 1,576 down to 904, with no change to any
call site. Everything removed had already lost its last caller in an earlier
step; this is the sweep that collects them.

What went, and where it went instead:

  - The whole SSH key toolchain -- scan_host_key, trust_host_key,
    ensure_keypair, install_public_key, verify_key_auth, remove_stale_keys,
    ensure_key_dir -- replaced by repo.ssh.* in step 4b. RemoveStaleKeys was
    ported to Go in the same change rather than lost, which is why this can be
    a plain deletion now.
  - browse_uri and sshfs_command, replaced by snapshots.browse in step 6.
  - download_file and read_control_files, replaced by log.parse reading a
    remote snapshot in place and by snapshots.list. read_control_files existed
    to fetch every snapshot's control files in ONE round trip because doing it
    per snapshot cost four each; the daemon does that work now.
  - probe_writable, probe_hardlinks, probe_preserves_ownership and
    probe_xattr_support, replaced by repo.status in the previous commit.
    Those four are the reason SnapshotRepo had a capability cache.
  - upload_file, and shell_quote with it.

RepoBackend is not deletable yet and that is expected: rsync_rsh,
rsync_remote_path and rsync_prefix still build the transport for the local
restore, which stays until the VM gate. is_remote and display_name are the
other survivors, and both are values rather than behaviour.

Verified by driving the two remote paths that would break first if this had cut
too deep -- browse mounted a snapshot on the real repository, and the rsync log
viewer parsed and drew a remote log with directory icons. Both still work with
the code that used to implement them gone.

One thing observed rather than caused: killing the daemon mid-browse left a
mount behind, and the next daemon start unmounted it and removed the stale run
directories. That is internal/rundir's Reaper doing exactly what it exists for,
and it is the first time I have watched it fire.

src/ is now 33,852 lines, from 36,258 when this phase began.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzskcWZVUkLyLunk9W3z3t
The CronTab half of the tranche. cron_job_update() had been reduced to a sweep
of what older versions wrote, kept because it was "the only thing that removes"
those -- and its own comment claimed the daemon does the same sweep at startup.

It does not, quite. RemoveLegacyCron handles three FILES:
/etc/cron.d/timeshift-hourly, /etc/cron.d/timeshift-boot and
/etc/cron.hourly/timeshift-hourly. It does not touch root's own crontab, where
versions predating the drop-ins wrote "timeshift --backup" and
"timeshift-btrfs --backup" directly -- and the Go file's own comment names that
gap while implementing only the file half. Deleting the Vala sweep would have
lost it silently on exactly the machines that still need it: ones upgraded far
enough forward to carry those lines.

So RemoveLegacyCrontabEntries is ported first, with the same three rules that
govern the files, because this edits something the administrator also owns:

  - Only OUR lines go, and a line is ours only when it invokes timeshift AND
    asks for a backup. Either half alone matches somebody's wrapper or another
    program's flag.
  - A commented line is a note, not a schedule.
  - The remainder is written back WHOLE, and `crontab -r` is used only when
    nothing of anyone else's survives -- writing an empty crontab through
    `crontab -` is rejected by some implementations.
  - No cron installed, and "no crontab for root", are success with nothing to
    do. That is the normal case now.

Verified on this machine rather than only in the fakes. With root's crontab
holding a comment, both legacy lines and an unrelated job, the daemon reported
"removed legacy timeshift lines from root's crontab lines=2" and left:

    # test crontab
    30 3 * * * /usr/local/bin/keepme.sh

and against a machine with no root crontab it does nothing and creates none.
The crontab was removed again afterwards.

CronTab.vala and cron_job_update() are gone, with the four GUI calls that
invoked it after every wizard.

Subvolume is NOT part of this after all, and the audit is why. Only print_info
was dead; remove() and restore() are both held by restore_execute_btrfs, which
stays until the VM gate -- the same thing holding RepoBackend's rsync transport.
The 34 apparent callers of Subvolume.remove were all collection .remove().
Trying to force it out now would mean rewriting the local btrfs restore, which
is precisely the code the gate exists to protect.

src/ is 33,445 lines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzskcWZVUkLyLunk9W3z3t
The gate step 9 was blocked on. A VM booting an installed Ubuntu 26.04, a local
repository on its own disk, a snapshot taken, the system deliberately diverged
from it, and then a restore of the RUNNING system driven through the wizard --
select target, dry run, confirm actions, summary, restore -- which rebooted and
came back on the snapshot's contents.

What proves it is not the marker file, it is the package version. The system was
running 25.12.4+ssh49 when the restore started and 25.12.4+ssh43 when it came
back, because ssh43 is what was installed when the snapshot was taken. The whole
system reverted, not a file. /etc/ts-gate-marker reverted with it, no
.timeshift-restore-failed sentinel was written, and the only failed unit
afterwards is swap.img.swap, which had already failed eight times on the
previous boot: that image has an fstab entry for a swap file it never had.

The run found five real defects, none of which could have been found on the
development machine, and every one of them is in code this session wrote:

  1. Device.get_filesystems() stopped populating the static device_list when
     the local lsblk parse went, so get_device_by_uuid() searched a null list
     and the GUI showed "Snapshot device not available" over a perfectly good
     repository. Invisible on a REMOTE repository, which is addressed by url
     and never looks a device up -- which is exactly how this host is
     configured.

  2. Snapshot.from_wire() sets sys_distro but never the LinuxDistro object, so
     init_boot_options() dereferenced snapshot_to_restore.distro.dist_id and
     SEGFAULTED. Every user opening the restore wizard would have hit it. There
     is a dist_id property now that falls back to the recorded name.

  3. The wizard's device page always shows /, /boot and /home, adding
     placeholders for the ones the snapshot lacks -- and begin_restore sent the
     list whole, so the daemon refused by name: 'the snapshot has no mount
     point "/home"'. Only entries with a device chosen are sent now; the daemon
     builds the rest from the snapshot's own fstab.

  4. log.parse on a LOCAL repository returned a path under a mount the handle's
     own close() then unmounted, so the job opened a path that had stopped
     existing. The handle is held until the job ends now, the same shape the
     remote branch already had. Also invisible against a remote repository.

  5. MainWindow adopted the wizard's own job. Its "nothing local is running"
     guard predates the restore moving to the daemon: both built a bridge for
     the same job, each replaced App.task, and the wizard sat forever on a dry
     run that had already finished. A bridge already mirroring in this process
     now counts as local work.

Four of the five only appear with a LOCAL repository, and this machine's is
remote. That is the whole argument for the gate, and it is why "it builds and
the daemon answers" was never going to be enough.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzskcWZVUkLyLunk9W3z3t
The page told the person "devices from which snapshot was created are
pre-selected" and pre-selected nothing. It read the snapshot's fstab through
snapshot_to_restore.fstab_list, which needs the repository mounted in THIS
process -- never true for a remote repository, and no longer true for a local
one since the mount moved to the daemon. init_mount_list() then found no
entries and fell through to its placeholder branch, adding empty rows for /,
/boot and /home whether or not the snapshot has them.

That was visible in the VM as "Select a device" on the root row, and it was
also the cause of a refused restore: begin_restore sent those placeholders and
the daemon answered 'the snapshot has no mount point "/home"'.

restore.plan now carries the whole default selection, not just the rows that
can block. Its Rows are a validation report -- deliberately only the root and
the ESP, because only those two stop a restore -- while a device page has to
draw every mount point the snapshot declares. The daemon already built exactly
that list, from the snapshot's own fstab, on the side that can read it; it was
simply not on the wire.

The GUI resolves each entry to a Device by UUID first and by path second. The
uuid is what the snapshot actually recorded, and a device path can move between
boots.

No protocol bump: Mounts is an additive RESULT field, and a client talking to
an older daemon gets an empty list and falls back to reading the fstab as
before -- a page with no pre-selection rather than a wrong one, which is where
it already was.

The plan is asked for the non-current-system case even when restoring in
place, because current_system collapses the answer to one "/ -> the running
system" row: the right summary, and the wrong thing to build a page from.

Verified in the VM, twice through the whole wizard. The device page now opens
with / -> vda2 (ROOTFS) and /boot/efi -> vda1 (ESP) already chosen and no
phantom /boot or /home rows, and the Summary lists BOTH devices where it
previously listed only the root -- the ESP is modified by a restore and was
not being named. A second restore of the running system then ran end to end
from those defaults with no manual selection at all: rebooted at 14:08, marker
reverted from DIVERGED-AGAIN-1788372340 to STATE-BEFORE-SNAPSHOT, no failure
sentinel, /boot/efi still mounted from /dev/vda1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzskcWZVUkLyLunk9W3z3t
…es too

The main window reported "0 B Available" over a local repository with
seventeen gigabytes free, while the CLI reported it correctly from the same
daemon.

SnapshotRepo.free_bytes returned remote_free_bytes for a remote repository and
device.free_bytes for a local one, and the second half stopped being true in
this phase. Device.free_bytes is `used_bytes == 0 ? 0 : available_bytes`, and
used_bytes comes from df -- which only sees a filesystem that is MOUNTED. The
repository is mounted for exactly as long as the daemon holds it open, which is
not when devices.list happens to run, so the repository's own partition comes
back with zeros and the property faithfully returns zero.

repo.status measures the repository's own mount path with the repository open,
which is the only moment the answer is knowable. It was already on the wire and
already being read -- and then thrown away for anything local, by a guard whose
comment explained that a local repository "reads it from its Device, which the
local scan filled". That was true when the scan ran df here. It has not been
true since the device model moved to the daemon.

has_space() went the same way. It probed twice over -- an ssh df for a remote
repository, a local df for a local one -- to compute a number repo.status
already has. It now refreshes the status and reads it, which also matters
because that number decides whether a backup may start and a figure cached at
window-open time can be minutes old. RepoBackend.query_space and
Device.query_disk_space had no callers left and are deleted.

Verified on both kinds, which is the point: the VM's local repository now reads
"17.1 GB Available /dev/vdb1" where it read 0 B, a GUI-driven create ran
through the space check without complaint, and this machine's remote
repository still reads "29.9 TB Available".

One scare along the way worth recording: the host briefly showed "No snapshots
available" and I took it for a regression in this change. It was a stale
binary -- an earlier compound command had failed before its go build ran, so
the daemon serving those requests predated free_bytes being in StatusView at
all. Rebuilding fixed it. The lesson is that "the daemon says None" needs the
daemon's provenance checked before the client is blamed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzskcWZVUkLyLunk9W3z3t
The VM gate changed which implementation is trusted. Until it passed, deleting
the local restore would have removed the only path that had been through a
restore of a running system; now the daemon's has been through two, and the
Vala one has been through none in this shape. Keeping an untested second
implementation as the FALLBACK for the most destructive operation in the
program is the wrong way round -- a refusal is recoverable, a restore that goes
wrong is not.

Main loses 2,924 lines: restore_snapshot and its locked wrapper,
restore_execute_rsync and restore_execute_btrfs, create_restore_scripts and the
four restore_{current,other}_{gui,console} drivers, the fstab and crypttab
rewriters, the rsync retry block, the phase markers, the snap-confine
capability check, the filesystem repair, and create_pre_restore_snapshot_btrfs.
RestoreScriptTask and OSDNotify are deleted outright -- OSDNotify had already
lost its last caller with the local create.

init_mount_list is daemon-only too. Its local branch could only work for a
local repository something else happened to have mounted, and produced three
empty placeholder rows otherwise -- the same placeholders that made the daemon
refuse a restore, because they name mount points the snapshot does not have.

RestorePhase moved to DaemonTypes rather than dying with the file it lived in.
It described a step of the generated script, matched by an ASCII key the script
echoed as "@@TS_PHASE:<key>"; the daemon reports the same steps as job events,
so the type belongs with the rest of what arrives over the socket.

Two notes on method, because both cost time here. Vala only warns about unused
PRIVATE members, so the public half of a dead subsystem is invisible to the
compiler and has to be found by grep -- restore_execute_rsync sat there with no
callers and no warning. And a brace-matching deleter walks straight through the
braces inside a generated shell script's string literals: it removed the head
of create_restore_scripts and left the tail, which at least failed loudly.
Deleting from one member signature to the next is the safe shape.

Verified in the VM after the removal, not just built: the restore wizard still
opens with / -> vda2 (ROOTFS) and /boot/efi -> vda1 (ESP) pre-selected, the dry
run runs as a daemon job to "finished ok", the log parse follows it, and the
Confirm Actions page draws the file list. On this machine the main window still
lists 53 snapshots with 29.9 TB free.

src/ is 31,322 lines, from 35,971 when the audit began.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzskcWZVUkLyLunk9W3z3t
The applet said "restart the service after upgrading" whichever side was
behind. Upgrading to protocol 3 produced it for real:

    timeshift-tray: the Timeshift service speaks protocol 3 and this applet
    speaks 2; restart the service after upgrading

and the service was the correct one. The applet was the stale party, because
the two ship in separate packages and a running applet keeps its old code in
memory until it is restarted -- which makes "the applet is behind" the common
case, not the rare one. Following that advice resets a daemon that is already
right and leaves the mismatch exactly where it was.

Both places that say it now compare the two numbers and name the side that is
older: "restart this applet" when the service is newer, "restart the service"
when it is not. The menu row said "restart it", which was ambiguous in the same
way with fewer words.

The test asserts both directions, because a message that is right in one
direction is what was already shipping.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzskcWZVUkLyLunk9W3z3t
@makeafide
makeafide marked this pull request as draft September 2, 2026 19:49
@makeafide makeafide closed this Sep 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant