Commit Graph
102 Commits
Author SHA1 Message Date
.CooCoo 6e03f65bf4 craft-update: a failed check for a running attach is reported on macOS (#803)
detach_unmounted leaves a disk image's attachments alone when the check
for a running `hdiutil attach` of the image gets no answer, and it
returned Ok in that case. A failed install then ended with no note that
the image may still be attached. It now returns the check's reason as
an error, so the install's error says "the disk image may still be
attached" with that reason, and craft-update logs it at warn level.
2026-11-11 11:22:26 -05:00
Dylan M. Taylor 208171b920 update: stage replacements exclusively and refuse oversized tar metadata (#563)
* update: stage replacements exclusively and refuse oversized tar metadata

- self_update: stage_copy creates the staged file beside the target with
  an unpredictable name (tempfile, O_EXCL) and writes bytes and mode
  through the handle, for both the AppImage and the tarball standalone
  replacement; a link at the old .<name>.update names is never followed.
- install::tree::unpack: check_metadata walks the tar headers first and
  refuses PAX or GNU long-name records over 1 MiB, which the tar crate
  buffers whole before the entry loop sees a size; both passes read at
  most MAX_TAR_BYTES of decompressed stream.

* address review: refuse size-truncated update archives, fixed staging prefix
2026-11-11 05:36:05 -05:00
smittie2000 797d21a3f3 craft-update: accept exactly one download link per asset (craft-launcher#764); keep the Linux self-update off other copies
Fixes storytold/craft-launcher#764 in craft-update, and with it storytold/craft-launcher#765 (each link is tied to its own release's tag). Also brings back a Linux self-update rule that the extraction lost.

## Why

craft-update was copied out of ArtCraft Launcher in #311, so its `release.rs`, `version.rs`, `platform.rs`, `distro.rs` and `proxy.rs` are the launcher's files. craft-launcher#764 was fixed only in the launcher's copy. This PR brings the fix here, where every Craft app's self-update runs. Once this PR and #389 merge, the launcher drops its copy (storytold/craft-launcher#930).

## 1. Each asset has exactly one acceptable download link (#764)

**The problem:** an asset's link could point at another release, or climb out of the app's releases with `..` or `%2e%2e`, which github.com decodes before routing.

**The fix:**
- `release::expected_download_url(repo, tag, name)` builds the only link an asset may have: `https://github.com/<repo>/releases/download/<tag>/<name>`. The tag and name may only use ASCII letters, digits and `._+-`. A `+` is written `%2B`, as GitHub writes it.
- Reading the GitHub API, an asset is kept only when its link equals that URL. This also ties each link to its own release.
- `release_from_sums` builds its links with the same function.
- `bind_artifact_version`, which compares the tag and file name inside the link with the release's, reads `%2B` there as `+`. It decodes nothing else.
- An encoded dot (`%2e`) is rejected like an encoded slash (`%2f`, `%5c`), both in links and in file names from `SHA256SUMS.txt`.
- `tag_from_redirect` refuses `.` and `..`.
- `install::check_download_link` checks the asset's link, and the link of the release's `SHA256SUMS.txt`, just before downloading. It runs in `install_at` **and** in `self_update::update`, because a self-update doesn't go through `install_at`.

## 2. Linux self-update: only `/usr/bin/<id>` counts as the package

Before #311, the launcher checked the package databases (dpkg, rpm, pacman) only for `/usr/bin/<id>`. The extraction dropped that check and added a `Standalone` fallback, which broke two cases:
- **A copy in `~/bin`** was treated as the deb/rpm/Arch package whenever that package was also installed. The update installed the package with pkexec, then relaunched the `~/bin` copy, which it never touched.
- **An AUR build in `/usr/bin`**, which the code comment says is pacman's to update, became `Standalone`. The update then tried to overwrite a file pacman owns.

`linux_location` brings the old rule back:

| Where the executable is | What happens |
|---|---|
| `/usr/bin/<id>` | checked against the package databases, as before |
| anywhere else under `/usr`, `/opt`, `/bin`, `/sbin`, `/nix`, `/snap` or `/gnu` (Guix), or in a Homebrew `.linuxbrew` prefix | no self-update: its package manager updates it |
| `/usr/local`, the home folder, anywhere else | updates itself (`Standalone`) |

**Symlinked installs are left alone on purpose.** `current_exe()` follows symlinks, so a package that puts the real file in `/opt` and a symlink in `/usr/bin` lands in the second row and doesn't self-update. That's intended. All 14 Craft apps' packages (`packaging/linux/nfpm.yaml`) put the real file at `/usr/bin/<id>`, so this only happens with someone else's repackaging, such as an AUR `-bin` package, and that copy belongs to its package manager.

## 3. Fixes from reviewing this PR with craft-launcher (3 November)

Rebased onto main (`55332dd`) instead of merging it; #460, #392, #481 and #478 changed the same files. Then two reviews against craft-launcher main, which feeds this crate its releases:

- **A release knows its repository.** `Release::repo` is the repository the release was read from, and `check_download_link` checks links against it (empty means the app's own). The launcher's feed can rename a repository (`Feed::repo_for`); checking against `app.repo` refused the renamed repository's downloads.
- **A `+` matches either way.** `release::same_download_url` reads `%2B` as `+` on both sides. The launcher's own `release_from_sums` and feed write a plain `+`, and so do releases saved by an older version, so only accepting `%2B` refused those self-updates. Nothing else is decoded (`%2b` still fails).
- **The launcher's tag and file-name rule (craft-launcher#1024).** `valid_tag`: up to 64 of `[A-Za-z0-9._+-]`, no leading dot, used for the API's `tag_name`, the redirect and download links. `valid_file_name`: the same characters, no leading or trailing dot, no Windows device name (`CON`, `nul.txt`, `COM1.msi`). The launcher's feed calls `release::valid_tag`, so craft-launcher#930 needs it here.
- **`ask_pages` fetches `SHA256SUMS.txt` from `expected_download_url`**, the link it stores.
- **Guix and Homebrew on Linux** are left to their package manager (table above), like Homebrew and Nix on macOS (#460).

## What changes for apps

- **Loopback mirrors:** a build that downloads from a developer's loopback mirror must turn on `craft-update/local-mirror`, or the new check refuses the mirror's links. The launcher's `local-mirror` feature turns it on (craft-launcher#930). Apps rolling out under #319 without a mirror need no change.
- **Linux:** copies under the package-manager folders above no longer update themselves. That's how the launcher behaved before #311.
- **`+` in a tag:** handled (section 1). No Craft release uses `+` today. GitHub's link form was checked on bell-sw/Liberica's releases: the tag and name keep `+`, the link has `%2B`, and the `releases/latest` redirect has a plain `+`.

## Compared with the launcher

I compared launcher 2d12245 (the snapshot #311 copied) and launcher `main` with this crate.
- **All of the launcher's security fixes from 15–25 October are here:** #747/#864, #865/#748, #867, the proxy part of #868, #689, #815, #821 and the archive limits. The launcher's two commits after the snapshot (#871, #903) change no behaviour here.
- **Two things are here but not yet in the launcher:**
  - the minute-long `hdiutil attach` retry (#349): craft-launcher#930 takes it;
  - the zip 9 drive-prefix test (#335): it waits for the launcher's zip 9 bump (storytold/craft-launcher#899).
- **Follow-ups that don't belong in this PR** are filed separately (linked below once filed).

## Tests

- **New:** `a_download_link_must_be_its_releases_own`, `a_plus_in_the_tag_is_percent_encoded_in_the_link`, `install_accepts_a_plus_release_up_to_the_download`, `update_accepts_a_plus_release_up_to_the_download`, `checksum_names_with_encoded_separators_make_no_download`, `update_refuses_a_link_that_is_not_its_releases_own`, `only_the_packages_own_path_is_a_package_and_system_folders_are_left_alone`.
- **Extended:** `install_rejects_an_artifact_whose_version_is_not_the_advertised_one` (another repo's link, a foreign checksums link), `drops_downloads_that_point_elsewhere`, `file_names_cannot_escape`, `tag_from_redirect`.
- **Mutation check:** `update_refuses_a_link…` fails when the call in `self_update::update` is removed.
- **Mutation check:** both `…_accepts_a_plus_release_up_to_the_download` tests fail with the bind error when the `%2B` decoding is removed.
- **On Windows, after merging main (31 October):** `cargo test -p craft-update`: 232 passed, 3 ignored, the same without default features. Clippy `-D warnings` is clean with default features and with `egui,local-mirror`, and `cargo fmt --check` is clean.
- **Merged with #389 (before the 31 October merge of main):** 173 passed, 28 without default features, and clippy for wasm32 is clean.
- **Launcher (before the 31 October merge of main):** with this branch merged with #389 (craft-launcher#930 pins that merge), the full ArtCraft Launcher suite passes: 401 tests, plus clippy, fmt and the wasm32 build of its core.
- **Added for review (3ad9074):** `two_links_for_one_asset_keep_only_the_releases_own`, `install_refuses_links_off_the_releases_own_before_downloading` (other hosts, and a `SHA256SUMS.txt` linked elsewhere), `install_refuses_while_the_installed_copy_is_running`, `a_download_that_fails_its_checksum_replaces_nothing`; `a_download_link_must_be_its_releases_own` also refuses other hosts. On Windows: 236 passed, 3 ignored; clippy and fmt clean. The bad-checksum test fails when the server sends the real file.
- **Added in section 3:** `tags_are_plain`, `a_plus_link_matches_either_way_and_nothing_else_does`, `a_release_knows_its_repository`; `file_names_cannot_escape` takes #1024's cases; the install test checks a renamed repository and a plain `+`; the Linux location test adds Guix and Linuxbrew. On Windows after the rebase: 251 passed, 3 ignored; the workspace builds; clippy `-D warnings` is clean for this crate (with `local-mirror`) apart from `translate::button` being unused, which comes from main's #508.
- **Not built yet:** the Linux-only `detect` code, and the Unix branch of the running-app test. CI builds it first, once a maintainer approves the workflow runs.


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Bug Fixes**
  * Update downloads are now restricted to assets belonging to the selected release, including checksum files. Unrelated or invalid links are rejected before a download begins.
  * Release links with unsafe path components or invalid redirect tags are rejected. Encoded plus signs in release tags and filenames are handled correctly.
  * Linux installation detection more accurately distinguishes package-managed installations, other system locations, and standalone installations, improving whether self-updates are available.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-11 07:47:52 +01:00
.CooCoo 5a93050c9f update: integration of the open fixes to craft-update (#763)
* update: an unsigned Windows copy gets Download instead of Update and Restart

The update helper installs nothing for an unsigned copy, so the prompt's
Update and Restart button on a local or unsigned build downloaded the
release and then stopped with "this copy isn't signed". is_self_updating
now answers false for an unsigned copy on Windows, and the prompt offers
Download. CRAFT_UPDATE_DEV still offers the update.

* update: check that a Windows copy can install before downloading

windows_precheck ran after the download, so a copy that could not install
downloaded the whole release first. The checks that do not depend on the
download now run before it; a new MSI's signature is checked after it.
The cache folder is created first, so a first update passes the check.

* update: a failure recorded before the running program is not reported

take_outcome reported any recorded failure at the next start, so a copy
put in place after an update had failed still opened with "The update
could not be installed". A failure record older than the running program
is now removed without being reported. A failure recorded after the
program was put in place, such as a failed helper's, is still reported.

* update: a Cargo build with a separate build directory is a development build

With build.build-dir set, Cargo keeps .rustc_info.json and the profile's
.fingerprint, deps and build folders in the build directory, and the
program's own folder has only its lock file. Such a build counted as an
installed copy and was offered Update and Restart. A profile folder with
Cargo's .cargo-lock under a target root with CACHEDIR.TAG is now a
development build, and gets the download link.

* update: a cache folder that can't be created is reported as that

same_cache_as_helper ignored the error from creating the folder, so a
failure read as a cache in the wrong place. It now names the folder and
the error.

* craft-update: a slow macOS signature check is not reported as a bad signature

`signed_by` gave `codesign` 120 seconds and treated every failure as a bad
signature, so a Mac that could not finish in time was told the app "isn't
signed by its developer".

The check now has 10 minutes and checks one bundle at a time. A job waiting
for its turn reports `JobPhase::Waiting` and can be canceled. A timeout, a
`codesign` that cannot start or is stopped by a signal, and any exit other
than 1 or 3 report that the signature couldn't be checked. Exit 3 refuses the
app as not signed by its developer. Exit 1 refuses it with `codesign`'s own
reason, because `codesign` also exits 1 for a bundle it cannot read. The app
is not installed in any of these cases.

On macOS, an install or uninstall now stops when `pgrep` gives no answer,
where a timeout used to read as "not running". `pgrep` has 30 seconds. An
install looks again after the signature check, which can wait behind other
apps' checks, and does not replace an app opened during the wait. A
self-update skips that second look.

* craft-update: a copy that cannot be replaced gets Download

A macOS bundle, an AppImage or a Linux standalone program was offered
Update and Restart even when the update could not replace it, so the
update failed only after the whole download.

`current_at` now leaves out such a copy when `access(2)` refuses what
the update needs. On macOS that is adding a subdirectory to the parent,
and adding a subdirectory to and deleting the bundle. On Linux it is
writing and searching the folder. A macOS copy under App Translocation
is also left out. Any other error from the check leaves the update on
offer, and `CRAFT_UPDATE_DEV` still offers it.

* craft-update: a macOS standalone copy that cannot be replaced gets Download

macOS `detect` returns `Install::Standalone` for a loose program, and
the update replaces it by writing beside it, as on Linux. `can_replace`
now checks that folder on macOS too, so a copy in a folder the user
cannot write gets Download instead of failing after the download.
`CRAFT_UPDATE_DEV` still offers the update.

* craft-update: a standalone copy whose share files cannot be replaced gets Download

The update replaces a standalone copy's files under `share` as well as
the program when the app's `share/<id>` folder sits next to `bin`. The
offer now also checks those folders, the `applications` and
`icons/hicolor` folders a release writes to, and the nearest existing
parent of one that doesn't exist yet.

* craft-update: a standalone macOS program without a release tarball gets Download

On macOS a program outside an app bundle counts as a standalone copy, and
the self-update replaces it from a macOS .tar.gz. Releases carry a disk
image and a command-line zip for macOS and no .tar.gz, so the prompt showed
Update and Restart and the update stopped with "has no Tarball (.tar.gz)
for macOS".

The prompt now also asks release_installs, which on macOS offers Update and
Restart to a standalone program only when the release lists that asset.
Without it the prompt shows Download. A release whose file list was not
loaded keeps the offer, because the install fetches the full release first.
CRAFT_UPDATE_DEV still offers the update. Other platforms and other kinds of
install are not checked against the release.

* craft-update: CRAFT_UPDATE_DEV no longer skips the release check

A standalone macOS program run with CRAFT_UPDATE_DEV was still offered
Update and Restart for a release without a macOS .tar.gz, and that update
failed. release_installs now checks the release for that copy too.

release_installs_at takes the executable, the opt-in setting and the
platform, and a new test checks a loose program in a temporary folder
against releases with and without the .tar.gz. The tests use SoundCraft's
release files, since craft-launcher ships no command-line zip.

* craft-update: a failed self-update keeps its download for the next try

On macOS, Linux and FreeBSD, prepare_update removed the downloaded
release file whether or not the install succeeded, so every retry after
a failed copy, signature check, package install or swap downloaded the
whole release again. A failed install now keeps the file, as install_at
already does, and the next try reuses it through Http::download, which
checks it against the published SHA-256 first and fetches it again when
it does not match.

- The cache folder's self-update-download.txt records the name and size
  of the release file before the download starts. A self-update for a
  different release file removes the finished and partial download that
  the record names, so each app keeps the files of one release at most.
- An install that succeeds removes the file and the record. So does a
  failed install whose record could not be written, since nothing would
  remove that file later.
- On macOS, when hdiutil cannot attach the disk image, the file is still
  removed: an image left attached by an install that ended part way
  makes hdiutil refuse the same file with "Resource busy", while a new
  download of it attaches.
- Windows is unchanged: its update helper removes the download after
  the app exits.

* craft-update: a macOS install that stopped partway no longer blocks the next install

When a macOS install ends before it finishes (the app quits or
crashes), the copy it was making stays beside the app and the update's
disk image stays attached. The next attach of the same file fails with
"Resource busy".

- Each staging folder's name now carries the installing process's id,
  and the folder holds an empty marker file. Before it stages a copy, an
  install removes the marked staging folders of processes that are no
  longer running, and their empty unmarked ones. It also removes a
  staging folder from earlier launcher versions when it holds only the
  app's bundle and has not changed for a day. Backup folders and other
  apps' folders are left alone.
- Before it attaches an image, an install detaches the images that
  earlier installs of the same app left at their mount points in the
  cache, when their process has ended or, in this process, when no
  install uses them any more.
- When the attach fails, the install detaches its image if it is
  attached with no volume mounted and no other attach of the same file
  is running.
- A staging folder that can't be removed, or an image that stays
  attached, is logged, and the error of a failed install says so after
  its reason.

* craft-update: the kept-download test accepts either signature refusal

An unsigned bundle is refused as "isn't signed" today and as a failed signature check once codesign's exit status is read, so the test checks that the install stopped on its signature.

* craft-update: the share check covers nested and pixmaps folders

A standalone copy in a prefix gets Update and Restart only when the
update can write every `share` folder it replaces files in. The check
covered `share/<id>`, `share/applications` and `share/icons/hicolor`
only, but the update writes a copy of each file next to its
destination first. A folder inside `share/<id>` (such as `models`), an
icon folder such as `icons/hicolor/256x256/apps`, or `share/pixmaps`
(where an older flat release puts its icon) that the user can't write
stopped the update after the whole download.

The check now also takes in every folder inside `share/<id>`, each
`icons/hicolor/<size>/apps` folder that holds the app's icon, and
`share/pixmaps`. Icon folders of other packages and other themes are
not checked. A linked folder is checked but not walked. The walk reads
at most 4,096 folder entries, so a large tree does not slow every
start; a folder past that is reported by the install as before.

The same check runs for a standalone copy on Linux, so a Linux copy
whose nested share folder or `share/pixmaps` it can't write now gets
the download link too.

* craft-update: a release folder that can't be read stops a standalone update

A standalone update in a prefix copies every file under the release's
`share` folder. The walk skipped any folder it could not list, and the
archive keeps each folder's mode, so a folder that nobody may list left
its files out while the update reported success. An error reading an
entry of the release's `bin` folder was dropped the same way.

These are now errors that name the folder, and the update stops before
any installed file changes. A release without a `share` folder still
updates.

A release whose `share` or `bin` is a link was followed, so the update
copied files from outside the release into the prefix. A `share` or
`bin` that is a link, or a `share` that is a file, now stops the update.
Links further down under `share` are still skipped.

The unpacked release is removed after a failed update even when it holds
a folder that its owner can't list. Before, such a folder stayed in the
cache.

* craft-update: the share check covers doc, metainfo and mime, and follows links

The share check for a standalone copy in a prefix now also takes in
`share/doc/<id>` and every folder inside it, `share/metainfo` and
`share/mime/packages`. Release archives such as VectorCraft's Linux and
FreeBSD ones put files in all three.

The walk now follows links, as the update's writes do. A copy whose
`share/<id>` is itself a link had none of its folders checked, and a
folder behind a link inside `share/<id>` was not checked either. A
folder the walk reaches twice is read once.

When `doc/<id>`, `metainfo`, `mime/packages` or `pixmaps` is missing,
the check no longer falls back to `share` itself. A copy whose `share`
the user can't write, but whose app, `applications` and `icons/hicolor`
folders exist, gets Update and Restart again, as it did before the
previous commit.

The entry limit of 4,096 now applies to each of `share/<id>`,
`doc/<id>` and `icons/hicolor` separately, and it counts files and
folders. A large app folder no longer turns off the icon folder check.

In a folder with the sticky bit, such as `/tmp`, the user can rename
over a file only when the user owns the file or the folder. A copy now
gets the download link when such a folder holds one of the app's files
that another user owns: the program itself, its other programs
(`<id>-<name>`, such as `<id>-cli`), anything in `share/<id>` or
`share/doc/<id>`, or a `share` file named `<id>.<ext>` or
`<bundle id>.<ext>`.

An icon size that the installed copy doesn't have yet is still not
checked, and a read-only folder that the user added under `share/<id>`
does count. Linux runs the same checks for a standalone copy, and the
sticky check on the program file for an AppImage.

* craft-update: the app-program check has its own name

The check for the app's programs in a bin folder was named is_program, the name of the Windows check for an MZ header, so the crate did not build on Windows. It is now is_app_program.

* craft-update: a missing doc, metainfo or mime folder is checked through share

A Linux release writes files into `share/doc/<id>`, `share/metainfo`
and `share/mime/packages`, and the update creates each of these
folders when it is missing. When one was missing, the share check took
its nearest existing parent inside `share` and never `share` itself. A
copy whose `share` the user can't write and that lacked one of these
folders was offered Update and Restart, and the update failed after
the whole download.

For a missing `doc/<id>`, `metainfo` or `mime/packages`, the check now
takes the nearest existing parent with `share` included, as it already
did for `applications`. Only the old flat release layout writes to
`share/pixmaps`, so a missing `pixmaps` is still checked inside
`share` only, and a copy whose `share` the user can't write keeps
Update and Restart when every other release folder exists.

A folder in the app's folder or in `doc/<id>` that the user can't
read is still checked against its own permissions, as before. The
copy gets Download when the user can't write and search that folder.
The check can't list the folders inside it and does not check them.
A new test keeps this rule, and the check does not refuse a copy only
because a folder in it can't be read.

* craft-update: the staging cleanup's doc says which folders it removes

A marked staging folder is removed once its process has ended,
whichever app that install was for: the folder's name does not record
the app. Only staging folders of earlier launcher versions are checked
for the app's bundle.

* craft-update: a failed attach leaves its image attached when pgrep gives no answer

detach_unmounted, from #766, checked for another attach of the same
image file with is_running, which reads a pgrep that gives no answer as
"not running", and then force-detached the image's unmounted
attachments. It now uses process::running, from #753, and detaches only
when the check answers that nothing is running.
2026-11-11 00:07:01 -05:00
dotCooCoo c365d75c48 craft-update: the restart-helper test's comment matches spawn_detached
spawn_detached now starts the process on its waiting thread, and the test
starts the helper on the calling thread. The comment names only what the
two share, standard streams set to null.
2026-11-08 14:22:08 -08:00
dotCooCoo 70ac5a64bb Merge #687's head into fix/update-messages 2026-11-08 14:05:59 -08:00
dotCooCoo f89c6686df craft-update: keep link addresses out of the opener's warnings
The warnings that open_url_with_fallback and the system opener log no
longer include the address being opened, so a link carrying a token or
personal details does not reach the application's log. The reasons from
the system stay in the messages.
2026-11-08 13:52:56 -08:00
dotCooCoo 5eb0bca0b1 craft-update: start a detached process only from the thread that waits for it
On Unix, spawn_detached starts the waiting thread first, and that thread
starts the process and waits for it. When no thread can be started, the call
returns an error and the process is not started, so no exited process is
left unreaped. On Windows, where an exited process needs no wait, the
process starts on the calling thread and no thread waits for it. The list
of processes kept for a later call, and the reaping it needed, are removed.

When the opener of open_url_with_fallback is still running after its
timeout, the open-url thread waits for it. An opener whose state can't be
read is stopped and waited for.

The tests run spawn_detached's waiting-thread path and check that the
waiting thread gets the process's exit status, that the process is not
started when no thread can start, and that a program that cannot start
reports it. They no longer need ps.
2026-11-08 13:43:12 -08:00
dotCooCoo 196d2053db Merge #687's head into fix/update-messages 2026-11-08 13:02:48 -08:00
dotCooCoo 8f17421a0a craft-update: say when a kept process is reaped and that start_pending_update is for exit
A process that no thread could wait for is reaped when a later call starts
another process after it has exited. start_pending_update is called only as
the app exits: afterwards a finishing installation starts its helper itself
instead of reporting through tick, and one still running when the process
ends installs nothing. The reaping test waits for the process to exit
through its output and fails instead of looping when reaping does not
happen.
2026-11-08 12:25:32 -08:00
dotCooCoo 658136392b craft-update: start the helper of an install that finishes after start_pending_update
An app that calls start_pending_update in on_exit without dropping the
Updater now gets the same handoff as the drop: an install that finishes
after the call starts its helper itself instead of sending its outcome to
an Updater that will not read it.
2026-11-08 12:04:02 -08:00
dotCooCoo 075ad12afc craft-update: keep the update step's error when the helper cannot restart the app
When the update helper's step fails and starting the old app again fails
too, the error recorded for the next start names both, the step's first.
Before, the restart error replaced the step's.
2026-11-08 12:04:02 -08:00
dotCooCoo 324f06f639 craft-update: wait later for a started process when no thread can wait for it
When spawn_detached or the system opener cannot start a thread to wait for
the process it started, the process is kept and waited for by a later call
once it has exited, so on Unix it does not stay in the process table.
2026-11-08 12:04:02 -08:00
dotCooCoo 01e0322721 craft-update: the restart-helper test fails when the helper exits without starting the command
The test kept the events pipe open for reading and writing, so a helper
that exited before writing `started` left the read waiting forever. The
helper is now started through `relaunch_with` with a spawner the test
controls: it starts the helper the way `spawn_detached` does, and a
thread that waits for the helper to exit writes `exited`, so every read
gets a line. The test also checks that nothing is spawned before
`Restart::start`.
2026-11-07 22:43:46 -08:00
dotCooCoo 4671e213a2 craft-update: restart helpers start at the exit and outcomes reach the new app
- On macOS and Linux the restart helper starts at the exit too
  (Restart::start), instead of when the update is prepared. A close the
  user cancels no longer leaves a helper that starts a second copy after
  its one-minute wait, or a Flatpak helper that gives up before Restart
  to Update.
- The record of a finished update names the release's version instead
  of its tag, so a release whose tag is not a version is still reported.
- A standalone Windows copy updated from the portable zip becomes
  portable. The helper now writes the outcome and the record of the
  update to the folders beside it, which the new app reads.
- When the check at start finds a release for a prompt the app already
  opened with Updater::offer, that prompt shows why the last update
  failed.
- Errors and failure reasons reach 4.5:1 contrast on the card and in
  the prompt's message box in both themes, and a long one scrolls inside
  a 160 px area so the card's buttons stay on screen.
2026-11-07 21:53:25 -08:00
dotCooCoo 8ecf7fa15d craft-update: self-update tests wait on handshakes instead of time
The Windows helper tests hand step_after_wait a wait that reports when
the helper reaches it and blocks until the test has reaped the stand-in
launcher, so the old-file check no longer depends on a 500 ms sleep or a
2 s observation window. The test-only wait_watch hooks are removed. The
launcher-that-keeps-running test asks the real wait with no timeout
instead of bounding the elapsed time.

The Unix restart test puts a sleep script on the helper's PATH that
reports each pause through a named pipe, and the started command reports
whether the old process was still running. A thread that waits for the
helper to exit writes to the same pipe, so a helper that dies early fails
the test instead of hanging it.
2026-11-07 20:34:29 -08:00
dotCooCoo 10d1cbb4ab craft-update: a check that answers during an installation reports the installation
A requested check still running when the user starts the installation from
the prompt answered with its own result once it finished, which replaced
Installing or Ready with UpToDate, the check error or nothing. Its answer is
now logged, and a requested check reports Installing, Ready or the recorded
helper failure, as a check started during the installation does.
2026-11-07 19:58:28 -08:00
dotCooCoo 75eacfc2a8 craft-update: an offer after a failed update clears the failure status
Updater::offer opens a prompt that carries the failed update's reason, and now sets the status to Idle, as a check that finds a release does. An app that shows CheckResultDialog while the status is not Idle no longer shows the failure card and the prompt at once.
2026-11-07 19:50:28 -08:00
dotCooCoo 53fbd5b636 craft-update: a retry after a failed helper waits for the exit and says why
A new install worker after start_pending_update gets a fresh handoff flag, so
a retry after a helper that failed while the app kept running reports through
tick and starts its helper at the exit, instead of at once. Updater::offer
carries the reason of a failed update into its prompt, as a check does.

The outcome records are read only from plain files: a link is not followed,
as is_plain_file already does for the files it moves. The tests wait for a
check's answer on its channel instead of polling with sleeps.
2026-11-07 19:44:54 -08:00
dotCooCoo 164dcf6a6d craft-update: an install still running at start_pending_update starts its own helper
start_pending_update now marks the Updater as exiting and lets go of an install
that is still running, so the worker starts its helper itself when it finishes
(or records its failure) instead of sending its outcome into a channel nobody
reads. Before, only dropping the Updater did this, so an app that called
start_pending_update in on_exit and kept the Updater alive lost the helper of
an install that finished afterwards. Drop now calls start_pending_update.

The docs of Action::Close and start_pending_update say that an app which ends
the process in on_exit loses an install still in progress.
2026-11-07 18:41:49 -08:00
dotCooCoo a1471f2cf3 craft-update: show_if alone decides whether Restart to Update closes the app
restart_to_update only asks for the close; show_if returns Action::Close
only while an update is ready, so the test of a call with no update
ready fails when that check is removed. The test of a folder in place of
an outcome record is removed: Windows refuses to open a folder, so it
passed with the plain-file check removed.
2026-11-07 18:34:39 -08:00
dotCooCoo 7bde3a7c1e craft-update: record an install worker that ends without an outcome at the exit
start_pending_update now treats a disconnected install worker as a
failed update, as tick does, so the next start says the update stopped
unexpectedly.
2026-11-07 18:09:51 -08:00
dotCooCoo 4c29fc7cd5 craft-update: Check Again after a failed update still says why it failed
A failed update's reason is kept until a prompt that offers the update
again shows it, so a dismissed failure followed by Check Again still
explains the failure. A prompt that already showed the reason does not
repeat it.
2026-11-07 18:05:12 -08:00
dotCooCoo 8ccbe9b2ca craft-update: record every update failure at the exit and clear a stale ready prompt
An installation that finishes after the Updater is dropped now records a
helper that cannot start, and a failed installation, so the next start
reports it. start_pending_update records an installation failure it picks
up at the exit too. When a requested check finds that a started helper
failed, the open prompt no longer offers Restart to Update: it says why
the update could not be installed, and no close is asked for.
2026-11-07 17:37:59 -08:00
dotCooCoo 878d4e712c craft-update: tell the user what happened to an update
A requested check while an update downloads or waits for the app to close
now reports Installing or Ready instead of leaving the result window
blank, and an app the launcher manages reports Managed instead of calling
its version the newest.

On Windows a prepared update says "Update ready. Close {app} to install
it." rather than that it is installed. The helper records the version it
installed just before it starts the new app, and the first start of that
version reports Updated. A recorded helper failure is reported once at the
next start (InstallFailed, with Check Again), in the prompt when the
update is offered again, and to a requested check while the old app still
runs. A failure wins over a stale record of a finished update, and a new
update clears both records. Records are read up to 4 KiB.

Action::Close is now a request: the Windows helper starts only at the exit
(dropping the Updater or start_pending_update in on_exit). A close the
user cancels keeps the update ready, and Restart to Update in the prompt
or the result window (Updater::restart_to_update) asks for the close
again. A helper that cannot start at the exit is recorded and reported at
the next start.

New strings for app catalogs: "Installing update…", "Update ready. Close
{app} to install it.", "Restart to Update", "The update could not be
installed: {reason}", "{app} updated to {version}." and "{app} {version}
follows the version chosen in the launcher, so it does not update itself."
2026-11-07 17:31:45 -08:00
dotCooCoo cbd9abfdc3 craft-update: say that the drop starts the helper only when the app drops the Updater 2026-11-07 17:11:33 -08:00
dotCooCoo da46f3ca20 craft-update: an install that finishes after the updater is dropped starts its helper 2026-11-07 16:34:01 -08:00
dotCooCoo 0e856027c5 craft-update: log an install failure picked up at exit and clear the finished job 2026-11-07 16:27:51 -08:00
dotCooCoo f5f45b090c craft-update: start a waiting update helper when the app exits without Close
The Windows helper of a finished installation started only when show_if
returned Action::Close. An app that exited another way (the window's
close button, a quit while unsaved work held the close back, an OS
shutdown) dropped it, so the update never installed and the download
stayed in the cache.

Dropping the Updater now starts a helper that is still waiting, once,
including one from an installation that finished after the last tick.
Updater::start_pending_update does the same for an app that ends the
process in on_exit without dropping the Updater; Action::Close's doc
comment names it.
2026-11-07 16:23:44 -08:00
dotCooCoo df66c35b07 craft-update: keep the app running when a helper thread cannot start
spawn_detached, run_command and open_url_with_fallback started their
reaper, pipe-reader and opener threads with std::thread::spawn, which
panics when the thread cannot be created. The Windows update helper now
starts from the UI thread when the app closes, so such a panic would end
the app. Each thread now starts through std::thread::Builder: a process
that has already started keeps running and the failed wait is logged,
run_command stops the helper and returns an error, and a failed opener
thread is logged.
2026-11-07 16:19:03 -08:00
dotCooCoo 85de86be60 craft-update: start the Windows update helper when the app closes, and never restart a running app
On Windows the update helper was started as soon as the download was
ready, then waited one minute for the app to exit. An app that held
unsaved work, or a user who did not close it, outlasted that wait; the
helper then started the old exe again with no arguments, which opened a
second window of the old version and installed nothing.

- self_update::prepare_update does what update did but returns a
  Restart. On Windows the Restart records the release as installed and
  starts the helper; on macOS and Linux it does nothing, since the
  restart is already scheduled. update calls prepare_update and starts
  the Restart at once, so its callers see no change.
- Updater starts the Restart when it returns Action::Close. If the
  helper can't start, the app stays open and the prompt shows the error.
- The helper does not start any exe when the app is still running after
  its wait. A step that fails after the app exited still starts the old
  exe, as before.
2026-11-07 16:14:17 -08:00
FredApps a231e517c3 craft-update: zip without zlib-rs so apps keep flate2's miniz backend (#608)
zip's `deflate` feature pulls in deflate-flate2-zlib-rs (and zopfli). Cargo
unifies flate2's features across the whole app, so on Windows every flate2
user in an app depending on craft-update switched to zlib-rs, whose level 1
is a static-Huffman quick mode that can produce output larger than its input
(Lightcraft's 16-bit ZIP TIFFs on Windows came out bigger than uncompressed).

Use `deflate-flate2` with flate2's default miniz_oxide backend instead; the
updater only reads Deflate zips, which that covers.
2026-11-05 13:20:56 +01:00
.CooCoo 893eafc086 craft-update: gate translated button labels with their egui callers (#542) 2026-11-04 09:29:06 +01:00
Dylan M. Taylor 9257c78cef Merge pull request #508 from cdhdt/update-dialog-translations
craft-update: the update dialogs take the app's translator
2026-11-02 22:52:25 -05:00
Dylan M. Taylor 0c332d87de Merge pull request #460 from marshiyar/fix/update-package-manager-copies
update: a package manager's copy and another app's bundle do not self-update on macOS and Windows
2026-11-02 21:39:55 -05:00
Dylan M. Taylor 0035af1539 Merge pull request #392 from Tomcatt/fix/standalone-update-whole-tree
fix(update): a standalone self-update replaces the whole tarball install
2026-11-02 21:39:39 -05:00
Dylan M. Taylor e21dfdba5d Merge pull request #481 from VoiceLessQ/fix/flatpak-bundle-reinstall
fix(update): handle a bundle Flatpak before 1.16.2 won't reinstall
2026-11-02 21:34:54 -05:00
Dylan M. Taylor 40751df9ac Merge pull request #465 from Jeremy8776/fix/msi-context-native
craft-update: read an MSI's install context without starting PowerShell
2026-11-02 21:34:50 -05:00
Dylan M. Taylor b49ce8590f Merge pull request #452 from ZIDAN44/fix/391-safe-release-link
update: the prompt opens only a safe release link; document token logging (#391)
2026-11-02 21:33:30 -05:00
Dylan M. Taylor e4108a3172 Merge pull request #513 from ljkx/update/sums-duplicate-names
update: one asset per name in SHA256SUMS.txt (#478)
2026-11-02 21:30:55 -05:00
ljkx e6189423a0 update: test Http::new's HTTPS-only setting, and keep refused links out of messages
An integration test builds the library without cfg(test), so it checks the release configuration: the remote agents refuse plain HTTP (an upper-case scheme too) without connecting, loopback links still work, and with local-mirror the remote agents take plain HTTP.

A refused hop's message names only the host. ureq's message holds the whole link, and a redirect to the file host carries a signed query. host() also drops a query and any user:password@.
2026-11-03 01:13:37 +01:00
ljkx 240a005494 update: look SHA256SUMS.txt names up in a map, not by scanning
A 4 MiB list holds some 60,000 names, and searching the earlier entries
for each one made parsing quadratic: about 8 s for a full-size list in
a release build. A map from name to position and a set of conflicting
names make it linear again, with the same entries in the same order.
2026-11-02 23:56:02 +01:00
ljkx 867c9479b4 update: keep the remote HTTP agents and their redirects on HTTPS (#477)
The ureq agents for remote hosts now set https_only, so a redirect to http:// is refused before connecting instead of being followed in plain text. A download refused this way fails at once rather than retrying until it pauses. Tests and the local-mirror feature keep plain HTTP, and the direct agents still serve loopback links.
2026-11-02 19:38:56 +01:00
ljkx e412b14fa3 update: one asset per name in SHA256SUMS.txt (#478)
A line repeated as is counts once. A name listed with two different
hashes is dropped, with a warning, so the result no longer depends on
line order: the file has no published checksum and its install is
refused, as for any file the release doesn't vouch for.
2026-11-02 18:43:43 +01:00
cdhdt 939ded3e3d craft-update: a translator given after the prompt opened reaches it (review)
Updater::with_translator stored the function for later prompts only; a prompt already open
kept the old one. It now also sets the open prompt's.
2026-11-02 13:13:12 +01:00
cdhdt 76e2bb0eb9 craft-update: the updater's own install failures are translated; an error written to status_message wins over a note (review)
A panicking or dropped install worker produced English text shown as a literal error. The
worker's answer is now a Failure: the installer's words, shown as they came, or the updater's
own, a Note translated when the prompt draws. StartupPrompt::message() prefers status_message,
so an error written straight to the public field isn't hidden by an earlier note.
2026-11-02 12:08:03 +01:00
cdhdt 493bdb9b1c craft-update: the prompt's own messages are translated when it draws; fill keeps unknown placeholders whole (review)
The installed, save-your-work-first, can't-auto-update and couldn't-start messages were
translated when set, so a language change while the prompt showed one left it in the old
language. They are now a Note (English text and values) in StartupPrompt::status_note,
translated each time the prompt draws; errors from elsewhere stay in status_message as they
came. set_note/set_message/clear_message keep only one; message() is what the prompt shows.

fill keeps an unknown placeholder through its closing brace (`{bad {app}` stays), the rule
craft_i18n::fmt follows.
2026-11-02 11:17:00 +01:00
cdhdt a9e5afebf8 craft-update: the update dialogs take the app's translator
The startup prompt, the Check for Updates result card and the messages the Updater puts in the
prompt were English whatever the app's interface language (storytold/wordcraft#5257).
`StartupPrompt`, `CheckResultDialog` and `Updater` get `with_translator(Translate)`: a
`fn(&str, &[(&str, &str)]) -> String` that gets the English text (the key, as in the craft-i18n
catalogs) and its `{name}` values, so translations may reorder them and an app on craft-i18n
passes `craft_i18n::fmt(i18n.t(lang, text), args)`. It is called when the card draws, so a
language change shows on the next frame. Buttons are looked up in title case and drawn in
capitals, so the apps' existing button rows serve. Without a translator nothing changes.
2026-11-02 10:55:49 +01:00
TomcattandClaude Opus 5.5 30b8911dd0 fix(update): refuse a folder in a file's place; per-process staging names
A shipped file whose destination is a folder is now caught before anything is
staged, instead of failing its rename after earlier files were replaced. The
unpack folder and staged .update files carry the process ID, so two updates
running at once can't remove or overwrite each other's files. Adds a test for
each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019oRr8q5tawLko7mLqEXf8g
2026-11-02 00:44:39 +09:00
TomcattandClaude Opus 5.5 eb2d0c1bc7 fix(update): stage every file before replacing any in a standalone update
A copy that fails (full disk, unwritable folder) now stops the update before
any installed file changes; staged copies are removed. Adds tests for an
unwritable destination and for the modes given to replaced files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019oRr8q5tawLko7mLqEXf8g
2026-11-02 00:23:54 +09:00