Ubuntu gtk4 - #561
Closed
makeafide wants to merge 85 commits into
Closed
Conversation
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 '&'. - 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.
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
marked this pull request as draft
September 2, 2026 19:49
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.