- The action row's width lost a gap for every app, pinned or not (`+ gap`), so
each row's menu ran 8 px past its column. The main button keeps its old slot;
a pinned app's grip now leads the row and the table widens its actions column
by one square while a pinned app is shown, so no button is squeezed (#956) and
every row stays in line. A test checks a pinned row against an unpinned one.
- The grip only shows in the grid and the table, where dropping works; on the
details page it was a control that did nothing.
- A released drag ends in one place, after the list draws, instead of in the
grid, the table, the empty list and each drop target.
- Move up and Move down use the drag's `reorder_pinned_to`; `reorder_pinned`
and its test go, and `pinned_neighbor` returns the slot.
- Drop the settings test that only re-ran its own steps (the UI test covers
hiding a pinned app), the tool-generated design note, and use "Lösen" for
Unpin in German.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* macOS: the Dock icon brings the window back after closing it (#456)
Closing the window to the menu bar ordered it out, and clicking the Dock
icon afterward brought nothing back: the Dock sends a "reopen" Apple
Event, which neither winit 0.30 nor eframe passes on, and handling it
directly needs unsafe code.
On macOS, closing to the tray now hides the app, as Cmd-H does. macOS
unhides a hidden app by itself, window and all, when its Dock icon is
clicked, it is opened again from Finder or Spotlight, or it is picked
with Cmd-Tab. "Show ArtCraft Launcher" unhides it before showing the
window. A start in the tray keeps ordering the window out, as the app
is still being activated then, which would unhide it.
* macOS: say why Shell.visible isn't resynced after the Dock unhides the app
A left click on the tray icon opens its menu on macOS, so the toggle that
reads this flag never arrives there.
* fix(packaging): declare the launcher's real window class in its .desktop file (#1026)
The window sets its app id (Wayland) and WM_CLASS (X11) to
ai.storyteller.artcraft-launcher, but the entry said
StartupWMClass=artcraft-launcher. Desktops that find the entry by its
file name were fine; an entry under another name (AppImageLauncher,
Gear Lever, a hand-made one) never matched, so the dock showed a
generic icon and the launcher could not be pinned.
* fix(packaging): no second menu entry when an AppImage manager already lists the launcher (#474, #394)
appimaged, AppImageLauncher and Gear Lever write their own entry for the
AppImage under another file name. AppRun added
ai.storyteller.artcraft-launcher.desktop next to it on every start, so the
launcher showed up twice in the app menu, and deleting the extra entry only
lasted until the next launch.
AppRun now installs nothing while another entry's Exec runs this AppImage,
and removes the entry it wrote earlier when own_entry says it is still
unchanged. own_entry also accepts entries that carry the old
StartupWMClass=artcraft-launcher, since that line is the only difference
from what earlier releases wrote.
* fix(packaging): a hidden manager entry doesn't stand in for ours
Menu editors hide an entry with NoDisplay=true or Hidden=true. When a user
hid the manager's duplicate that way, AppRun still counted it and removed
our entry, leaving the launcher out of the menu. Such an entry no longer
counts. Test 15e covers both keys.
* fix(packaging): a manager entry stands in for ours only with our window class
Read only the [Desktop Entry] group, so an Exec in a [Desktop Action] group
doesn't count, and require StartupWMClass=ai.storyteller.artcraft-launcher.
Gear Lever doesn't refresh its entry when the launcher replaces the AppImage
in place, so its entry can keep the old class, and without ours GNOME would
have nothing to match the window against.
- Put a space between Japanese and Latin words/placeholders in the
install-folder messages and "Show {n} more changes", as the rest of
the catalog does.
- "Installing, then restarting…": drop the completed-state wording.
- "On Linux, custom folders use AppImage format.": no longer identical
to the line above it.
Drafted with AI assistance; native-speaker review welcome.
Claude-Session: https://claude.ai/code/session_01K5NRZNWfaSLBWVzV8hivyS
Co-authored-by: Claude <noreply@anthropic.com>
* refactor(native): one PATH lookup for detect and process
process.rs (`which_on_path`) and two places in detect.rs's Linux module
each searched PATH the same way. They call `process::which` now.
These all ask what the user can run, so PATH is the right place to look.
install.rs is left alone: programs run as root come from the root-owned
SYSTEM_BIN_DIRS, never from PATH.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Document the Linux detect, find_binary and binary_version functions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Say every case where binary_version gives up
The 5-second limit matches the code. The doc also missed that the binary
can fail to start or exit with an error, and that it is killed on timeout.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Timestamps saved to disk never wrap the schedule arithmetic (#774)
A saved feed-throttle `not_before` outside the sane range — the far-future
check missed anything at or below zero — survived `load`, and `wait`'s
`at - now` wrapped: near `i64::MIN` it panicked a debug build and, wrapped
in release, kept the feed from ever being asked again. `load` now drops
any wait outside 0..=now + 2 * MAX_THROTTLE_BACKOFF and the subtraction
saturates.
The update-check schedule had the same shape: a `last_check` in the future
(an edited app.ron, a clock that ran fast and was later corrected)
postponed checks until real time caught up, and i64::MIN panicked. The
condition is now `update_check_due`, which treats a future time as due and
saturates; the two rescan comparisons that share the pattern saturate too.
Co-Authored-By: GLM-5.3 <noreply@z.ai>
* Test the saved throttle state at its limits (#774)
Covers the review's remaining cases: a saved `not_before` of i64::MAX is
dropped while a sane one (now + 600) stands, and `failures`/`throttles`
saved at u32::MAX cap the next backoff at its maximum without overflowing
(`doubled` caps the doublings at 16 and the counts add saturating).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Merge schedule tests into existing test module
* Preserve long Retry-After waits
---------
Co-authored-by: GLM-5.3 <noreply@z.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ui): keep a long Retry update button from pushing the menu out of the row (#956)
The table gives the main button about 160 px, but buttons grow to fit
their label, so "Retry update to 0.12.0" (205 px) pushed the menu out
of the actions column. When the full label doesn't fit, the button now
says "Retry update"; the version column already shows the version.
"Update to {v}" gets the same fallback to "Update".
Adds "Retry update" to the ten complete catalogs.
* fix(ui): fall back to the plain action when a short retry label still doesn't fit
Running the launcher in German showed "Update wiederholen" (173 px) is
itself wider than the table's 162 px slot, so the menu was still pushed
out. Retry and update buttons now try their labels longest first and end
on the plain action ("Update", "Install"), which fits in every catalog.
The German test checks both retry rows against an Install button.
* test(ui): check which Retry label each view shows (#956)
The slot test matched "Retry update" by substring, so it passed whether the
button kept its version or dropped it. Now it names the label. The table's
narrow slot shows "Retry update", while a card with room for it keeps the
full "Retry update to 0.12.0", which is what a wide enough slot should do.
The doc of `fitted` also names the card's top strip, which shows the version
there.
* test(ui): main buttons fit the narrowest window in every language (#956)
* fix(core): tighten tag, file name and notice link checks (#776, #782)
- Tags from GitHub, the feed and the latest-release redirect must be up to
64 of [A-Za-z0-9._+-], not starting with a dot (release::valid_tag).
A feed tag like `v0.6.0~beta-` plus a bidi override still read as
0.6.0-beta and was served; such a release now falls back to GitHub.
- Asset file names use the same allowlist, with no trailing dot and no
Windows device names (CON, nul.txt, COM1.msi).
- Notice links go through safe_web_link, so plain http and user-info
links are dropped, as in release notes.
- The 20-notice cap now counts the rename notices too.
* fix(core): treat only COM1-COM9 and LPT1-LPT9 as Windows device names
Microsoft's naming rules reserve COM1 to COM9 and LPT1 to LPT9 (plus the
superscript forms, which the ASCII allowlist already refuses), not COM0
or LPT0. Match them with a slice pattern instead of indexing.
* test(core): cover the hostile tags, names and links from review (#776, #782)
Adds a NUL in a tag, a file: notice link, 1,000 announcements, and direct
checks that a download link and a redirect use the same rules.
* fix(core): keep rename notices inside the notice cap (#782)
Twenty announcements left no room, so the cap cut the rename added after them.
* docs(core): notice links are https only now (#776)
* fix(ui): a press on the window edge only resizes (#571)
egui gives a press with no direct hit to the closest widget within 5 px, so a press in the resize band landed on the title-bar control below it, and a quick drag clicked it. Put a click-sensing Area over each band, above dialogs too.
* fix(ui): cover a grown window's edge from its first frame (#571)
An Area's own hit rect keeps last frame's size, so a press on the new
part of an edge still reached Close. A widget sized from this frame's
rect closes that gap.
* fix(ui): an edge press on the first restored frame only resizes (#571)
That frame has no edge areas yet and egui hit-tests last frame's widgets, so the press lands on a full-height caption button. window_buttons now drops a pointer click inside the resize band. Key presses carry no pointer position, so they still work.
* test(ui): press either side of the band's edge on the first restored frame (#571)
* fix(ui): the release notes card grows with its zoom (#594)
The card's size limit now scales with notes_zoom, and sheet::size still
holds it inside the window. Below 100% it keeps the usual size.
* test(ui): the notes card's height follows the zoom too (#594)
* test(ui): the notes card stays in the smallest window, and keeps its size below 100% (#594)
* fix(windows): read an MSI's install context without starting PowerShell
The update path ran a hidden PowerShell that asked the WindowsInstaller COM
object which context a product was installed in. That is the pattern
behavioural antivirus flagged in #1045. The context now comes from
Windows Installer's own registration keys, read the way ProductsEx reads
them: this account's managed and unmanaged products and the machine's,
under the product code's packed name. A key the launcher may not read is
still a lookup that didn't run, so the uninstall-record fallback is
unchanged. Nothing in an update starts PowerShell any more.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(windows): declare winsafe's advapi feature and avoid a panicking split
From review:
- current_user_sid uses HACCESSTOKEN and TokenInfo, which need winsafe's advapi
feature. It built only because craft-update enables it; now declared here.
- packed_code uses split_at_checked, like the .get() calls after it.
- The incident note lists the replaced lookup under "What changed", not "Still open".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test: accept any well-formed account SID, not only S-1-5-
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>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Command::Replace carried only the app. Between the "already exists"
question and the answer, the release can change (a new version, another
asset name, another folder), and the install then recomputed its
destination and wrote over a different existing file with replace=true.
Replace now carries the path from Event::Occupied. The backend checks
what is in the way again: if it is exactly that path the install
overwrites it; if something else is in the way it asks again; if nothing
is, it installs without leave to overwrite.
* fix(single-instance): exit only for a launcher that proved itself
A second start exited (Instance::Secondary) whenever the port's holder
answered with a version at least as new as its own, or failed the token
proof. Any local listener on 47413 could therefore keep the launcher from
starting without proving it belongs to the same account.
The running launcher now challenges every newcomer that offers a nonce,
whatever its version, and still grants Replace only to a newer one that
proves itself. The newcomer defers only to an authenticated answer; an
unproven or failed one is treated as an unrelated program and it starts
anyway, as it already did for a port held by something else.
* fix(single-instance): re-prove the port holder before exiting after a stalled takeover
* fix(single-instance): prove a stalled holder without the 0.0.0 probe
Launchers from 0.8.1 to 0.14.x only challenge a newer newcomer, so a 0.0.0
probe of a busy older launcher that stayed after a takeover was never proven
and the new start ran beside it. Greet with our own version, check the
holder's proof, and never answer the challenge, so the probe can only show
the window and never grant Replace.
* fix(fonts): match CJK face names without decoding every record
face_index decoded every family and style name record of each face into
an owned String, collected them, and only then compared. Name records can
share one long encoded string, so a crafted font at a candidate path
(a hand-installed MiSans, say) could make the UI thread decode and hold
hundreds of MiB of names.
Compare each name char by char against the short wanted name, stopping at
the first mismatch or one char past its length, keep nothing, and look at
no more than MAX_NAME_RECORDS records per lookup.
* fix(fonts): cap compared name records per name ID, not across both
The feed test wrote its fixture under a predictable PID-based name in the
shared temp root. Another local user could pre-create that tree with
symlinks, so the fixture writes would follow them with the test runner's
permissions. Use tempfile::tempdir, which creates a fresh 0700 directory
with an unpredictable name and removes it when dropped.
* fix(logging): drop HTTP wire dumps whatever RUST_LOG says
ureq_proto traces every byte it sends as hex-and-ASCII rows, the
Authorization header with the optional ARTCRAFT_LAUNCHER_GITHUB_TOKEN
included. The builder capped ureq_proto at debug, but env_logger lets the
longest matching module win, so RUST_LOG=ureq_proto::util=trace still
wrote the token to standard error and the log file, split across rows
where the Bearer redaction can't see it.
Wrap the env_logger Logger in a gate that drops ureq_proto trace records
before they are formatted, whatever the filter says.
* test(logging): pin wire-dump test filters against inherited RUST_LOG
Review of #1147:
- A failed OCI migration precheck (no version in the remote's AppStream data, polkit refusing
remote-add, offline) now falls back to the bundle update instead of failing it; only errors
once the reinstall starts are reported. The ref must list exactly the selected version.
- `flatpak list` names a named installation `system (<name>)`; the parser keeps `<name>`, and
one packages::flatpak_scope builds the --user/--system/--installation= argument everywhere.
- A repository-managed Flatpak on a pinned or nightly track gets the "update with flatpak"
hint instead of a plain `flatpak update`, and status no longer offers a GitHub version for it.
- The migration refuses while the app runs; the origin check after the reinstall can't be
cancelled; is_flatpark_origin matches only `flatpark`; settings_install keeps its doc; the
no-op oci_migration Cargo features are gone.
Signed bundles (storytold/craft-common-actions#129): a signed bundle's origin has the release
key but no signature lookaside, which `flatpak build-bundle` can't record, so its first update
from the ArtCraft OCI repository fails with "no valid signatures". The updater then sets
--signature-lookaside=https://getartcraft.com/signatures on that origin once and retries;
any other origin or error is left alone.
An all-users downgrade removed, under one elevated prompt, every product
code whose uninstall record carried the app's DisplayName and Publisher,
the current account's own HKCU records included. Those are writable
without elevation, so a program running as the user could plant one that
names the app and an unrelated all-users product's code, and have that
product removed under the app's permission prompt.
The elevated session now takes its removal targets only from the
machine's uninstall records (HKLM), which only administrators can write.
An all-users downgrade replaces an all-users copy, which a per-user
record never names.
Add the artcraft-oci remote with --if-not-exists so concurrent migrations
do not fail, let the --reinstall step finish once started because killing
flatpak-spawn does not stop the host transaction, and when the reinstall
fails after removing the ref, point to the .flatpak bundle on the releases
page.
* Add MaterialCraft to the launcher suite
* Stage MaterialCraft integration for its first published release
* Fix MaterialCraft website link and installer icon layout
Latch pin_dragging only on drag start so table cell reuse cannot reverse a reorder, and pick the next visible pinned neighbor when moving from the menu.