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@.
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.
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.
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.
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.
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.
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.
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
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
Flatpak before 1.16.2 ignores --reinstall for a bundle and fails with
"<id> already installed" when that commit is deployed.
An app install now uninstalls the user's copy and installs again, without
--delete-data, as the launcher does (storytold/craft-launcher#1014). A failure
after that says what happened and what to do.
A self-update never uninstalls the app it runs from: its download goes right
after, so a failed install would leave nothing. That error there means an
earlier update landed and never restarted, so it goes on to the restart.
Root gets a flatpak command, so tests can record the real calls.
A Microsoft Entra ID account's SID starts S-1-12-1-, so the test failed on
those machines although the lookup works. It now checks the SID's shape.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
msi_context::probe now reads Windows Installer's own registration keys (the
packed product code under HKLM Classes\Installer\Products, HKCU
Installer\Products and HKLM Installer\Managed\<SID>) instead of a hidden
PowerShell ProductsEx lookup. Same route as storytold/craft-launcher#1082.
msi_context.ps1 is deleted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
self_update::detect fell back to Install::Standalone for any executable it
could not place, so on macOS a Homebrew, MacPorts or Nix copy, or a program
inside another app's bundle, was replaced in place, and on Windows so was a
scoop, winget, Chocolatey or Store copy. The package manager's record then
stays on the old version, and the bundle belongs to another app.
macOS now treats executables under /opt/homebrew, /usr/local/Cellar,
/usr/local/Homebrew, /usr/local/opt, /opt/local and /nix as owned by a package
manager, and any executable inside an .app bundle that is not this app's own
as not ours; none of them self-update. Windows does the same for scoop, winget,
Chocolatey and WindowsApps folders. The rules are plain path functions, so
they are tested on every platform.
Tested: three new tests failed before the change (the macOS and Windows folder
rules, and detect on an executable in another app's bundle) and pass after it.
cargo test -p craft-update (209 tests), clippy -D warnings and fmt pass on
macOS; the Windows branch was not compiled here.
Fixes#390 for macOS and Windows; the Linux side is #388.
get_api shared the 4 MiB limit for small answers. A launcher version list of
100 releases can pass that: SolidCraft's was 6.5 MB (craft-launcher#1021).
Other small requests keep 4 MiB.
Brings in main's launcher version pins (craft-update: apps respect the
launcher's per-app version choice, and the follow-ups through 9817c81).
One conflict, in updater.rs's tests: main made the newer() helper
pub(super) for updater_pin_tests.rs, and this branch added its two #391
tests (download_opens_only_a_safe_release_page,
every_releases_page_is_a_safe_web_link) just above it. Kept both tests
and main's pub(super). Everything else merged cleanly: Download still
goes through download_action (safe_web_link, then the app's releases
page), "What's new" still checks safe_web_link, and main's pin checks
in offer, check_now, finish_check and start_install are untouched. Main
adds no new way for the prompt to open a URL.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Flatpak launcher wrote versions.json inside its own sandbox, and no Flatpak
app could read the launcher's folder, so pins never reached Flatpak apps, nor
host apps started from their menu entry. The file now sits in its own folder,
ArtCraft/versions under the host's per-user data folder, found from inside a
Flatpak too (HOST_XDG_DATA_HOME, else ~/.local/share). A Flatpak app reads it
with one read-only grant for that folder alone (pins::FLATPAK_GRANT).
folders::host_data_local_dir replaces install's private copy of the same rule.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ureq 3.5's Debug request log lists an allow-list of safe headers and
leaves Authorization out; the unredacted wire is its Trace output
(ureq-proto's log_data). `Http::with_github_token` now says so: apps
should redact Bearer tokens or keep `ureq` and `ureq_proto` at Debug or
quieter.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Download closes the prompt, so a release link that fails
`release::safe_web_link` left the user with nothing opened. It now opens
`App::releases_url()` instead, which passes the check for every app in the
suite; a release link with `&` or another shell metacharacter is still
refused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`Release`'s fields are public, so an app that builds one from its own
feed or cache could hand any URL to the prompt's "What's new" link and to
Action::OpenUrl after Download. Both now go through
`release::safe_web_link`, as the launcher does before every open_url.
`Http::with_github_token` says that ureq's debug logs print the Bearer
token, so apps should redact it or keep ureq below Debug.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
finish_check now re-reads the choice before looking at the answer, so an app
the launcher pinned mid-check reports UpToDate and an up-to-date managed()
whether the network found a release, nothing, or an error.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
finish_check used the choice cached when the check started, so a launcher
pin made during the network request still opened the stable prompt. It now
re-reads the choice when the check finishes, as offer does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review fixes for #449:
- A prompt opened while the app followed stable stayed open after the
launcher pinned it, and its Update button still installed the stable
release. Reading a managed choice now closes a prompt that is not
installing, and an install re-reads the choice before it starts. A
running or finished install keeps its prompt, which tick reports through.
- Pins::tracks listed an explicit Stable entry despite its docs.
- The pretend-version child test read the developer's real pins file.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The launcher lets someone keep an app on an older release or on nightlies,
but the app's own update check never knew, so a pinned app prompted to
update on every start and "Update and restart" undid the downgrade.
`pins` is one small file the launcher writes (versions.json in its per-user
data folder, or the absolute path in CRAFT_VERSIONS_FILE) and apps read:
app id to Stable, Nightly or Pinned(tag), the launcher's own serde shape.
A missing or damaged file means everyone follows stable; an entry a newer
launcher wrote is left to the launcher and kept on save.
`Updater` reads it before each check and before an offer. An app kept off
stable gets no prompt and no network call; a requested check reports
UpToDate, and `Updater::managed()` says why, so the public Status enum is
unchanged and no app breaks on a rev bump.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The update prompt drew the app name's first letter in a blue square and used that blue for
its buttons and highlights. App gains accent and developer, and the prompt now draws:
- App::icon_png (decoded once per context, PNG only, at most 1024 px a side and never past
the renderer's texture limit), keeping the letter badge for an app without a usable icon;
- App::accent filling its buttons, the badge and an UPDATE chip, with white or black text,
whichever contrasts more, so every Craft app's accent reaches WCAG AA (4.5:1) for these
small labels; the What's new link is body text with an accent underline (accent-coloured
text missed 4.5:1 for several apps). No accent keeps the default blue;
- "by <developer>" beside the app name: App::developer, ArtCraft (app::DEVELOPER) for every
app in the storytold organisation unless the app passes its own;
- its own button states: backgrounds for unfilled buttons (LATER, DISMISS) when hovered and
pressed, as the host app's style can hover in a colour its dark text doesn't read on, and a
2 px focus ring in the body text colour on every button (egui shows focus in that state)
and on the What's new link.
Apps opt in by passing their own App, e.g.
App { icon_png: Some(include_bytes!("…128x128…png")), accent: Some([0x3f, 0x5f, 0xe0]), ..suite::WORDCRAFT }.
* craft-update: a Windows MSI copy updates itself in its own context, wherever it is installed
Self-update only recognised an MSI copy by the launcher's per-user folder (the HKCU InstallDir
key, or %LOCALAPPDATA%\Programs\<App>). Every app installed for everyone, in
Program Files\ArtCraft\<App> or the older Program Files\<App>, was treated as a loose folder and
updated by unpacking the portable zip over Program Files without permission.
- msi_context moves here from the launcher (craft-launcher crates/native/src/msi_context.rs at
b827fed, unchanged apart from visibility, MsiScope and scope_arguments). Windows Installer's
own registration decides a copy's context, for the launcher and the apps alike, so the
launcher can drop its copy and both routes follow one rule.
- New msi_copy module: a copy is an MSI copy when an App Paths entry names its exe (a registry
read, cheap enough for status checks). Its update asks Windows Installer for the context; the
App Paths hive is only the fallback when that lookup cannot run.
- The update stops, before the app exits, when the copy is registered in both hives, the lookup
is unclear, or Windows lists the app more than once.
- After the update the helper starts the exe the registrations name now, since the package may
have moved an exact old default folder.
- A failed self-update now names the Windows Installer log, as the launcher's installs do.
- The launcher's own per-user package is recognised and updated exactly as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* craft-update: a copy only takes the Windows Installer context of its own hive
Review follow-up (CodeRabbit on #419). The product code for the context lookup came from the
app's only uninstall record, which was not proven to own the running exe. A package writes App
Paths under HKMU, always the hive of its own context, so a registration whose Windows Installer
context differs from the hive that named this exe belongs to another copy. The update now stops
then, instead of taking the other copy's context.
Also documents the remaining helpers the diff touches.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* craft-update: an app in the old per-user folder is judged by its registration first
Review follow-up (CodeRabbit on #419). The folder rule for the launcher's per-user package also
matched an app installed in %LOCALAPPDATA%\Programs\<App>, and it ran before the App Paths
check, so such a copy skipped the product-count, conflict and context checks. Now an app is
judged by its registration first; the folder rule only covers an app copy no registration names.
The launcher in its own folder still keeps its old path whatever is registered: on a real PC it
has an App Paths entry and a stale second uninstall record, and registration-first would have
stopped its self-update.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* craft-update: when the Windows Installer lookup cannot run, the uninstall records decide
Review follow-up (CodeRabbit on #419). The fallback trusted the App Paths hive, so a stale entry
or a product registered in both scopes could run the update in the wrong context. The fallback
now follows the launcher's rule: the context every uninstall record of the product agrees on
(msi_context::single_scope over HKLM in both views and HKCU). Missing or mixed records stop the
update, and an agreed context that differs from the App Paths hive still stops it as another
copy's registration.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Review follow-up (CodeRabbit on #423). If LOCALAPPDATA\Programs or Programs\ArtCraft is itself a
junction, the exe's folder resolves through it but a default path built from a resolved
LOCALAPPDATA does not, so a default install was planned as a chosen folder. The Windows layer now
resolves each complete default folder (new default_folders) and hands them to the planner, which
only compares.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review follow-up (CodeRabbit and CI on #423):
- The profile roots and LOCALAPPDATA are canonicalised like the exe's folder, so a profile behind
a junction or a subst drive still matches its default folder and still counts as the profile.
- When a failed move leaves no program to start, the helper starts nothing, so the recorded
"run its installer" message is not replaced by a restart error.
- The default-folder test builds its expected path the way the plan does, so it also passes
where Path::join uses '/' (Linux CI).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reported by FredApps: PhotoCraft built from main as 0.17.0-integration.20261029.f5b3f123 offered
"Update and Restart" to 0.17.0, a downgrade. Semver puts a pre-release before its release, and
the update check and the self-update guard compared with plain semver.
New version::offers_update: a pre-release label the suite publishes (alpha, beta, rc, nightly)
sorts as before; any other label marks a build from source, which is only offered a strictly
higher X.Y.Z. check_for_update (through a new testable offered_release) and the self-update
guard both use it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Temporary (craft-libs#422, step 4): new msi_migrate module, to be removed two releases after the
app packages become per-machine only.
The released packages refuse a per-machine install while a per-user copy exists, so the move
removes first and installs second: remove the per-user product (no permission prompt; if that
fails nothing has changed), install the new version for everyone, and if that fails or
permission is refused, install the new version for the account again in its old folder.
Settings and documents live outside the program folder and are never touched.
Which copies move: a per-user default folder goes to Program Files\ArtCraft\<App>; a chosen
folder outside the profile moves in place; a chosen folder inside the profile keeps updating
for the account. The launcher's own package never moves.
Tested with real Windows Installer runs of a renamed dual-scope package carrying the apps' own
install-policy.wxi: the move with the permission prompt accepted (twice), and with it refused
(the app came back for the account at the new version, the failed log kept). Also unit tests for
every plan and the order of steps, and a mutation check (9 of 9 killed).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review follow-up (CodeRabbit on #419). The fallback trusted the App Paths hive, so a stale entry
or a product registered in both scopes could run the update in the wrong context. The fallback
now follows the launcher's rule: the context every uninstall record of the product agrees on
(msi_context::single_scope over HKLM in both views and HKCU). Missing or mixed records stop the
update, and an agreed context that differs from the App Paths hive still stops it as another
copy's registration.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review follow-up (CodeRabbit on #419). The folder rule for the launcher's per-user package also
matched an app installed in %LOCALAPPDATA%\Programs\<App>, and it ran before the App Paths
check, so such a copy skipped the product-count, conflict and context checks. Now an app is
judged by its registration first; the folder rule only covers an app copy no registration names.
The launcher in its own folder still keeps its old path whatever is registered: on a real PC it
has an App Paths entry and a stale second uninstall record, and registration-first would have
stopped its self-update.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review follow-up (CodeRabbit on #419). The product code for the context lookup came from the
app's only uninstall record, which was not proven to own the running exe. A package writes App
Paths under HKMU, always the hive of its own context, so a registration whose Windows Installer
context differs from the hive that named this exe belongs to another copy. The update now stops
then, instead of taking the other copy's context.
Also documents the remaining helpers the diff touches.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Self-update only recognised an MSI copy by the launcher's per-user folder (the HKCU InstallDir
key, or %LOCALAPPDATA%\Programs\<App>). Every app installed for everyone, in
Program Files\ArtCraft\<App> or the older Program Files\<App>, was treated as a loose folder and
updated by unpacking the portable zip over Program Files without permission.
- msi_context moves here from the launcher (craft-launcher crates/native/src/msi_context.rs at
b827fed, unchanged apart from visibility, MsiScope and scope_arguments). Windows Installer's
own registration decides a copy's context, for the launcher and the apps alike, so the
launcher can drop its copy and both routes follow one rule.
- New msi_copy module: a copy is an MSI copy when an App Paths entry names its exe (a registry
read, cheap enough for status checks). Its update asks Windows Installer for the context; the
App Paths hive is only the fallback when that lookup cannot run.
- The update stops, before the app exits, when the copy is registered in both hives, the lookup
is unclear, or Windows lists the app more than once.
- After the update the helper starts the exe the registrations name now, since the package may
have moved an exact old default folder.
- A failed self-update now names the Windows Installer log, as the launcher's installs do.
- The launcher's own per-user package is recognised and updated exactly as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A system install was offered only as a download. Reinstalling that bundle
through Flatpak's install-bundle action is denied without a password dialog
when polkit cannot fill in $(path). Use pkexec on the host instead, and keep
per-user bundles on flatpak install --user.
Co-authored-by: Cursor <cursoragent@cursor.com>