Skip to content

In-app updates, bundled CLI, instant results and a hands-off UI harness - #32

Merged
eladbash merged 13 commits into
mainfrom
void-update-and-harness
Oct 17, 2026
Merged

eladbash merged 13 commits into
mainfrom
void-update-and-harness

Conversation

@eladbash

Copy link
Copy Markdown
Owner

What's in it

Hands-off UI harness (T-18)

  • A debug-only drive feature lets scripts/drive.py send keys, clicks and scrolls straight into GPUI over a socket.
  • The window stays behind yours, never takes focus, and leaves your keyboard, mouse and clipboard alone. Several runs can go at once.
  • Release builds don't contain it.

Updates (T-10 – T-14)

  • Every release now carries SHA256SUMS and a manifest.json signed with an Ed25519 release key, made by a new manifest job in release.yml.
  • The app checks GitHub Releases at most once a day, with a toggle and a manual Check for updates in Settings → About. It is Void's only network request.
  • When a newer version is out, a banner above every screen and Update to vX.Y.Z… in the tray menu say so. The banner has What's new, Skip this version, and close.
  • Update now downloads with progress and Cancel, verifies the signature and checksum, then installs and restarts:
    • macOS: swaps the bundle in place, with rollback.
    • Windows: runs the installer silently.
    • AppImage: replaces itself.
    • .deb: shows the sudo apt install command.
  • void version --check and void self-update do the same for the CLI. A cargo-installed copy prints the cargo command instead.

Bundled CLI (T-16)

  • void ships inside the app, so Install hooks and MCP work without Rust.
  • Install void command on the Agents screen puts it on your PATH. install.sh links it too.

Instant results (T-17)

  • Void opens to the last scan and refreshes it in the background.
  • Rows, selection and filters stay put while it does, and a cache from another version or for other folders is ignored.

Before releasing

  • Add the repository secret VOID_RELEASE_SIGNING_KEY with the Ed25519 private key. Its public half is crates/deepclean-core/src/update/release-key.pem. Without it, the new manifest job fails the release.

Testing

  • cargo test --workspace: 478 passed. Clippy is clean with and without --features deepclean-app/drive.
  • I ran the new harness scenarios against the real app (updates, update-skip, update-tampered, cli, relaunch). All checks passed, with focus and mouse untouched.
  • Not run locally: the Windows installer path, building .msi/.deb/AppImage bundles, the macOS admin prompt, and restarting a real installed app after an update. CI covers compiling and testing on Windows and Linux.

scripts/drive.py used System Events keystrokes, a forced frontmost window
and cliclick, so it stole focus, typed into whatever app was in front and
moved the mouse. It could not run while someone worked on the machine, or
next to another agent driving another app.

A debug-only `drive` feature adds a Unix-socket driver to the app, active
only with both VOID_HOME and VOID_DRIVE set. Keys and clicks go straight
into GPUI (Window::dispatch_keystroke / dispatch_event); the window opens
unfocused and behind every other window, and each command forces a frame
so window-id screenshots stay current while it is covered. Clipboard
writes, notifications, Finder reveals and the folder picker are captured
for the script instead of reaching the OS. Release builds contain none of
it.

drive.py now talks to that socket, checks after every step that Void never
became the frontmost app (DRIVE_STRICT=1 also pins the mouse and frontmost
app), asserts on app state instead of sleeps, and follows the 1.2.1 Group,
Sort and Filters menus. CI lints the feature.

Builder-Task: T-18
A new manifest job runs after the bundles are attached. It writes
manifest.json (version, and each asset's size and SHA-256) and SHA256SUMS,
and signs the manifest with the Ed25519 key in the
VOID_RELEASE_SIGNING_KEY secret. The script refuses a key that doesn't
match the committed public half.

deepclean-core gains update::Manifest, which verifies the signature
against the embedded release key, refuses a manifest for another version,
and checks a downloaded file's size and hash. Its tests use a fixture
signed by the real script, so they pin the OpenSSL output format.

install.sh is unchanged: its asset pattern doesn't match the new files.

Builder-Task: T-10
deepclean-core gains update::release. It reads the repository's release
list, skips drafts, prereleases and non-version tags, compares versions
numerically, and picks the download each package updates from: the
macOS .app.tar.gz, the Windows setup.exe, the Linux AppImage or .deb, or
the CLI binary. VOID_UPDATE_FEED swaps GitHub for a local releases file,
and a sandboxed run never touches the network.

Settings → About has an Updates section: the current status, a "Check
for updates" button and a "What's new" link, plus a "Check for updates
automatically" toggle, on by default. The automatic check runs at launch
at most once a day. Its failures stay quiet; a manual check reports
them. What a check found is saved in config.updates without the "Saved"
flash. The README documents the request as Void's only network call.

Builder-Task: T-11
When the check (this session's, or an earlier one remembered in config)
finds a release newer than the running one, a banner above every screen
says so. It has What's new, Skip this version, Update… (which opens
Settings → About) and a close button. Closing hides it until the next
launch. Skipping saves the version and hides the notice until a newer
release appears.

The tray menu gains "Update to vX.Y.Z…" at the top while an update is
pending and not skipped. Picking it brings the window to the update.

Builder-Task: T-12
"Update now" in Settings → About downloads the release asset for this
install, with progress and a Cancel button. It verifies the manifest
signature (and version) against the embedded release key, then the
file's size and SHA-256, and deletes anything that fails. Then it
installs:

- macOS: unpack the .app beside the running one, check its bundle
  version, clear quarantine (see D-3), swap it in by rename, and put
  the old bundle back if that fails. Then start the new version once
  this process has exited.
- AppImage: replace the file in place.
- Windows: run the NSIS installer silently, then start Void again.
- .deb: show the `sudo apt install` command rather than escalating.

A sandboxed run can point the key and the install target at test
stand-ins (VOID_UPDATE_KEY, VOID_UPDATE_TARGET). It reports "Installed"
instead of restarting, and the driver records the relaunch. `void dev
release <dir>` writes a matching fake signed release from
testkit::release.

Builder-Task: T-13
Both reuse the core release check, with Package::Cli picking the
void-<version>-<target> asset. self-update works out how this binary was
installed. A cargo install gets the `cargo install --force --tag`
command, and the copy inside Void.app defers to the app's updater. A
release binary downloads the new one, verifies the manifest signature
and checksum, and replaces itself in place. Anything that fails
verification is deleted.

--home keeps both offline unless VOID_UPDATE_FEED points at a local
file. The fake release from `void dev release` now also carries a CLI
asset, which the new integration tests update against.

Builder-Task: T-14
cargo-packager now bundles `void` beside the app's executable:
Contents/MacOS, the Windows install folder, and /usr/bin in the .deb and
AppImage. find_void_cli() prefers that copy, except inside an AppImage,
whose mount path changes every launch. So Install hooks and MCP work
without Rust, and hooks point at a path that in-place updates keep.

The Agents screen gets a "The void command" card with an Install button:
- macOS/Linux: symlink into a writable folder on the PATH, else
  ~/.local/bin with the export line to add. On macOS an admin prompt
  offers /usr/local/bin instead.
- AppImage: copy instead of link, refreshed at launch when the
  AppImage's own copy changes.
- Windows: add the install folder to the user PATH.
Someone else's `void` is never overwritten. A sandbox only writes inside
its fake home.

The MCP one-liner uses the bundled path until `void` is on the PATH, and
install.sh links the bundled CLI after installing the app.

Builder-Task: T-16
The scan's results are saved in scan-cache.json beside the settings
(inside the sandbox for VOID_HOME runs) after each scan, clean and item
refresh. The file records its format, the app version and the scan
roots. On launch a matching cache is shown at once ("36 items · scanned
2h ago") and seeded into the results the cleaner reads. Items whose
paths are gone are dropped. A corrupt or mismatched cache is ignored.

A background refresh (Settings → General, on by default) keeps the rows
on screen and collects the new scan aside. When it finishes it merges by
ecosystem, kind and path, carrying over the selection, chosen actions,
open drawer and focus. Filters are untouched. New items whose paths no
longer exist are dropped, which covers anything cleaned meanwhile. The
backend likewise swaps its results only at the end, so a clean started
mid-refresh still finds its items. Cleans re-check their safety gates
when they run, as for any scan.

Builder-Task: T-17
It overflowed the About section's row and was cut off.

Builder-Task: T-11
drive.py gains scenarios for this branch's features, all against a
sandbox:
- updates: the launch check finds a fake signed 9.0.0, then the banner,
  tray item, What's new, and Update now installing into a stand-in app.
- update-skip: skipping survives a relaunch.
- update-tampered: a bad checksum is refused and deleted, and the app is
  untouched.
- cli: Install void command links the bundled CLI, and MCP uses the
  bundled path.
- relaunch: the cached scan shows at once, then refreshes.

The driver gains a scroll command, and scenarios can set environment for
the app they launch.

Builder-Task: T-18
On a fast runner the background refresh could finish between reading an
item's id and selecting it, leaving the test selecting a stale id. Pick
the item by path when selecting instead.

Builder-Task: T-17
How far the background refresh gets while the window opens depends on
the machine. On CI it could already be done, so asserting it was
mid-refresh flaked. Assert instead that a refresh ran: the scan time is
newer than the cached one.

Builder-Task: T-17
In-app updates, the void command bundled with the app, and results that
are there at launch. drive.py pins the fake installed app at 1.2.1 so
the update scenarios don't depend on the crate version.
@eladbash
eladbash merged commit daebcaa into main Oct 17, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant