37 Commits
Author SHA1 Message Date
Mex 0f295d4b9c fix(install): preserve unrelated update backups
Reserve an exclusively owned backup directory while replacing an installed app. Preserve neighboring .old files and directories, and retain the old copy with its recovery path if rollback fails.

Includes regression coverage for backup collisions and failed rollback.

PR #332.
2026-10-13 23:40:50 -06:00
Skyfuller 1579aa8c02 fix(ui): sharpen Windows launcher rendering
Present Windows DX12 through DirectComposition while retaining the presentation-system override and existing graphics selection.

Use whole-pixel glyph placement for the bundled fonts and inset the card hover rule within its hairlines.

Refs #339.
2026-10-13 17:18:31 -06:00
Brandon ThomasandClaude Opus 5.5 cbae640688 Add FigCraft; release 0.4.1 (#313)
* Add FigCraft to the launcher

FigCraft (storytold/figcraft): interface design and prototyping, the
thirteenth Craft App and the sixth in the New group.

- Catalog row (brand colour #02da96 from FigCraft's icon README), details
  page (no pictures yet), Linux desktop categories, icon tables, tagline
  in every catalog, xtask local mirror list.
- Icon copied unmodified from FigCraft's repo; ATTRIBUTION.md row.
- The Windows installer art fits five rows of icons.
- Test that a companion figcraft-server tarball is never picked as the app.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Release 0.4.1

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 12:28:29 -04:00
Brandon ThomasandClaude Opus 5.5 d6fdab6596 Release 0.4.0 (#291)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-13 06:29:23 -04:00
Brandon ThomasandClaude Opus 5.5 1aaf7b27be AGENTS.md: agents never land or approve third-party apps (#213)
A catalog row, download source or install path for an app outside storytold/, or a rule change
that would allow one, is for a maintainer to decide. Agents flag it and stop.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 05:41:18 -04:00
Brandon ThomasandClaude Opus 5.5 2b86d4ca52 Point the details pictures at storytold/craft-launcher-assets (#221)
The pictures repo was renamed from artcraft-launcher-assets to match craft-launcher. GitHub
redirects the old name for now, but the launcher should not depend on that.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 05:02:23 -04:00
AlexisandBrandon Thomas cb82e851cd Add a details drawer for each Craft App (#60)
* Add a hand-written details page for each Craft App

core::details holds, for every app in the suite, a headline, a few
paragraphs, a list of features, where it runs and the list of its
pictures. The copy is the launcher's own: written from each app's page on
getartcraft.com and, for the five newer apps, from its README. It is
compiled in, so it works offline and in the web build, and it leaves out
figures that go stale (effect counts, timings).

The pictures are not in this repository. A page names them (a key and a
caption each) and AppDetails::screenshot_url turns a key into the address
of a 1200 px JPEG in storytold/artcraft-launcher-assets, pinned to a tag
(SCREENSHOTS_REV) so a saved copy never goes stale. It only answers for
keys the page lists, so no address is ever built from anyone else's text.
MAX_SCREENSHOT_BYTES caps what is accepted, and looks_like_jpeg refuses a
login page or error page that arrives as a 200.

PdfCraft lists none yet: its screenshots still show its old name
(PrintCraft). ATTRIBUTION.md points to the pictures repository, which
carries each picture's own provenance.

Tests check that every app has exactly one page, that each is filled in,
and that addresses come only from a page's own pictures.

* Open an app's details in a drawer from its card

Clicking a card (or an app's name in the table) slides a drawer in from
the right over the dimmed grid. It shows the app's headline, version and
main button, links to its website, GitHub and releases, a gallery of
screenshots, its features, the facts and notes of its latest release, and
the same trademark notice as the grid. Esc, the close button or a click
on the grid closes it. The page's text is the hand-written copy in
core::details; the version, date, download size and notes come from the
release the launcher already fetches.

The pictures are fetched when a page is opened, not bundled. The UI sends
Command::FetchScreenshots and gets one Event::Screenshot per picture, the
lead one first. The desktop backend downloads them through the launcher's
Http (its User-Agent, a 2 MB cap, redirects followed) and keeps them under
<cache>/screenshots/<revision>/<app>/<key>.jpg, so later opens, and any
time offline, read from disk. Only a JPEG is kept: a login page or error
page that arrives as a 200 is refused. A missing picture doesn't hold the
others back, but once the network has failed the rest fail at once instead
of each waiting for its own timeout. The web build does the same with
fetch and leaves the caching to the browser.

Until a picture is here, a frame of the same size says "Loading pictures…"
or, when it failed, that it couldn't be loaded and that reopening the page
tries again; nothing moves when the picture arrives. The lead picture
opens full-window, the arrows over the pictures and the arrow keys flip
through them, wrapping around, and a scroll bar stays visible (egui's
floating one only shows under the pointer).

The drawer's labels are tl! strings, with 13 rows in each of ru, zh-CN,
de, fr and pt-BR; details.rs joins the sources the catalog coverage test
reads. Every size taken from the window is kept non-negative: the new
tests resize it from 40 x 30 to 4000 x 2400 with the drawer open.

actions() becomes actions_in(), which can leave the links out of the
... menu where the page already shows them as buttons. The image crate's
jpeg feature is enabled to decode the pictures (zune-jpeg and zune-core
join Cargo.lock). The dim and the enlarged picture's backdrop are colours
in theme.rs.

* Bring the details drawer up to date with main

- Website is always a button now (#198); drop the "Like {name}" line and the trademark notice,
  which main removed with the product comparisons (93caf01).
- Bring back widgets::eyebrow for the drawer's section headings (#187 dropped its last caller).
- The drawer's ⋯ menu leaves out only the links, so Hide/Unhide (#185) stays.
- No slide when the OS asks for reduced motion (#201).
- Translate the screen readers' "{name} details" label, and add the drawer's strings to the
  Spanish, Czech, Polish, Hungarian and Japanese catalogs.
- AGENTS.md: name the pictures' download origin.

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 05:00:22 -04:00
0e87e90aef macOS DMG: branded window and hdiutil create, so the app passes codesign --strict (#169)
* macOS: build the DMG with hdiutil create so the app passes codesign --strict

hdiutil makehybrid -hfs put a com.apple.FinderInfo on every file in the
image, so the signed app inside the DMG (and any ditto copy of it) failed
codesign --verify --deep --strict. That is why verify.sh had dropped
--strict. Build the image with hdiutil create -srcfolder (retried, since
create can hit "Resource busy" on CI), clear build-time xattrs before
signing, and make verify.sh check the app in the finished DMG and a ditto
copy of it with --strict again.

Ported from storytold/pdfcraft#823 and storytold/lightcraft#959.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* macOS DMG: branded window background and icon layout

The launcher's DMG opened as a plain Finder window, unlike every other
Craft app. Add packaging/macos/dmg/ the same way WordCraft and PhotoCraft
did: a background (the ArtCraft mark whole on its blue, title, Ink and
Paper), a .DS_Store with the window size and icon positions, and the
generate.py that writes both reproducibly. The volume is now named
"ArtCraft Launcher" without the version, which the background alias needs;
the DMG file name keeps the version.

Fixes #20

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 04:26:54 -04:00
78ef4a8c77 Linux: draw on the desktop's GPU, not always the discrete one (#164)
* Linux: draw on the desktop's GPU, not always the discrete one

egui asks wgpu for the high-performance adapter. On a hybrid laptop in
PRIME on-demand mode (Intel + NVIDIA, X11, GNOME) that is the NVIDIA GPU
while the display server runs on the Intel one, so every frame is copied
between the GPUs. During a resize the window then shows stale or garbled
frames and can stop drawing until it is resized again.

On Linux and the BSDs, ask for no power preference instead: wgpu then
takes the first adapter Vulkan lists, and Mesa's device-select layer lists
the display server's GPU first (or the one DRI_PRIME picks). Measured on
an RTX 4060 + Intel RPL-S laptop: in on-demand mode the list starts with
the Intel GPU, in `prime-select nvidia` mode with the NVIDIA one, so the
launcher draws on whichever GPU drives the screens. WGPU_POWER_PREF still
overrides it. Windows and macOS keep their current choice.

Refs #69

* Linux GPU: keep wgpu's adapter order, but a software rasterizer last

Without Mesa's device-select layer the Vulkan order follows the ICD files, and lavapipe can come
before the real driver.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 02:50:10 -04:00
1c03e2d2ea Accessibility/UI: follow the OS's animation setting (#201)
* Add a Polish interface

- pl.tsv: the full catalog (287 rows), written clean-room from the English
  labels; plurals use the same one|few|many rule as Russian.
- Registered as the seventh language: "Polski" in the picker, locales pl,
  pl-PL and pl_PL.UTF-8, complete_menus = true so the completeness tests
  cover it.
- tests/polish.rs: the Polish UI test, modeled on the French one.
- README and AGENTS updated for the new language.

* Add Hungarian localization and fix responsive settings layout

* i18n: add Czech (cs) interface language

Czech catalog with every key of the most complete catalog (287 rows), the
cs language entry with locale detection (cs-CZ, cs_CZ.UTF-8, ?lang=cs),
a Czech plural rule (1 / 2-4 / other) with a unit test, and the README
language list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* i18n: add Japanese (ja) interface language

ja.tsv translates the whole interface, the tray menu and tooltip, each app's
tagline and the backend's install/update messages into Japanese, and
Settings > Language offers 日本語. Japanese locales (ja, ja-JP, ja_JP.UTF-8…)
select it; Japanese has one plural form.

Japanese is a CJK language, so cjk_font.rs now looks for a Japanese system
font (Hiragino Sans on macOS; Yu Gothic, Meiryo or MS Gothic on Windows;
Noto Sans CJK JP, Noto Sans JP, IPAGothic, Takao or VL Gothic on Linux and
FreeBSD), falling back to a Chinese one, which draws kana and most kanji,
where the system has none. The font is loaded per language, so switching
between Japanese and Chinese reloads the right face.

A fallback face is nudged onto the body font's baseline: egui puts a fallback
glyph's baseline at that face's own ascent and only centres the two line
boxes, which left a face whose metrics differ from Inter's riding high. On
Windows, Yu Gothic's line gap is half an em, so its kana and kanji sat about
0.29 em above the Latin text beside them (Microsoft YaHei, used for Chinese,
happens to match). The nudge, applied through a FontTweak, is the difference
in ascent and line height, and helps the Chinese faces too.

The README and AGENTS list the new language. Tests: the complete-catalog
checks cover ja.tsv (placeholders, plural forms, every tl! literal, taglines,
package formats), plus a locale/plural test, a Japanese glyph-coverage test,
a baseline test and tests/japanese.rs (grid, table, dates, a backend message).

* i18n: make the CJK font choice language-neutral

The CJK comments, the missing-font note and the plural-rule docs named the
languages, so each new one meant editing them all; they now say "CJK". Each
language's writing system (its `script`) now lives in the registry, and a
language is drawn only with a face for its own script — never another's,
which would leave characters as tofu — so a language the system has no font
for stays in English, like any other. Adding Korean or Traditional Chinese is
a registry row and a candidate list, not a special case.

* Hide Craft apps the user is not interested in.

* Read the Linux desktop appearance portal so system theme matching works on Wayland.

* UI: follow the OS's animation setting

With Windows' "Animation effects" switched off, the launcher still glided each
notch of the mouse wheel over several frames and faded its dropdowns in. egui
does both by default, and nothing asked the OS.

The shell now says what the OS wants (Host::reduce_motion) and the UI asks it
about once a second while it draws, so a change shows without a restart. With
fewer animations asked for:

- a mouse wheel scrolls at once. egui has no switch for its wheel smoothing but
  applies a trackpad's small steps as they come, so each wheel event is
  rewritten as a run of those in the shell's raw_input_hook;
- dropdowns, tooltips and dialogs appear without a fade, and scrolling to a
  place jumps there (style.animation_time, style.scroll_animation);
- the rule along the top of a hovered card appears instead of wiping in.

Progress bars and spinners keep moving: they say that work is going on.

Where the setting comes from:

- Windows: SPI_GETCLIENTAREAANIMATION, read as bit 33 of UserPreferencesMask in
  the registry, as SystemParametersInfoW needs unsafe;
- macOS: NSWorkspace's accessibilityDisplayShouldReduceMotion;
- Linux and the BSDs: the XDG settings portal over D-Bus (its reduced-motion,
  GNOME's enable-animations, KDE's AnimationDurationFactor), followed on a
  thread so the UI never waits for D-Bus;
- web: prefers-reduced-motion.

Animations stay on wherever the setting can't be read. winreg and zbus become
direct dependencies of the desktop app; both were already in Cargo.lock.

The Windows path was run on a machine with the setting off. The macOS and Linux
paths were type-checked for those targets but never run, and the web one only
compiled.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* UI tests: hovering a card follows the OS's animation setting

With animations off, moving the pointer onto a card leaves the window still
within 3 frames: its rule and its icon's rise show at once. With them on, the
0.3 s wipe takes 19 frames at 60 frames a second. Each test fails when the
card's timing is made to ignore the setting.

The test window no longer has a download in progress, so it is still unless
something animates.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* i18n(pl): use Polish plural rule, so 21 is "21 aplikacji"

plural_east_slavic treats 21, 31 and 101 as "one", which is right for
Russian but not Polish, where only 1 itself takes the singular.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* system theme: back off to every 5 minutes when there's no appearance portal

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Merge #189 and main into #201: one raw_input_hook, and test anchors on taglines

Main no longer shows the "Like …" product lines, so the tests find cards by their taglines.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* i18n(cs): translate "Installed · latest {latest}" (added by #195)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* i18n(pl): translate "Installed · latest {latest}" (added by #195)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* i18n(hu): translate "Installed · latest {latest}" (added by #195)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* i18n(ja): translate "Installed · latest {latest}" (added by #195)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* i18n: hide/unhide strings for the Spanish, Czech, Polish, Hungarian and Japanese catalogs

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* hungarian test: count only the settings body's links, not the cards' Website links behind the modal

#198 gave every card a Website link, and Hungarian labels both Webhely.

* hungarian test: card links are 14px text, settings icons 24px; filter on that

---------

Co-authored-by: DzikStar <[email protected]>
Co-authored-by: ferivoq <[email protected]>
Co-authored-by: Losminek <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: leonaci <[email protected]>
Co-authored-by: satchmo91 <[email protected]>
Co-authored-by: Vikram Bhaskaran <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 02:49:59 -04:00
cf573b2d35 Fix system theme matching on Linux Wayland (#189)
* Read the Linux desktop appearance portal so system theme matching works on Wayland.

* system theme: back off to every 5 minutes when there's no appearance portal

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 02:49:47 -04:00
250cc54335 Hide Craft apps the user is not interested in. (#185)
* Add a Polish interface

- pl.tsv: the full catalog (287 rows), written clean-room from the English
  labels; plurals use the same one|few|many rule as Russian.
- Registered as the seventh language: "Polski" in the picker, locales pl,
  pl-PL and pl_PL.UTF-8, complete_menus = true so the completeness tests
  cover it.
- tests/polish.rs: the Polish UI test, modeled on the French one.
- README and AGENTS updated for the new language.

* Add Hungarian localization and fix responsive settings layout

* i18n: add Czech (cs) interface language

Czech catalog with every key of the most complete catalog (287 rows), the
cs language entry with locale detection (cs-CZ, cs_CZ.UTF-8, ?lang=cs),
a Czech plural rule (1 / 2-4 / other) with a unit test, and the README
language list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* i18n: add Japanese (ja) interface language

ja.tsv translates the whole interface, the tray menu and tooltip, each app's
tagline and the backend's install/update messages into Japanese, and
Settings > Language offers 日本語. Japanese locales (ja, ja-JP, ja_JP.UTF-8…)
select it; Japanese has one plural form.

Japanese is a CJK language, so cjk_font.rs now looks for a Japanese system
font (Hiragino Sans on macOS; Yu Gothic, Meiryo or MS Gothic on Windows;
Noto Sans CJK JP, Noto Sans JP, IPAGothic, Takao or VL Gothic on Linux and
FreeBSD), falling back to a Chinese one, which draws kana and most kanji,
where the system has none. The font is loaded per language, so switching
between Japanese and Chinese reloads the right face.

A fallback face is nudged onto the body font's baseline: egui puts a fallback
glyph's baseline at that face's own ascent and only centres the two line
boxes, which left a face whose metrics differ from Inter's riding high. On
Windows, Yu Gothic's line gap is half an em, so its kana and kanji sat about
0.29 em above the Latin text beside them (Microsoft YaHei, used for Chinese,
happens to match). The nudge, applied through a FontTweak, is the difference
in ascent and line height, and helps the Chinese faces too.

The README and AGENTS list the new language. Tests: the complete-catalog
checks cover ja.tsv (placeholders, plural forms, every tl! literal, taglines,
package formats), plus a locale/plural test, a Japanese glyph-coverage test,
a baseline test and tests/japanese.rs (grid, table, dates, a backend message).

* i18n: make the CJK font choice language-neutral

The CJK comments, the missing-font note and the plural-rule docs named the
languages, so each new one meant editing them all; they now say "CJK". Each
language's writing system (its `script`) now lives in the registry, and a
language is drawn only with a face for its own script — never another's,
which would leave characters as tofu — so a language the system has no font
for stays in English, like any other. Adding Korean or Traditional Chinese is
a registry row and a candidate list, not a special case.

* Hide Craft apps the user is not interested in.

* i18n(pl): use Polish plural rule, so 21 is "21 aplikacji"

plural_east_slavic treats 21, 31 and 101 as "one", which is right for
Russian but not Polish, where only 1 itself takes the singular.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* i18n(cs): translate "Installed · latest {latest}" (added by #195)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* i18n(pl): translate "Installed · latest {latest}" (added by #195)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* i18n(hu): translate "Installed · latest {latest}" (added by #195)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* i18n(ja): translate "Installed · latest {latest}" (added by #195)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* i18n: hide/unhide strings for the Spanish, Czech, Polish, Hungarian and Japanese catalogs

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* hungarian test: count only the settings body's links, not the cards' Website links behind the modal

#198 gave every card a Website link, and Hungarian labels both Webhely.

* hungarian test: card links are 14px text, settings icons 24px; filter on that

---------

Co-authored-by: DzikStar <[email protected]>
Co-authored-by: ferivoq <[email protected]>
Co-authored-by: Losminek <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: leonaci <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 02:48:47 -04:00
93f12a5d9e i18n: add Japanese (ja) interface language (#177)
* Add a Polish interface

- pl.tsv: the full catalog (287 rows), written clean-room from the English
  labels; plurals use the same one|few|many rule as Russian.
- Registered as the seventh language: "Polski" in the picker, locales pl,
  pl-PL and pl_PL.UTF-8, complete_menus = true so the completeness tests
  cover it.
- tests/polish.rs: the Polish UI test, modeled on the French one.
- README and AGENTS updated for the new language.

* Add Hungarian localization and fix responsive settings layout

* i18n: add Czech (cs) interface language

Czech catalog with every key of the most complete catalog (287 rows), the
cs language entry with locale detection (cs-CZ, cs_CZ.UTF-8, ?lang=cs),
a Czech plural rule (1 / 2-4 / other) with a unit test, and the README
language list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* i18n: add Japanese (ja) interface language

ja.tsv translates the whole interface, the tray menu and tooltip, each app's
tagline and the backend's install/update messages into Japanese, and
Settings > Language offers 日本語. Japanese locales (ja, ja-JP, ja_JP.UTF-8…)
select it; Japanese has one plural form.

Japanese is a CJK language, so cjk_font.rs now looks for a Japanese system
font (Hiragino Sans on macOS; Yu Gothic, Meiryo or MS Gothic on Windows;
Noto Sans CJK JP, Noto Sans JP, IPAGothic, Takao or VL Gothic on Linux and
FreeBSD), falling back to a Chinese one, which draws kana and most kanji,
where the system has none. The font is loaded per language, so switching
between Japanese and Chinese reloads the right face.

A fallback face is nudged onto the body font's baseline: egui puts a fallback
glyph's baseline at that face's own ascent and only centres the two line
boxes, which left a face whose metrics differ from Inter's riding high. On
Windows, Yu Gothic's line gap is half an em, so its kana and kanji sat about
0.29 em above the Latin text beside them (Microsoft YaHei, used for Chinese,
happens to match). The nudge, applied through a FontTweak, is the difference
in ascent and line height, and helps the Chinese faces too.

The README and AGENTS list the new language. Tests: the complete-catalog
checks cover ja.tsv (placeholders, plural forms, every tl! literal, taglines,
package formats), plus a locale/plural test, a Japanese glyph-coverage test,
a baseline test and tests/japanese.rs (grid, table, dates, a backend message).

* i18n: make the CJK font choice language-neutral

The CJK comments, the missing-font note and the plural-rule docs named the
languages, so each new one meant editing them all; they now say "CJK". Each
language's writing system (its `script`) now lives in the registry, and a
language is drawn only with a face for its own script — never another's,
which would leave characters as tofu — so a language the system has no font
for stays in English, like any other. Adding Korean or Traditional Chinese is
a registry row and a candidate list, not a special case.

* i18n(pl): use Polish plural rule, so 21 is "21 aplikacji"

plural_east_slavic treats 21, 31 and 101 as "one", which is right for
Russian but not Polish, where only 1 itself takes the singular.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* i18n(cs): translate "Installed · latest {latest}" (added by #195)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* i18n(pl): translate "Installed · latest {latest}" (added by #195)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* i18n(hu): translate "Installed · latest {latest}" (added by #195)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* i18n(ja): translate "Installed · latest {latest}" (added by #195)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* hungarian test: count only the settings body's links, not the cards' Website links behind the modal

#198 gave every card a Website link, and Hungarian labels both Webhely.

* hungarian test: card links are 14px text, settings icons 24px; filter on that

---------

Co-authored-by: DzikStar <[email protected]>
Co-authored-by: ferivoq <[email protected]>
Co-authored-by: Losminek <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 02:48:34 -04:00
57105f8445 Add Hungarian localization and fix responsive settings layout (#146)
* Add a Polish interface

- pl.tsv: the full catalog (287 rows), written clean-room from the English
  labels; plurals use the same one|few|many rule as Russian.
- Registered as the seventh language: "Polski" in the picker, locales pl,
  pl-PL and pl_PL.UTF-8, complete_menus = true so the completeness tests
  cover it.
- tests/polish.rs: the Polish UI test, modeled on the French one.
- README and AGENTS updated for the new language.

* Add Hungarian localization and fix responsive settings layout

* i18n: add Czech (cs) interface language

Czech catalog with every key of the most complete catalog (287 rows), the
cs language entry with locale detection (cs-CZ, cs_CZ.UTF-8, ?lang=cs),
a Czech plural rule (1 / 2-4 / other) with a unit test, and the README
language list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* i18n(pl): use Polish plural rule, so 21 is "21 aplikacji"

plural_east_slavic treats 21, 31 and 101 as "one", which is right for
Russian but not Polish, where only 1 itself takes the singular.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* i18n(cs): translate "Installed · latest {latest}" (added by #195)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* i18n(pl): translate "Installed · latest {latest}" (added by #195)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* i18n(hu): translate "Installed · latest {latest}" (added by #195)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* hungarian test: count only the settings body's links, not the cards' Website links behind the modal

#198 gave every card a Website link, and Hungarian labels both Webhely.

* hungarian test: card links are 14px text, settings icons 24px; filter on that

---------

Co-authored-by: DzikStar <[email protected]>
Co-authored-by: Losminek <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 02:48:21 -04:00
94d6480a11 Add Polish (Polski) as an interface language. (#138)
* Add a Polish interface

- pl.tsv: the full catalog (287 rows), written clean-room from the English
  labels; plurals use the same one|few|many rule as Russian.
- Registered as the seventh language: "Polski" in the picker, locales pl,
  pl-PL and pl_PL.UTF-8, complete_menus = true so the completeness tests
  cover it.
- tests/polish.rs: the Polish UI test, modeled on the French one.
- README and AGENTS updated for the new language.

* i18n: add Czech (cs) interface language

Czech catalog with every key of the most complete catalog (287 rows), the
cs language entry with locale detection (cs-CZ, cs_CZ.UTF-8, ?lang=cs),
a Czech plural rule (1 / 2-4 / other) with a unit test, and the README
language list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* i18n(pl): use Polish plural rule, so 21 is "21 aplikacji"

plural_east_slavic treats 21, 31 and 101 as "one", which is right for
Russian but not Polish, where only 1 itself takes the singular.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* i18n(cs): translate "Installed · latest {latest}" (added by #195)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* i18n(pl): translate "Installed · latest {latest}" (added by #195)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Losminek <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 02:48:08 -04:00
6d59370024 i18n: add Czech (cs) interface language (#167)
* i18n: add Czech (cs) interface language

Czech catalog with every key of the most complete catalog (287 rows), the
cs language entry with locale detection (cs-CZ, cs_CZ.UTF-8, ?lang=cs),
a Czech plural rule (1 / 2-4 / other) with a unit test, and the README
language list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* i18n(cs): translate "Installed · latest {latest}" (added by #195)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Losminek <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 02:45:12 -04:00
Brandon ThomasandClaude Opus 5.5 a464e432b3 i18n(es): translate "Installed · latest {latest}" (#211)
#195 added the string and #196 marked Spanish complete; they landed together, so the
completeness test failed on main.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 02:02:14 -04:00
d4c61ec110 Improve indeterminate installer progress bar animation (#163)
* Improve indeterminate progress bar animation

Refactor indeterminate progress bar rendering for smoother animation.

* progress: rustfmt, and skip the wrapped segment while it's off the track

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 01:57:06 -04:00
155d6bd6b0 Detect PdfCraft installs made by its PrintCraft-named MSI (#134)
* Match MSI entries and find app executables through pure functions

Windows detection decided which uninstall entry belongs to an app, and
which paths might hold its exe, inline in the registry-reading code
under #[cfg(windows)]. Neither decision needs the registry or the disk,
so move both into module-level functions, is_installer_entry and
exe_candidates, that tests can call on every platform. The windows
module keeps the registry reads and the exists() checks.

No behaviour change: an entry still has to carry the app's display name
and the Learning Machines LLC publisher, exactly, and the candidates are
the same paths in the same order. The new tests pin that down for every
app but PdfCraft, and for PdfCraft's own entry name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Detect PdfCraft installs made by its PrintCraft-named MSI

PdfCraft's Windows installer shipped as PrintCraft up to 0.2.1
(printcraft-0.2.1-windows-x64.msi). That MSI registers its uninstall
entry with DisplayName "PrintCraft" and installs printcraft.exe into
Program Files\PrintCraft, but Windows detection only accepted an entry
named after the app's current name and looked only for pdfcraft.exe, in
App Paths, the install folder and Program Files\PdfCraft. On a machine
still on that install the launcher showed PdfCraft as not installed,
with no version, and uninstall_msi found nothing to remove.

The catalog already knows the old asset stem (former_ids); add
former_names for the display names an app's installers registered,
since the capitalisation can't be derived from the stem, and names()
to go with stems(). Entry matching now accepts any of the app's names,
still exactly and still only from Learning Machines LLC, and the exe is
looked up under every stem in App Paths and under every name in Program
Files, current first. Apps without former names see the same match and
the same candidates as before.

The impact is limited to early adopters: PdfCraft 0.4.0 and later
register as PdfCraft, and their MSI keeps the same UpgradeCode, so
installing from the launcher already upgraded a PrintCraft install in
place. What this fixes is the wrong status and the missing Uninstall
until then.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-12 01:56:56 -04:00
a702ef16c8 Skip deb/rpm installs on rpm-ostree immutable systems (#107)
Fedora Atomic variants (Bazzite, Silverblue, etc.) block local dnf install;
prefer Flatpak and AppImage so one-click installs succeed without pkexec dnf.

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 01:56:45 -04:00
0c3e639b4d Windows: compile DX12 shaders with FXC (#173)
wgpu's default DX12 compiler (Auto) loads whichever dxcompiler.dll the DLL
search path finds. The launcher ships none, and without a matching dxil.dll
the unsigned-DXIL warning fails wgpu's indirect-validation pipeline, the
device is lost and the launcher exits before the first frame. Pin the
compiler to FXC, which is part of Windows; WGPU_DX12_COMPILER still
overrides it. GL stays in the Windows backends: it is the deliberate
fallback for machines whose D3D12 adapters can't present to the window.

Fixes #126

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 01:56:24 -04:00
Nezuko-Chan 41ded15851 Fallback apps to install from release tarballs when no usable Flatpak spawn is available (#87) 2026-10-12 01:54:08 -04:00
Gerknis 98c31aa066 Add experimental flake.nix for NixOS/Linux development (#72)
* Add experimental flake.nix for NixOS/Linux development

This adds an experimental flake-based development environment and package entry for ArtCraft Launcher.

What this adds:
- `flake.nix` at the repository root
- Rust toolchain via `rust-overlay`
- Required native Linux libraries for the Rust workspace
- A dev shell via `nix develop`
- A package/app target via `nix run` / `nix build`

Why:
ArtCraft Launcher is a Rust workspace and currently has no Nix flake support. This makes local development and installation much easier for NixOS/Linux users.

Notes:
- This is intentionally minimal and experimental.
- It targets the development environment and local package use.
- Additional refinements may be needed for specific Wayland/X11 setups.

* Fix flake metadata to match ArtCraft Launcher and remove GTK dependency

* add flake.lock

* Update flake.nix: replace deprecated xorg packages with their new names
2026-10-12 01:54:03 -04:00
Diego OspinaandDiego Ospina c064fde960 i18n: add Spanish (es) interface language (#196)
es.tsv translates the whole interface, the tray menu and tooltip, each
app's tagline and the backend's install/update messages, in neutral
international Spanish. Settings > Language offers Español; es, es-ES,
es-MX, es-419 and the like select it under Auto.

AI-assisted, native review welcome.

Models: Opus 5.5, Terra 5.6, DeepSeek V4 Flash

Co-authored-by: Diego Ospina <[email protected]>
2026-10-12 01:54:00 -04:00
96ed437966 Desktop: write log records to standard error and a rotating log file (#178)
A launcher started from the Dock, the Start menu or at login has no
terminal, and the Windows build has no console, so its log records and
panic reports were lost.

env_logger now writes each record to standard error and to
logs/artcraft-launcher.log in the launcher's data folder (portable mode
included). Each start keeps the previous two runs' logs; the file is
capped at 16 MiB. The file is opened once this is the only launcher
running, so a second start doesn't rotate the first one's log away.
Panics are reported through the logger. The default filter is now info
for the launcher's crates and warn for the rest; RUST_LOG still wins.

Ported from storytold/pdfcraft#239.

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 01:53:54 -04:00
Osman Said e49bdc94dc Fix header label overlap in narrow windows (#130) 2026-10-12 01:53:50 -04:00
0358cda174 Linux: name the tray icon instead of showing the process id (#140)
tray-icon's default id is "<pid>-<n>" and the title is empty, so
StatusNotifierItem hosts showed numbers like "61965-1". On Linux and the
BSDs, give the icon the id "artcraft-launcher" and the title
"ArtCraft Launcher". Windows and macOS are unchanged.

Fixes #136

Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 01:53:46 -04:00
MarekSpodBiedry 7938ed90ac macOS: set the notification bundle id so no 'Where is use_default?' picker appears (#95) (#116) 2026-10-12 01:53:42 -04:00
Val EnfysandClaude Opus 5.5 ba8a704908 macOS: keep the bundle's icon in the Dock while the launcher runs (#127)
eframe sets the app icon at runtime, and on macOS that replaces the
Dock icon with a raw bitmap (the 256 px PNG we passed, or egui's logo
when none is given), which skips the system's icon treatment. Pass the
empty icon on macOS so eframe leaves the bundle's .icns alone.

Fixes #32.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 01:53:38 -04:00
Vikram Bhaskaran 1f92b7db17 Show real versions for PdfCraft and SoundCraft in the launcher. (#195)
Those binaries print a former id (`printcraft 0.2.1`) or the display
name (`SoundCraft 0.5.0`). Detection required an exact machine-id
prefix, so the version was missing and the card said "installed
installed · latest …". Parse --version against stems and the product
name, and stop using the word "installed" as a version placeholder.
2026-10-12 01:53:34 -04:00
XianZheYangandYangXZ2008 80f70a1a33 Keep feed repo renames inside storytold, and never install without a checksum (#208)
Two download-trust gaps from #90.

A repo rename in the release feed only had to look like owner/name, so
a compromised feed could send an app's downloads, or the launcher's own
self-update, to another account's repository with matching links and
checksums, and every check would vouch for them. A rename now has to
stay under `catalog::GITHUB_OWNER` (storytold) at both ends, or it is
dropped and the feed's entry stops matching, so GitHub decides as before.

A release with neither a per-file digest nor a SHA256SUMS.txt line was
still installed, checked by size only, with a note after the fact; a
same-length altered file would have passed. `install::download` now
refuses it before fetching anything, with an error that says why. With
a checksum always in hand, the size-only path goes: `http::download`
takes the SHA-256 as `&str`, and `Verified` and `keep_download` are
removed.

Tests: a rename within storytold is followed, one out of it is dropped
in either direction, and a release without a checksum fetches nothing
and never reaches the installer.

Fixes #90

Co-authored-by: YangXZ2008 <[email protected]>
2026-10-12 01:53:30 -04:00
MarekSpodBiedry 31a5e426e7 linux: only run pacman when the pacman database exists, so an unrelated pacman on PATH is not launched (#158) (#180) 2026-10-12 01:53:25 -04:00
XianZheYangandYangXZ2008 165f5358e0 Dialogs: keep the uninstall prompt at its own size, not the last dialog's (#207)
Every dialog shared one egui area id, "dialog". egui lays an area out in
the size it had the frame before, and the uninstall prompt's button row
was a right-to-left layout placed straight in the column, which fills
whatever height is left. So after Settings or the release notes had been
open, the prompt came up as tall as they were, with its buttons floating
in the middle, and never shrank back.

- Each dialog gets its own area id (`Dialog::key`), so a small prompt
  starts from its own size, and so does each feed alert.
- The uninstall prompt's and the feed alert's button rows sit in
  `ui.horizontal` like the other dialogs' rows, so they take only a
  row's height.
- A UI test opens Settings, closes it, opens the uninstall prompt and
  checks the buttons follow the text (they sat 243 px below it before).

Fixes #88

Co-authored-by: YangXZ2008 <[email protected]>
2026-10-12 01:53:22 -04:00
MarekSpodBiedryandskylar taylor-barrick 93caf01c4e fix(ui): remove third-party product comparisons
PR #84.

Closes #83.

Includes formatting-only corrections in the shared UI.

Co-authored-by: skylar taylor-barrick <[email protected]>
2026-10-11 23:38:46 -06:00
Dylan M. Taylor 7ba85e54f0 fix(ui): remove Classic and New app sections
PR #187.
2026-10-11 23:27:54 -06:00
Dylan M. Taylor 6124cf0693 fix(ui): enable website links for all Craft apps
PR #198.
2026-10-11 23:22:17 -06:00
Dylan M. Taylor 257e16c325 ci(actions): upgrade workflow actions to Node 24
Closes #159.
2026-10-11 22:44:40 -06:00
80 changed files with 7419 additions and 638 deletions

No files matched your search

+4 -4
View File
@@ -27,7 +27,7 @@ jobs:
# are kept for release builds, and release.yml builds and checks macOS.
os: [ubuntu-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# egui/winit/wgpu headers. The tray (ksni) and notifications (notify-rust over zbus) are
# pure-Rust D-Bus clients, so no GTK, appindicator, xdo or libdbus packages are needed.
@@ -42,7 +42,7 @@ jobs:
with:
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- name: Format
if: runner.os == 'Linux'
@@ -60,12 +60,12 @@ jobs:
env:
TRUNK_VERSION: 0.21.14
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
components: clippy
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: wasm32
- name: Check wasm-safe crates
+31 -5
View File
@@ -2,7 +2,7 @@
ArtCraft Launcher installs, updates and launches the **Craft App Suite** (PhotoCraft, LightCraft,
FilmCraft, DesignCraft, VectorCraft, PdfCraft, EffectCraft, WordCraft, DeckCraft, GridCraft,
CADCraft, SoundCraft). It is written in **Rust only** (no JavaScript or TypeScript): the desktop
CADCraft, SoundCraft, FigCraft). It is written in **Rust only** (no JavaScript or TypeScript): the desktop
app is native egui/eframe, and the web build is the same Rust compiled to WebAssembly (trunk +
wasm-bindgen). **No Tauri, Electron or webview shells**, ever.
@@ -62,8 +62,16 @@ settings file must produce an error the user can act on, never a panic. Shared s
`cargo xtask local`); keep it loopback-only and behind the `local-mirror` cargo feature, which
release builds never enable.
- Verify each download against the release's `SHA256SUMS.txt` before running or installing it.
- The details drawer's pictures are the one other download: JPEGs only, from a pinned tag of
`https://raw.githubusercontent.com/storytold/craft-launcher-assets/` (`core::details`),
capped at 2 MB each, cached, and never run. Pictures are not bundled in this repo.
- Never run an installer with elevated rights silently; tell the user what is about to happen.
- Never delete a user's files outside what the launcher itself installed.
- **Agents never land or approve a third-party app.** A change that adds a catalog row, download
source or install path for an app whose repo is not under `storytold/`, or that loosens the
rules above to allow one, is a maintainer's call alone. An agent reviewing it flags it at the
top of its review (who owns the repo, its age and history, whether the builds are signed) and
stops there: no merge, no approval, no fixes pushed to the PR.
### Look and feel
@@ -72,14 +80,22 @@ hard square corners, 1 px hairlines instead of shadows, Archivo (620, 118 % widt
and app names, Inter for body text, upper-case Geist Mono labels and buttons, and the site's
colour tokens by name (`theme::Tokens`). Build new UI from `widgets.rs`; never hard-code a colour.
Animations follow the OS: with Windows' "Animation effects" off, macOS' "Reduce motion" on, a Linux
desktop's animations off or a browser's `prefers-reduced-motion`, nothing fades, wipes or glides
(`Host::reduce_motion`, `crates/ui-egui/src/motion.rs`). Time a new animation with egui's
`style.animation_time`, or switch it off on `LauncherUi::reduce_motion`. Progress bars and
spinners keep moving.
### Languages
Every user-facing string goes through `crates/ui-egui/src/i18n` (`tl!("…")` for literals, `fmt`
for `{placeholders}`, `tn` for counts). English in the code is the lookup key; adding a string
means adding its row to every catalog marked `complete_menus` (`ru.tsv`, `zh-CN.tsv`, `de.tsv`),
which the i18n tests check. Product and app names (ArtCraft Launcher, Craft App Suite, PhotoCraft…) stay in English.
Chinese is drawn with the system's own CJK font (`crates/ui-egui/src/cjk_font.rs`, desktop only;
English where there is none): never bundle a CJK font, they are 10 MB and more.
means adding its row to every catalog marked `complete_menus` (the `*.tsv` files in
`crates/ui-egui/src/i18n/`), which the i18n tests check. Translate clean-room from the meaning of
the English text, never through another catalog. Product and app names (ArtCraft Launcher, Craft
App Suite, PhotoCraft…) stay in English.
A CJK language is drawn with the system's own CJK font (`crates/ui-egui/src/cjk_font.rs`, desktop
only; English where there is none): never bundle a CJK font, they are 10 MB and more.
### Other rules
@@ -142,6 +158,16 @@ Packaging facts the code relies on:
normal Dock app that can also sit in the menu bar: `LSUIElement` must never be set.
- Linux: the tray uses StatusNotifierItem over D-Bus and notifications use
`org.freedesktop.Notifications`, both in pure Rust (no GTK, appindicator or libdbus).
- Inside the Flatpak launcher (`process::in_sandbox()`), apps normally install as host Flatpaks
and run through `flatpak-spawn --host`. When the host has no usable Flatpak — none installed,
or `flatpak-spawn` not allowed — `linux::order_available_kinds` offers the release tarball
instead: it is unpacked into `<data>/apps/<app>` (`bin/`, `share/`) and run with `HOME` =
`<data>/apps/<app>/home`, so a Craft App's settings stay in the sandbox. An update swaps
`bin/` and `share/` and keeps `home/`; removing the app takes those two folders only. Outside
a sandbox the tarball is not on offer at all.
- `--socket=pulseaudio` in the launcher manifests is for an app running *inside* the sandbox
(SoundCraft's ALSA reaches the runtime's PulseAudio plugin through it); it also covers the
microphone, which SoundCraft records through.
- `build.rs` reads `ARTCRAFT_LAUNCHER_BUILD_SHA` / `ARTCRAFT_LAUNCHER_BUILD_DATE` and, on Windows,
embeds `assets/app-icon/artcraft-launcher.ico`, failing the build when
`ARTCRAFT_LAUNCHER_REQUIRE_WINRES=1` and it can't.
+3
View File
@@ -11,9 +11,11 @@ next to them.
| Path | Title | Author | Source | License |
|---|---|---|---|---|
| `assets/app-icon/` (all files) | ArtCraft Launcher app icon: the ArtCraft "A" mark in blue (getartcraft.com's favicon) | ArtCraft Team | Rendered unmodified from `docs/brand/artcraft-mark.svg` by `packaging/icons.sh`, see [`assets/app-icon/README.md`](assets/app-icon/README.md) | **Not open source; not MIT or Apache-2.0.** ArtCraft trademark, [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) |
| `packaging/macos/dmg/` (all files) | macOS DMG window background and layout | ArtCraft Launcher contributors, with the ArtCraft mark | Original work, see [`packaging/macos/dmg/README.md`](packaging/macos/dmg/README.md); the mark is `docs/brand/artcraft-mark-black.svg`, linked from `background.svg` and rendered unmodified into `background.tiff` | MIT OR Apache-2.0, like the code; the ArtCraft mark in it stays an ArtCraft trademark ([`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt)). The TIFF carries LittleCMS's built-in sRGB profile ("No copyright, use freely") |
| `assets/social/linkedin.svg` | LinkedIn logo, linking to ArtCraft's company page | Fonticons, Inc. ([Font Awesome Free](https://fontawesome.com) 6.7.2) | Font Awesome Free `svgs/brands/linkedin.svg`; white fill and a square view box, see [`assets/social/README.md`](assets/social/README.md) | CC BY 4.0. The logo is a trademark of LinkedIn Corporation. |
| `assets/social/x.svg`, `youtube.svg`, `tiktok.svg`, `instagram.svg` | X, YouTube, TikTok and Instagram logos, linking to ArtCraft's accounts | [Simple Icons](https://simpleicons.org/) contributors | Simple Icons, `fill` set to white for tinting; see [`assets/social/README.md`](assets/social/README.md) | CC0 1.0. The logos are trademarks of X Corp., Google LLC, TikTok / ByteDance and Meta Platforms, Inc. |
| `docs/screenshots/artcraft-launcher-grid.png`, `artcraft-launcher-table.png` | Screenshots of ArtCraft Launcher (macOS: grid view in dark theme, table view in light theme), used in the README | ArtCraft Launcher contributors | Captured from the app | MIT OR Apache-2.0 for the screenshot itself; the ArtCraft logo in it stays an ArtCraft trademark ([`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt)) and the app icons in it keep their own licenses (above) |
| (not bundled) the pictures on each app's details page | Screenshots of the Craft Apps, fetched when a page is opened and kept in the launcher's cache folder | The apps' contributors | [storytold/craft-launcher-assets](https://github.com/storytold/craft-launcher-assets), pinned to a tag (`SCREENSHOTS_REV` in `crates/core/src/details.rs`); each picture's source and license are listed in that repository's `ATTRIBUTION.md` | Each app's own license for the interface (MIT OR Apache-2.0); the work shown in them is public domain or made in the app, as that file says |
| `docs/brand/artcraft-logo.svg`, `artcraft-logo-white.svg` | ArtCraft wordmark, shown in the window header | ArtCraft Team | Supplied brand files, unmodified (same as PhotoCraft's `docs/brand/`) | **Not open source; not MIT or Apache-2.0.** ArtCraft trademark, [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) |
| `docs/brand/artcraft-mark-64.png`, `artcraft-mark-black-44.png` | ArtCraft "A" mark, the tray / menu-bar icon | ArtCraft Team | Rendered unmodified from `docs/brand/artcraft-mark.svg` and `artcraft-mark-black.svg` by `packaging/icons.sh` | **Not open source; not MIT or Apache-2.0.** ArtCraft trademark, [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) |
| `assets/fonts/Inter-Variable.ttf` | Inter 4.001, variable (body text) | The Inter Project Authors (Rasmus Andersson) | Google Fonts, <https://github.com/google/fonts/tree/main/ofl/inter> (`Inter[opsz,wght].ttf`, renamed) | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
@@ -40,6 +42,7 @@ licensed like those apps. Refresh them from the source path, never redraw them.
| `assets/app-icons/gridcraft.png` | GridCraft app icon (app icon), 128 px | ArtCraft Team and the GridCraft contributors | [`storytold/gridcraft`](https://github.com/storytold/gridcraft) `assets/app-icon/hicolor/128x128/apps/ai.storyteller.gridcraft.png`, copied unmodified | MIT OR Apache-2.0 (GridCraft's [`LICENSE-MIT`](https://github.com/storytold/gridcraft/blob/main/LICENSE-MIT), [`LICENSE-APACHE`](https://github.com/storytold/gridcraft/blob/main/LICENSE-APACHE)) |
| `assets/app-icons/cadcraft.png` | CADCraft app icon (app icon), 128 px | ArtCraft Team and the CADCraft contributors | [`storytold/cadcraft`](https://github.com/storytold/cadcraft) `assets/app-icon/hicolor/128x128/apps/ai.storyteller.cadcraft.png`, copied unmodified | MIT OR Apache-2.0 (CADCraft's [`LICENSE-MIT`](https://github.com/storytold/cadcraft/blob/main/LICENSE-MIT), [`LICENSE-APACHE`](https://github.com/storytold/cadcraft/blob/main/LICENSE-APACHE)) |
| `assets/app-icons/soundcraft.png` | SoundCraft app icon (nightingale (placeholder)), 128 px | ArtCraft Team and the SoundCraft contributors | [`storytold/soundcraft`](https://github.com/storytold/soundcraft) `assets/app-icon/hicolor/128x128/apps/ai.storyteller.soundcraft.png`, copied unmodified | MIT OR Apache-2.0 (SoundCraft's [`LICENSE-MIT`](https://github.com/storytold/soundcraft/blob/main/LICENSE-MIT), [`LICENSE-APACHE`](https://github.com/storytold/soundcraft/blob/main/LICENSE-APACHE)) |
| `assets/app-icons/figcraft.png` | FigCraft app icon (chameleon), 128 px | ArtCraft Team and the FigCraft contributors | [`storytold/figcraft`](https://github.com/storytold/figcraft) `assets/app-icon/hicolor/128x128/apps/ai.storyteller.figcraft.png`, copied unmodified | MIT OR Apache-2.0 (FigCraft's [`LICENSE-MIT`](https://github.com/storytold/figcraft/blob/main/LICENSE-MIT), [`LICENSE-APACHE`](https://github.com/storytold/figcraft/blob/main/LICENSE-APACHE)) |
## Documentation and README
Generated
+10 -6
View File
@@ -276,7 +276,7 @@ checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
name = "artcraft-launcher"
version = "0.3.2"
version = "0.4.1"
dependencies = [
"artcraft-launcher-core",
"artcraft-launcher-native",
@@ -293,12 +293,14 @@ dependencies = [
"ron",
"serde",
"tray-icon",
"winreg",
"winresource",
"zbus",
]
[[package]]
name = "artcraft-launcher-core"
version = "0.3.2"
version = "0.4.1"
dependencies = [
"semver",
"serde",
@@ -307,7 +309,7 @@ dependencies = [
[[package]]
name = "artcraft-launcher-native"
version = "0.3.2"
version = "0.4.1"
dependencies = [
"artcraft-launcher-core",
"dirs 6.0.0",
@@ -325,7 +327,7 @@ dependencies = [
[[package]]
name = "artcraft-launcher-ui-egui"
version = "0.3.2"
version = "0.4.1"
dependencies = [
"artcraft-launcher-core",
"egui",
@@ -341,7 +343,7 @@ dependencies = [
[[package]]
name = "artcraft-launcher-web"
version = "0.3.2"
version = "0.4.1"
dependencies = [
"artcraft-launcher-core",
"artcraft-launcher-ui-egui",
@@ -2283,6 +2285,8 @@ dependencies = [
"num-traits",
"png 0.18.1",
"tiff",
"zune-core",
"zune-jpeg",
]
[[package]]
@@ -5799,7 +5803,7 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]]
name = "xtask"
version = "0.3.2"
version = "0.4.1"
[[package]]
name = "yoke"
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "3"
members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.3.2"
version = "0.4.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
+33 -23
View File
@@ -12,7 +12,7 @@
<p align="center">
<b>One place to install, update and open every app in the Craft App Suite, rebuilt in pure Rust.</b><br>
PhotoCraft, LightCraft, FilmCraft, DesignCraft, VectorCraft, PdfCraft, EffectCraft, WordCraft,<br>
DeckCraft, GridCraft, CADCraft and SoundCraft: the right build for your computer, kept up to date.
DeckCraft, GridCraft, CADCraft, SoundCraft and FigCraft: the right build for your computer, kept up to date.
</p>
<p align="center">
@@ -47,10 +47,10 @@
</p>
<p align="center">
<img src="docs/screenshots/artcraft-launcher-grid.png" alt="ArtCraft Launcher on macOS in the grid view (dark theme): a card for each Craft App with its icon, status, version, release date and an Install or Open button, grouped into classic and new apps" width="900">
<img src="docs/screenshots/artcraft-launcher-grid.png" alt="ArtCraft Launcher on macOS in the grid view (dark theme): a card for each Craft App with its icon, status, version, release date and an Install or Open button" width="900">
</p>
<p align="center">
<img src="docs/screenshots/artcraft-launcher-table.png" alt="The same apps in the table view (light theme), with columns for what each app is inspired by, its status, installed and latest versions, and release date" width="900"><br>
<img src="docs/screenshots/artcraft-launcher-table.png" alt="The same apps in the table view (light theme), with columns for each app's status, installed and latest versions, and release date" width="900"><br>
<sub>Switch between the grid and a sortable table.</sub>
</p>
@@ -84,20 +84,24 @@ The web version shows the same catalog in the browser, with the latest download
## Interface languages
The interface is in English, Brazilian Portuguese (Português (Brasil)), French (Français), German
(Deutsch), Russian (Русский) or Simplified Chinese (简体中文). **Auto**, the default, follows the
The interface is in English, Brazilian Portuguese (Português (Brasil)), Czech (Čeština), French
(Français), German (Deutsch), Hungarian (Magyar), Japanese (日本語), Polish (Polski), Russian
(Русский), Simplified Chinese (简体中文) or Spanish (Español). **Auto**, the default, follows the
system or browser language; **Settings → Language** picks one and switches at once, and the choice
is kept between runs. German locales (`de-DE`, `de-AT`, `de-CH`, `de_DE.UTF-8`…) select German,
French ones (`fr-FR`, `fr-CA`…) French, and Portuguese ones (`pt-BR`, and `pt-PT` too, for now)
Brazilian Portuguese. Simplified Chinese locales (`zh-CN`, `zh-SG`, `zh-Hans`, and POSIX forms such
as `zh_CN.UTF-8`) select Chinese; Traditional Chinese ones don't. To try a language without changing
the setting, start the desktop app with `ARTCRAFT_LAUNCHER_LOCALE=pt-BR`, or add `?lang=ru` to the
web address.
is kept between runs. Czech locales (`cs-CZ`, `cs_CZ.UTF-8`…) select Czech, German ones (`de-DE`,
`de-AT`, `de-CH`, `de_DE.UTF-8`…) German, French ones (`fr-FR`, `fr-CA`…) French, Hungarian ones
(`hu`, `hu-HU`, `hu_HU.UTF-8`…) Hungarian, Japanese ones (`ja`, `ja-JP`, `ja_JP.UTF-8`…) Japanese,
Polish ones (`pl`, `pl-PL`, `pl_PL.UTF-8`) Polish, Spanish ones (`es-ES`, `es-MX`, `es-419`…)
Spanish, and Portuguese ones (`pt-BR`, and `pt-PT` too, for now) Brazilian Portuguese. Simplified
Chinese locales (`zh-CN`, `zh-SG`, `zh-Hans`, and POSIX forms such as `zh_CN.UTF-8`) select
Chinese; Traditional Chinese ones don't. To try a language without changing the setting, start the
desktop app with `ARTCRAFT_LAUNCHER_LOCALE=pt-BR`, or add `?lang=ru` to the web address.
Chinese is drawn with the system's own Chinese font (PingFang on macOS, Microsoft YaHei on
Windows, Noto Sans CJK or WenQuanYi on Linux and FreeBSD). Where there is none, and in the web
version, the interface stays in English and Settings says why. Product names and release notes
stay as published; text without a translation falls back to English.
The CJK languages are drawn with the system's own CJK font (Hiragino Sans or PingFang on macOS,
Yu Gothic, Meiryo or Microsoft YaHei on Windows, Noto Sans CJK, IPA, Takao or WenQuanYi on Linux and
FreeBSD). Where the system has no font for one, and in the web version, the interface stays in
English and Settings says why. Product names and release notes stay as published; text without a
translation falls back to English.
## Where apps are installed
@@ -110,6 +114,7 @@ installed by hand (and the launcher finds apps you installed yourself):
| macOS | `/Applications/<App>.app` | Copied from the signed, notarized release DMG |
| Windows | `C:\Program Files\<App>` | The release MSI, through Windows Installer (with Start menu entry and Apps & features entry) |
| Linux | `/usr/bin`, `/usr/share/applications` | The release `.deb` (apt) or `.rpm` (dnf, zypper), with your system's password prompt. Without those, a Flatpak, or an AppImage in `~/Applications` |
| Linux, inside the launcher's Flatpak, with no Flatpak on the host | `~/.var/app/ai.storyteller.artcraft-launcher/data/artcraft-launcher/apps/<app>` | The release tarball, unpacked into the launcher's own folder and run inside the same sandbox, with its settings in a `home/` folder beside it. Nothing is installed on the host, and each app's configuration stays in the sandbox |
| FreeBSD | `/usr/local` | The release tree, with a password prompt (polkit); `~/.local` when polkit isn't installed |
ArtCraft Launcher keeps itself current too: when a new version is out, **Update and restart**
@@ -119,6 +124,12 @@ package or portable folder, the AppImage, or the `.deb`/`.rpm`) and reopens it.
Updates use the same format an app was installed with. Every download is checked against the
release's published SHA-256 before it is installed, and a running app is never replaced.
The launcher writes its log to `logs/artcraft-launcher.log` in its data folder
(`~/.local/share/artcraft-launcher` on Linux, `~/Library/Application Support/ArtCraft Launcher`
on macOS, `%LOCALAPPDATA%\ArtCraft Launcher` on Windows, `ArtCraftLauncherData` beside the
portable zip's executable) and keeps the previous two runs as `artcraft-launcher.1.log` and
`artcraft-launcher.2.log`. `RUST_LOG` changes what it records (`debug`, `info,zbus=warn`, …).
## Downloads
Download the build for your computer from the [latest release](https://github.com/storytold/craft-launcher/releases/latest), or browse [all releases](https://github.com/storytold/craft-launcher/releases). Every release has a `SHA256SUMS.txt` to check the files against. `<ver>` is the version number, for example `0.1.0`.
@@ -145,7 +156,7 @@ Download the build for your computer from the [latest release](https://github.co
| `artcraft-launcher-<ver>-linux-{x86_64,aarch64}.AppImage` | Runs on almost any distro without installing; `chmod +x` and run. The `.AppImage.zsync` beside it lets AppImageUpdate fetch only what changed |
| `artcraft-launcher-<ver>-linux-{x86_64,aarch64}.deb` | Debian, Ubuntu, Mint, Pop!_OS: `sudo apt install ./artcraft-launcher-…deb` |
| `artcraft-launcher-<ver>-linux-{x86_64,aarch64}.rpm` | Fedora, openSUSE, RHEL: `sudo dnf install ./artcraft-launcher-…rpm` |
| `artcraft-launcher-<ver>-linux-{x86_64,aarch64}.flatpak` | Flatpak bundle: `flatpak install --user artcraft-launcher-…flatpak`. Inside the sandbox the launcher can download apps but has limited power to install them on the host; prefer the AppImage, .deb or .rpm |
| `artcraft-launcher-<ver>-linux-{x86_64,aarch64}.flatpak` | Flatpak bundle: `flatpak install --user artcraft-launcher-…flatpak`. Inside the sandbox the launcher installs apps as Flatpaks on the host through `flatpak-spawn --host`; where the host has no Flatpak it falls back to each app's release tarball, unpacked and run inside the sandbox with the app's settings kept in it. Either way it can only reach the host as far as that permission allows; the AppImage, .deb and .rpm builds are the recommended way to run the launcher on Linux |
| `artcraft-launcher-<ver>-linux-{x86_64,aarch64}.tar.gz` | Plain tree (`bin/`, `share/`) for everyone else and for scripts |
RISC-V (riscv64): `artcraft-launcher-<ver>-linux-riscv64.{deb,rpm,tar.gz}`, built against Ubuntu 24.04 (needs glibc 2.39+).
@@ -213,7 +224,7 @@ New contributors and AI agents: start with [`AGENTS.md`](AGENTS.md). Release pac
> [!IMPORTANT]
> **Status:** ArtCraft Launcher is new and in early alpha. Expect rough edges, and please file
> issues (include your OS and which app you were installing) or tell us on
> issues (include your OS, which app you were installing and the launcher's log file) or tell us on
> [Discord](https://discord.gg/artcraft).
## The Craft App Suite
@@ -232,10 +243,11 @@ stand on its own.
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Releases](https://github.com/storytold/wordcraft/releases) |
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Releases](https://github.com/storytold/deckcraft/releases) |
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Releases](https://github.com/storytold/cadcraft/releases) |
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Releases](https://github.com/storytold/soundcraft/releases) |
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
| <img src="https://raw.githubusercontent.com/storytold/figcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
@@ -301,8 +313,6 @@ are trademarks of the ArtCraft Team and are not covered by this license. They ma
part of this repository and ArtCraft Launcher, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are either registered trademarks or trademarks of Adobe in the United States and/or other countries. Microsoft, Word, PowerPoint and Excel are trademarks of the Microsoft group of companies. Autodesk and AutoCAD are registered trademarks or trademarks of Autodesk, Inc. Avid and Pro Tools are trademarks or registered trademarks of Avid Technology, Inc. All other trademarks are the property of their respective owners. These names are used only to describe what each Craft App is for; the Craft App Suite is independent and is not affiliated with, sponsored by or endorsed by any of these companies.</sub>
<p align="center">
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
+1 -1
View File
@@ -26,4 +26,4 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "Navigator", "Request", "RequestInit", "RequestMode", "Response", "Headers"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "MediaQueryList", "Navigator", "Request", "RequestInit", "RequestMode", "Response", "Headers"] }
+92 -5
View File
@@ -3,6 +3,7 @@
use std::cell::RefCell;
use std::rc::Rc;
use artcraft_launcher_core::details::{AppDetails, MAX_SCREENSHOT_BYTES, looks_like_jpeg};
use artcraft_launcher_core::protocol::{Backend, Capabilities, Command, Event, LatestRelease, Origin, Source};
use artcraft_launcher_core::release::{self, Release};
use artcraft_launcher_core::{Arch, Os, Platform, SUITE, catalog, version};
@@ -95,8 +96,14 @@ pub fn start() {
});
}
struct NoHost;
impl Host for NoHost {}
/// The browser: nothing of the desktop's, but it knows whether the visitor asked for fewer animations.
struct WebHost;
impl Host for WebHost {
fn reduce_motion(&self) -> bool {
web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten()).is_some_and(|query| query.matches())
}
}
struct WebApp {
ui: LauncherUi,
@@ -104,11 +111,15 @@ struct WebApp {
impl eframe::App for WebApp {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.ui.logic(ctx, &mut NoHost);
self.ui.logic(ctx, &mut WebHost);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.ui.ui(ui, &mut NoHost);
self.ui.ui(ui, &mut WebHost);
}
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
self.ui.raw_input(ctx, raw_input);
}
fn save(&mut self, storage: &mut dyn eframe::Storage) {
@@ -122,11 +133,43 @@ struct WebBackend {
platform: Option<Platform>,
checking: Rc<RefCell<bool>>,
prereleases: bool,
/// Apps whose details pictures are on their way (a second request meanwhile does nothing).
pictures_busy: Rc<RefCell<std::collections::HashSet<&'static str>>>,
}
impl WebBackend {
fn new(ctx: egui::Context) -> Self {
Self { events: Rc::default(), ctx, platform: visitor_platform(), checking: Rc::default(), prereleases: false }
Self { events: Rc::default(), ctx, platform: visitor_platform(), checking: Rc::default(), prereleases: false, pictures_busy: Rc::default() }
}
/// Fetches the pictures of an app's details page, lead first. The browser keeps them in its own
/// cache, so there is nothing to save here.
fn fetch_screenshots(&self, app: &str) {
let Some(details) = AppDetails::by_id(app) else { return };
if !self.pictures_busy.borrow_mut().insert(details.id) {
return;
}
let (events, ctx, busy) = (self.events.clone(), self.ctx.clone(), self.pictures_busy.clone());
wasm_bindgen_futures::spawn_local(async move {
// Once the network has failed the rest would fail the same way: say so at once.
let mut offline: Option<String> = None;
for shot in details.shots {
let result = match (&offline, details.screenshot_url(shot.key)) {
(Some(why), _) => Err(why.clone()),
(None, Some(url)) => picture(&url).await.map_err(|failure| match failure {
PictureError::Network(why) => {
offline = Some(why.clone());
why
}
PictureError::Picture(why) => why,
}),
(None, None) => Err("this page has no such picture".to_string()),
};
events.borrow_mut().push(Event::Screenshot { app: details.id.to_string(), key: shot.key.to_string(), result });
ctx.request_repaint();
}
busy.borrow_mut().remove(details.id);
});
}
fn check(&self) {
@@ -165,6 +208,49 @@ impl WebBackend {
}
}
/// Why a picture couldn't be had.
enum PictureError {
/// This picture only: it isn't published, or what came back isn't a picture.
Picture(String),
/// The network or the server: the other pictures would fail the same way.
Network(String),
}
/// One picture of a details page. The size cap is checked on the announced length first and again
/// on what arrived; the bytes must start like a JPEG.
async fn picture(url: &str) -> Result<Vec<u8>, PictureError> {
let offline = |what: &str| PictureError::Network(format!("couldn't load a picture ({what})"));
let window = web_sys::window().ok_or_else(|| offline("no window"))?;
let init = web_sys::RequestInit::new();
init.set_method("GET");
init.set_mode(web_sys::RequestMode::Cors);
let request = web_sys::Request::new_with_str_and_init(url, &init).map_err(|_| offline("bad request"))?;
let resp: web_sys::Response = JsFuture::from(window.fetch_with_request(&request))
.await
.map_err(|_| offline("no connection"))?
.dyn_into()
.map_err(|_| offline("no answer"))?;
match resp.status() {
200 => {}
404 => return Err(PictureError::Picture("this picture isn't published".into())),
status => return Err(PictureError::Network(format!("the server answered HTTP {status}"))),
}
let too_big = || PictureError::Picture(format!("a picture of more than {MAX_SCREENSHOT_BYTES} bytes"));
let announced = resp.headers().get("content-length").ok().flatten().and_then(|n| n.parse::<usize>().ok());
if announced.is_some_and(|n| n > MAX_SCREENSHOT_BYTES) {
return Err(too_big());
}
let buffer = JsFuture::from(resp.array_buffer().map_err(|_| offline("unreadable"))?).await.map_err(|_| offline("unreadable"))?;
let bytes = js_sys::Uint8Array::new(&buffer).to_vec();
if bytes.len() > MAX_SCREENSHOT_BYTES {
return Err(too_big());
}
if !looks_like_jpeg(&bytes) {
return Err(PictureError::Picture("the answer isn't a JPEG picture".into()));
}
Ok(bytes)
}
/// The latest release; `Err(Some(reset))` when rate-limited, `Err(None)` on other failures.
async fn latest(repo: &str, prereleases: bool) -> Result<LatestRelease, Option<i64>> {
let url = if prereleases {
@@ -226,6 +312,7 @@ impl Backend for WebBackend {
fn send(&mut self, command: Command) {
match command {
Command::CheckForUpdates { .. } => self.check(),
Command::FetchScreenshots { app } => self.fetch_screenshots(&app),
Command::Configure(o) => {
let changed = o.prereleases != self.prereleases;
self.prereleases = o.prereleases;
+17 -3
View File
@@ -31,17 +31,31 @@ notify-rust = "4"
ron = "0.12.2"
serde = { workspace = true }
[target.'cfg(target_os = "linux")'.dependencies]
zbus = "5"
# The tray (menu bar) icon. On Linux and the BSDs the StatusNotifierItem backend talks D-Bus
# directly, so no GTK or libappindicator is needed.
[target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies]
tray-icon = "0.25"
# macOS only: which desktop (Space) the window shows on (spaces.rs). Safe AppKit calls only, and
# already in Cargo.lock through winit and tray-icon; pinned exactly to those versions.
# macOS only: which desktop (Space) the window shows on (spaces.rs) and the "Reduce motion"
# setting (motion.rs). Safe AppKit calls only, and already in Cargo.lock through winit and
# tray-icon; pinned exactly to those versions.
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "=0.6.5"
objc2-foundation = { version = "=0.3.2", default-features = false, features = ["std", "NSArray", "NSEnumerator"] }
objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow"] }
objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSAccessibility", "NSApplication", "NSResponder", "NSWindow", "NSWorkspace"] }
# Windows only: the "Animation effects" setting (motion.rs). Already in Cargo.lock through
# artcraft-launcher-native.
[target.'cfg(target_os = "windows")'.dependencies]
winreg = "0.55"
# Linux and the BSDs: the desktop's animations setting, from its settings portal (motion.rs).
# Already in Cargo.lock through tray-icon and notify-rust.
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
zbus = { version = "5", default-features = false, features = ["async-io", "blocking-api"] }
[target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", target_os = "dragonfly"))'.dependencies]
tray-icon = { version = "0.25", default-features = false, features = ["ksni"] }
+332
View File
@@ -0,0 +1,332 @@
//! Where log records go: standard error and a log file.
//!
//! A launcher started from the Dock, the Start menu or at login has no terminal (and the Windows
//! build has no console at all), so without a file every `log::` record and the report of a panic
//! vanished. `env_logger` still formats and filters the records; its output goes to standard
//! error and to `logs/artcraft-launcher.log` in the launcher's data folder (`Paths::logs`). Each
//! start moves the previous log to `artcraft-launcher.1.log` (and that one to `.2`), so the log
//! of a run that crashed survives the next launch.
//!
//! Levels: `info` for the launcher's own crates, `warn` for everything else (wgpu and zbus are
//! chatty at `info`). `RUST_LOG` replaces that as before: `debug`, `info,zbus=warn`, ….
//!
//! Records logged before the file is attached are kept (up to [`MAX_PENDING`]) and written first.
//! Writing never panics: a file that can't be created or written leaves standard error as the
//! only sink. Never log a sign-in token.
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, PoisonError};
/// The current log file's name inside the log directory.
pub const LOG_FILE: &str = "artcraft-launcher.log";
/// How many previous logs are kept (`artcraft-launcher.1.log` … `artcraft-launcher.<KEEP>.log`).
pub const KEEP: usize = 2;
/// The log file stops growing past this size (a runaway warning can't fill the disk).
pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
/// Records kept in memory until the log file is attached.
pub const MAX_PENDING: usize = 512;
/// The filter when `RUST_LOG` is unset (env_logger matches `artcraft_launcher` as a prefix).
pub const DEFAULT_FILTER: &str = "warn,artcraft_launcher=info";
fn numbered(dir: &Path, n: usize) -> PathBuf {
if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("artcraft-launcher.{n}.log")) }
}
/// Shift the previous logs up one (`.log` → `.1.log` → … → `.<KEEP>.log`, the oldest dropped)
/// and create a fresh, empty log file in `dir` (created if missing).
pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
std::fs::create_dir_all(dir)?;
for n in (1..=KEEP).rev() {
match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e),
_ => {}
}
}
let path = numbered(dir, 0);
let file = File::create(&path)?;
Ok((path, file))
}
/// Where formatted records go besides standard error: kept in memory until a file is attached,
/// then appended to it until it reaches its size cap.
pub struct Sink {
file: Option<File>,
pending: Vec<String>,
dropped: usize,
written: u64,
max_bytes: u64,
capped: bool,
}
impl Sink {
pub fn new(max_bytes: u64) -> Sink {
Sink { file: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false }
}
/// Append `record` to the file, or keep it until [`Sink::attach`].
pub fn write(&mut self, record: &str) {
if self.file.is_none() && !self.capped {
if self.pending.len() < MAX_PENDING {
self.pending.push(record.to_owned());
} else {
self.dropped = self.dropped.saturating_add(1);
}
return;
}
self.append(record);
}
/// Start writing to `file`, first the records kept so far.
pub fn attach(&mut self, file: File) {
self.file = Some(file);
for record in std::mem::take(&mut self.pending) {
self.append(&record);
}
if self.dropped > 0 {
let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped);
self.dropped = 0;
self.append(&note);
}
}
/// No file this run: stop keeping records for one.
pub fn no_file(&mut self) {
self.pending = Vec::new();
self.dropped = 0;
self.capped = true;
}
fn append(&mut self, record: &str) {
if self.capped {
return;
}
let Some(file) = self.file.as_mut() else { return };
let len = u64::try_from(record.len()).unwrap_or(u64::MAX);
let text = if self.written.saturating_add(len) > self.max_bytes {
self.capped = true;
format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes)
} else {
self.written = self.written.saturating_add(len);
record.to_owned()
};
// A full disk or a vanished file must not take the launcher down: drop the file sink.
if file.write_all(text.as_bytes()).is_err() {
self.file = None;
self.capped = true;
}
}
}
/// env_logger's output: each formatted record to standard error and to the [`Sink`].
struct Tee {
sink: Arc<Mutex<Sink>>,
stderr: bool,
}
impl Write for Tee {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
if self.stderr {
let _ = std::io::stderr().write_all(buf);
}
self.sink.lock().unwrap_or_else(PoisonError::into_inner).write(&String::from_utf8_lossy(buf));
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
if self.stderr {
let _ = std::io::stderr().flush();
}
Ok(())
}
}
/// The installed logger's file side.
pub struct Logs {
sink: Arc<Mutex<Sink>>,
}
impl Logs {
/// Rotate the logs in `dir` and send records to the fresh file; returns its path.
pub fn attach_dir(&self, dir: &Path) -> Result<PathBuf, String> {
let (path, file) = rotate(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
self.sink.lock().unwrap_or_else(PoisonError::into_inner).attach(file);
Ok(path)
}
/// No log file this run (no data folder on this system).
pub fn no_file(&self) {
self.sink.lock().unwrap_or_else(PoisonError::into_inner).no_file();
}
}
/// An env_logger builder (filter from `RUST_LOG`, else [`DEFAULT_FILTER`]) writing through a [`Tee`].
fn builder(sink: Arc<Mutex<Sink>>, stderr: bool) -> env_logger::Builder {
let mut b = env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(DEFAULT_FILTER));
b.target(env_logger::Target::Pipe(Box::new(Tee { sink, stderr })));
b
}
/// Install the logger; `None` when another logger was installed first. Call
/// [`Logs::attach_dir`] (or [`Logs::no_file`]) once it is known whether this run keeps a log.
pub fn install() -> Option<Logs> {
let sink = Arc::new(Mutex::new(Sink::new(MAX_FILE_BYTES)));
builder(sink.clone(), true).try_init().ok()?;
Some(Logs { sink })
}
/// Report a panic through the logger (standard error and the log file), with a backtrace when
/// `RUST_BACKTRACE` asks for one; standard error alone when `RUST_LOG` turned errors off.
pub fn install_panic_hook() {
std::panic::set_hook(Box::new(|info| {
let trace = std::backtrace::Backtrace::capture();
let report = if trace.status() == std::backtrace::BacktraceStatus::Captured {
format!("internal error: {info}\n{trace}")
} else {
format!("internal error: {info}")
};
if log::log_enabled!(log::Level::Error) {
log::error!("{report}");
} else {
let _ = writeln!(std::io::stderr(), "artcraft-launcher: {report}");
}
}));
}
#[cfg(test)]
mod tests {
use super::*;
use log::Log;
use std::sync::atomic::{AtomicUsize, Ordering};
fn temp_dir(tag: &str) -> PathBuf {
static N: AtomicUsize = AtomicUsize::new(0);
let d = std::env::temp_dir().join(format!("artcraft-launcher-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
d
}
fn read(path: &Path) -> String {
std::fs::read_to_string(path).unwrap()
}
#[test]
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
let dir = temp_dir("rotate");
for run in 1..=4 {
let (path, _file) = rotate(&dir).unwrap();
assert_eq!(path, dir.join(LOG_FILE));
assert_eq!(read(&path), "");
std::fs::write(&path, format!("run {run}")).unwrap();
}
assert_eq!(read(&dir.join(LOG_FILE)), "run 4");
assert_eq!(read(&dir.join("artcraft-launcher.1.log")), "run 3");
assert_eq!(read(&dir.join("artcraft-launcher.2.log")), "run 2");
assert!(!dir.join("artcraft-launcher.3.log").exists(), "only {KEEP} old logs are kept");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
let dir = temp_dir("blocked");
std::fs::write(&dir, "a file where the directory should be").unwrap();
assert!(rotate(&dir).is_err());
let _ = std::fs::remove_file(&dir);
}
#[test]
fn records_logged_before_the_file_exists_are_written_when_it_is_attached() {
let dir = temp_dir("pending");
let (path, file) = rotate(&dir).unwrap();
let mut sink = Sink::new(MAX_FILE_BYTES);
sink.write("early 1\n");
sink.write("early 2\n");
sink.attach(file);
sink.write("late\n");
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
let dir = temp_dir("bounded");
let (path, file) = rotate(&dir).unwrap();
let mut sink = Sink::new(MAX_FILE_BYTES);
for i in 0..MAX_PENDING + 5 {
sink.write(&format!("line {i}\n"));
}
sink.attach(file);
let text = read(&path);
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
assert_eq!(text.matches("5 earlier log lines were dropped").count(), 1, "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn without_a_file_nothing_is_kept() {
let mut sink = Sink::new(MAX_FILE_BYTES);
sink.write("early\n");
sink.no_file();
sink.write("late\n");
assert!(sink.pending.is_empty() && sink.dropped == 0);
}
#[test]
fn the_file_stops_at_its_size_cap_with_one_note() {
let dir = temp_dir("cap");
let (path, file) = rotate(&dir).unwrap();
let mut sink = Sink::new(100);
sink.attach(file);
for i in 0..50 {
sink.write(&format!("line {i:02} with some padding\n"));
}
let text = read(&path);
assert!(text.lines().filter(|l| l.starts_with("line ")).count() < 5, "{text}");
assert_eq!(text.matches("log file reached").count(), 1, "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
/// env_logger's formatted records reach the file: the launcher's info records and everyone's
/// warnings.
#[test]
fn env_logger_records_reach_the_attached_file() {
let dir = temp_dir("logger");
let sink = Arc::new(Mutex::new(Sink::new(MAX_FILE_BYTES)));
let logger = builder(sink.clone(), false).parse_filters(DEFAULT_FILTER).build();
let logs = Logs { sink };
let record = |level, target: &'static str, msg: &'static str| {
logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build());
};
record(log::Level::Info, "artcraft_launcher", "before the file");
let path = logs.attach_dir(&dir).unwrap();
record(log::Level::Warn, "wgpu_hal::vulkan", "a real warning");
record(log::Level::Info, "artcraft_launcher_native::proxy", "proxy in use");
record(log::Level::Info, "zbus::connection", "too chatty");
record(log::Level::Debug, "artcraft_launcher", "below info");
let text = read(&path);
assert!(text.contains("artcraft_launcher] before the file\n"), "{text}");
assert!(text.contains("WARN wgpu_hal::vulkan] a real warning\n"), "{text}");
assert!(text.contains("artcraft_launcher_native::proxy] proxy in use\n"), "{text}");
assert!(!text.contains("too chatty") && !text.contains("below info") && !text.contains('\u{1b}'), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_poisoned_lock_does_not_stop_logging() {
let dir = temp_dir("poison");
let logs = Logs { sink: Arc::new(Mutex::new(Sink::new(MAX_FILE_BYTES))) };
let path = logs.attach_dir(&dir).unwrap();
let sink = logs.sink.clone();
let _ = std::thread::spawn(move || {
let _guard = sink.lock();
panic!("poison the sink");
})
.join();
assert!(logs.sink.is_poisoned());
let mut tee = Tee { sink: logs.sink.clone(), stderr: false };
tee.write_all(b"after the panic\n").unwrap();
assert!(read(&path).contains("after the panic\n"));
let _ = std::fs::remove_dir_all(&dir);
}
}
+170 -5
View File
@@ -5,9 +5,13 @@
#![cfg_attr(windows, windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod logging;
mod motion;
mod settings_file;
#[cfg(target_os = "macos")]
mod spaces;
#[cfg(target_os = "linux")]
mod system_theme;
mod tray;
use std::path::PathBuf;
@@ -30,7 +34,9 @@ const SETTINGS_KEY: &str = "settings";
type Wake = Arc<OnceLock<Box<dyn Fn() + Send + Sync>>>;
fn main() -> ExitCode {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
// First, so the panic report and every start-up warning are recorded (`logging`).
let logs = logging::install();
logging::install_panic_hook();
// Detect system language in the background so the first frame can use it.
artcraft_launcher_ui_egui::i18n::detect_system_lang_in_background();
let args: Vec<String> = std::env::args().skip(1).collect();
@@ -45,6 +51,25 @@ fn main() -> ExitCode {
Instance::Secondary => return ExitCode::SUCCESS,
Instance::Primary(rx) => rx,
};
// The log file lives in the launcher's data folder (beside the executable in portable mode);
// opened after the arguments (so `--version` leaves no file behind) and once this is the only
// launcher running, so a second start doesn't rotate away the running one's log. Records
// logged until now are written to it first.
if let Some(logs) = logs {
match artcraft_launcher_native::paths::Paths::new() {
Ok(paths) => match logs.attach_dir(&paths.logs()) {
Ok(path) => log::info!("ArtCraft Launcher {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()),
Err(e) => {
logs.no_file();
log::warn!("no log file: {e}");
}
},
Err(e) => {
logs.no_file();
log::warn!("no log file: {e}");
}
}
}
let (tray_loop, woken) = sync_channel(1);
let waker = Arc::new(Waker { window: Mutex::new(None), tray_loop, quit_pending: AtomicBool::new(false) });
{
@@ -52,9 +77,18 @@ fn main() -> ExitCode {
let _ = wake.set(Box::new(move || waker.wake()));
}
// The desktop's animation setting comes over D-Bus: ask now, so the answer is in by the first frame.
#[cfg(all(unix, not(target_os = "macos")))]
let _ = motion::reduced();
let backend_waker = waker.clone();
let settings_path = settings_file::path(APP_ID);
let mut launcher = Launcher::new(Box::new(NativeBackend::new(move || backend_waker.wake())), starts, settings_path.clone());
#[cfg(target_os = "linux")]
{
let theme_waker = waker.clone();
launcher.system_theme = system_theme::Monitor::start(move || theme_waker.wake());
}
// Started hidden at login where a window can't hide: start in the tray without mapping one,
// so no window flashes up. Without a tray to come back from, the window opens after all.
let mut window = true;
@@ -110,7 +144,12 @@ fn native_options(hidden: bool) -> eframe::NativeOptions {
} else {
viewport.with_decorations(false)
};
if let Some(icon) = icon {
// On macOS the bundle's .icns is the icon. Any icon given to eframe (its egui logo when none
// is) replaces it in the Dock at runtime with a raw bitmap, which skips the system's icon
// treatment, so pass the empty icon, which eframe takes as "leave it alone".
if cfg!(target_os = "macos") {
viewport = viewport.with_icon(egui::IconData::default());
} else if let Some(icon) = icon {
viewport = viewport.with_icon(icon);
}
eframe::NativeOptions { viewport, persist_window: true, wgpu_options: wgpu_options(), ..Default::default() }
@@ -121,12 +160,20 @@ fn native_options(hidden: bool) -> eframe::NativeOptions {
/// integrated and discrete GPUs run different driver packages (common on OEM machines, where the
/// Adrenalin installer leaves the integrated GPU on the OEM driver) it crashes inside
/// `amdvlk64.dll` before the first frame. `WGPU_BACKEND` still overrides the default, and then
/// wgpu picks the adapter itself.
/// wgpu picks the adapter itself. Direct3D 12 shaders compile with FXC ([`pin_dx12_compiler`]).
///
/// Direct3D 12 presents through DirectComposition rather than straight to the window. The
/// borderless window's drop shadow moves its client area down a pixel, and a swapchain made from
/// the window is then stretched over it vertically by the compositor, blurring every horizontal
/// edge and line of text. `WGPU_DX12_PRESENTATION_SYSTEM` still overrides it.
#[cfg(windows)]
fn wgpu_options() -> eframe::WgpuConfiguration {
use eframe::wgpu::{Backends, PowerPreference};
use eframe::wgpu::{Backends, Dx12Compiler, Dx12SwapchainKind, PowerPreference};
let mut config = eframe::WgpuConfiguration::default();
pin_dx12_compiler(&mut config, Dx12Compiler::from_env());
if let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &mut config.wgpu_setup {
setup.instance_descriptor.backend_options.dx12.presentation_system =
Dx12SwapchainKind::from_env().unwrap_or(Dx12SwapchainKind::DxgiFromVisual);
if let Some(backends) = Backends::from_env() {
setup.instance_descriptor.backends = backends;
} else {
@@ -148,6 +195,19 @@ fn wgpu_options() -> eframe::WgpuConfiguration {
config
}
/// Compiles Direct3D 12 shaders with FXC (`d3dcompiler_47.dll`, part of Windows) unless
/// `env_choice` (normally `Dx12Compiler::from_env()`, i.e. `WGPU_DX12_COMPILER`) names another.
/// wgpu's default (`Auto`) loads whichever `dxcompiler.dll` the DLL search path finds. The launcher
/// ships none, and without a matching `dxil.dll` DXC warns that the DXIL is unsigned; wgpu takes
/// the warning for a compile error, its indirect-validation pipeline fails, the device is lost
/// and the launcher exits before the first frame (#126).
#[cfg(any(windows, test))]
fn pin_dx12_compiler(config: &mut eframe::WgpuConfiguration, env_choice: Option<eframe::wgpu::Dx12Compiler>) {
if let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &mut config.wgpu_setup {
setup.instance_descriptor.backend_options.dx12.shader_compiler = env_choice.unwrap_or(eframe::wgpu::Dx12Compiler::Fxc);
}
}
/// wgpu's own adapter order (by device type, then enumeration order), except that any Direct3D 12
/// adapter, the WARP software one included, comes before GL. wgpu ranks WARP last, so on GPUs with
/// no D3D12 driver (Intel HD 4000, Radeon TeraScale) it picked the GL one, whose legacy driver
@@ -165,7 +225,38 @@ fn adapter_rank(backend: eframe::wgpu::Backend, device_type: eframe::wgpu::Devic
(backend != Backend::Dx12, device)
}
#[cfg(not(windows))]
/// wgpu's adapter on Linux and the BSDs: the GPU the desktop runs on. egui asks for the
/// high-performance GPU, which on a hybrid laptop in PRIME on-demand mode is the discrete one while
/// the display server runs on the integrated one: every frame then crosses between the GPUs, and
/// while the window is resized it shows stale or garbled frames and can stop drawing (issue #69).
/// With no preference wgpu takes the first adapter Vulkan lists, and Mesa's device-select layer
/// lists the display server's GPU first (or the one `DRI_PRIME` picks). Without that layer the
/// order follows the ICD files, where lavapipe (`lvp_icd`) can sort before the real driver, so a
/// software rasterizer only wins when nothing else can draw. `WGPU_POWER_PREF` (`low`, `high` or
/// `none`) still overrides it, and then wgpu picks the adapter itself.
#[cfg(not(any(windows, target_os = "macos")))]
fn wgpu_options() -> eframe::WgpuConfiguration {
use eframe::wgpu::{DeviceType, PowerPreference};
let mut config = eframe::WgpuConfiguration::default();
if let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &mut config.wgpu_setup {
if let Some(preference) = PowerPreference::from_env() {
setup.power_preference = preference;
} else {
setup.power_preference = PowerPreference::None;
setup.native_adapter_selector = Some(Arc::new(|adapters, surface| {
adapters
.iter()
.filter(|a| surface.is_none_or(|s| a.is_surface_supported(s)))
.min_by_key(|a| a.get_info().device_type == DeviceType::Cpu)
.cloned()
.ok_or_else(|| "no graphics adapter can draw to the window".to_owned())
}));
}
}
config
}
#[cfg(target_os = "macos")]
fn wgpu_options() -> eframe::WgpuConfiguration {
eframe::WgpuConfiguration::default()
}
@@ -282,6 +373,21 @@ fn no_window() -> egui::RawInput {
egui::RawInput { focused: false, viewports: std::iter::once((egui::ViewportId::ROOT, viewport)).collect(), ..Default::default() }
}
/// macOS sends notifications on behalf of a bundle id. When none is set, the library looks up an app named
/// "use_default" and macOS asks the user "Where is use_default?". Set ours before the first notification.
/// The library accepts only one call (even a failed one skips that lookup), so the first outcome is kept and logged once.
#[cfg(target_os = "macos")]
fn claim_notification_identity() -> &'static Result<(), String> {
static CLAIMED: OnceLock<Result<(), String>> = OnceLock::new();
CLAIMED.get_or_init(|| {
let claimed = notify_rust::set_application(APP_ID).map_err(|e| e.to_string());
if let Err(e) = &claimed {
log::warn!("notification identity {APP_ID}: {e}");
}
claimed
})
}
struct DesktopHost {
tray: bool,
quit: bool,
@@ -296,6 +402,8 @@ impl Host for DesktopHost {
let (title, body) = (title.to_string(), body.to_string());
// Some platforms block while the notification is delivered.
std::thread::spawn(move || {
#[cfg(target_os = "macos")]
claim_notification_identity();
let mut n = notify_rust::Notification::new();
n.summary(&title).body(&body).appname("ArtCraft Launcher");
#[cfg(windows)]
@@ -324,6 +432,10 @@ impl Host for DesktopHost {
login::set_enabled(on)
}
fn reduce_motion(&self) -> bool {
motion::reduced()
}
fn quit(&mut self) {
self.quit = true;
}
@@ -332,6 +444,8 @@ impl Host for DesktopHost {
/// Everything that outlives a window: the UI state and its backend, the tray icon, and the work
/// that goes on with or without a window.
struct Launcher {
#[cfg(target_os = "linux")]
system_theme: system_theme::Monitor,
ui: LauncherUi,
tray: Option<tray::Tray>,
host: DesktopHost,
@@ -359,6 +473,8 @@ impl Launcher {
let _ = asked.send(info.delay);
});
Self {
#[cfg(target_os = "linux")]
system_theme: system_theme::Monitor::default(),
ui: LauncherUi::new(&windowless, backend, Settings::default()),
tray: None,
host: DesktopHost { tray: false, quit: false },
@@ -590,6 +706,12 @@ impl eframe::App for Shell<'_> {
self.launcher.ui.ui(ui, &mut self.launcher.host);
}
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
#[cfg(target_os = "linux")]
self.launcher.system_theme.apply(raw_input);
self.launcher.ui.raw_input(ctx, raw_input);
}
fn save(&mut self, storage: &mut dyn eframe::Storage) {
eframe::set_value(storage, SETTINGS_KEY, &self.launcher.ui.settings);
}
@@ -613,6 +735,15 @@ mod tests {
const NOW: i64 = 1_791_500_000;
/// The bundle id is set once: asking again gives the first outcome, not the library's "already set" error.
/// (Setting it fails where the launcher is not installed, so the outcome itself is not asserted.)
#[cfg(target_os = "macos")]
#[test]
fn notification_identity_is_claimed_once() {
let first = claim_notification_identity();
assert_eq!(claim_notification_identity(), first);
}
/// What the tests can see of a [`Quiet`] backend, and its clock.
#[derive(Default)]
struct Seen {
@@ -843,6 +974,27 @@ mod tests {
assert!(!shell.launcher.closed_to_tray);
}
#[test]
fn dx12_shaders_compile_with_fxc_unless_asked() {
use eframe::egui_wgpu::WgpuSetup;
use eframe::wgpu::Dx12Compiler;
fn compiler(env_choice: Option<Dx12Compiler>) -> Dx12Compiler {
let mut config = eframe::WgpuConfiguration::default();
if let WgpuSetup::CreateNew(setup) = &mut config.wgpu_setup {
// wgpu's default: whichever dxcompiler.dll the DLL search path finds (#126).
setup.instance_descriptor.backend_options.dx12.shader_compiler = Dx12Compiler::Auto;
}
pin_dx12_compiler(&mut config, env_choice);
match config.wgpu_setup {
WgpuSetup::CreateNew(setup) => setup.instance_descriptor.backend_options.dx12.shader_compiler,
WgpuSetup::Existing(_) => panic!("the default configuration creates its own instance"),
}
}
assert!(matches!(compiler(None), Dx12Compiler::Fxc));
assert!(matches!(compiler(Some(Dx12Compiler::Auto)), Dx12Compiler::Auto));
assert!(matches!(compiler(Some(Dx12Compiler::default_dynamic_dxc())), Dx12Compiler::DynamicDxc { .. }));
}
#[test]
fn warp_beats_a_gl_gpu_but_not_a_d3d12_gpu() {
use eframe::wgpu::{Backend, DeviceType};
@@ -856,4 +1008,17 @@ mod tests {
assert!(adapter_rank(Backend::Dx12, discrete, false) < adapter_rank(Backend::Dx12, integrated, false));
assert!(adapter_rank(Backend::Dx12, integrated, true) < adapter_rank(Backend::Dx12, discrete, true));
}
#[cfg(not(any(windows, target_os = "macos")))]
#[test]
fn linux_draws_on_the_desktops_gpu_unless_told_otherwise() {
use eframe::wgpu::PowerPreference;
let config = wgpu_options();
let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &config.wgpu_setup else {
panic!("the launcher creates its own wgpu instance");
};
// egui would ask for the discrete GPU: on a PRIME laptop that's not the desktop's (issue #69).
let expected = PowerPreference::from_env().unwrap_or(PowerPreference::None);
assert_eq!(setup.power_preference, expected);
}
}
+113
View File
@@ -0,0 +1,113 @@
//! Whether the OS asks for fewer animations: Windows' "Animation effects" switched off, macOS'
//! "Reduce motion", a Linux or BSD desktop's animations setting. The UI asks about once a second
//! while it draws (`Host::reduce_motion`), so a change shows without a restart.
/// Does the OS ask for fewer animations? Animations stay on where it can't be told.
#[cfg(windows)]
pub fn reduced() -> bool {
use winreg::RegKey;
use winreg::enums::HKEY_CURRENT_USER;
RegKey::predef(HKEY_CURRENT_USER)
.open_subkey(r"Control Panel\Desktop")
.and_then(|desktop| desktop.get_raw_value("UserPreferencesMask"))
.is_ok_and(|mask| animations_off(&mask.bytes))
}
/// Windows keeps `SPI_GETCLIENTAREAANIMATION` (the switch in Settings, and what browsers follow
/// for `prefers-reduced-motion`) as bit 33 of `UserPreferencesMask`. Reading the registry stands
/// in for `SystemParametersInfoW`, which needs `unsafe`. A mask too short for the bit says nothing.
#[cfg(any(windows, test))]
fn animations_off(mask: &[u8]) -> bool {
mask.get(4).is_some_and(|byte| byte & 0b10 == 0)
}
/// Does the OS ask for fewer animations?
#[cfg(target_os = "macos")]
pub fn reduced() -> bool {
objc2_app_kit::NSWorkspace::sharedWorkspace().accessibilityDisplayShouldReduceMotion()
}
/// Does the desktop ask for fewer animations? What its settings portal last said: the first call
/// starts a thread that asks it and then follows its changes, as an answer over D-Bus can take a
/// while and the UI must not wait for one. Animations stay on until it has answered, and where
/// there is no portal.
#[cfg(all(unix, not(target_os = "macos")))]
pub fn reduced() -> bool {
use std::sync::Once;
use std::sync::atomic::Ordering;
static WATCH: Once = Once::new();
WATCH.call_once(|| {
std::thread::spawn(|| {
if let Err(e) = portal::watch() {
log::debug!("the desktop's animation setting can't be read: {e}");
}
});
});
portal::REDUCED.load(Ordering::Relaxed)
}
/// The XDG desktop portal's settings, which a sandboxed Flatpak can read as well.
#[cfg(all(unix, not(target_os = "macos")))]
mod portal {
use std::sync::atomic::{AtomicBool, Ordering};
use zbus::blocking::{Connection, Proxy};
use zbus::zvariant::{OwnedValue, Value};
pub static REDUCED: AtomicBool = AtomicBool::new(false);
/// Keeps [`REDUCED`] to what the portal says, for as long as the session bus is there.
pub fn watch() -> zbus::Result<()> {
let bus = Connection::session()?;
let settings = Proxy::new(&bus, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings")?;
let changes = settings.receive_signal("SettingChanged")?;
REDUCED.store(reduced(&settings), Ordering::Relaxed);
for _ in changes {
REDUCED.store(reduced(&settings), Ordering::Relaxed);
}
Ok(())
}
/// Any one of the settings desktops keep this in asking for fewer animations is enough: the
/// portal's own `reduced-motion`, GNOME's `enable-animations` (which desktops built on GTK
/// share), and KDE's animation speed, where 0 is "instant".
fn reduced(settings: &Proxy<'_>) -> bool {
let read = |namespace: &str, key: &str| {
// `ReadOne` is the newer call; older portals only have `Read`.
["ReadOne", "Read"].iter().find_map(|method| settings.call::<_, _, OwnedValue>(*method, &(namespace, key)).ok())
};
read("org.freedesktop.appearance", "reduced-motion").is_some_and(|v| matches!(plain(&v), Value::U32(1)))
|| read("org.gnome.desktop.interface", "enable-animations").is_some_and(|v| matches!(plain(&v), Value::Bool(false)))
|| read("org.kde.kdeglobals.KDE", "AnimationDurationFactor").is_some_and(|v| match plain(&v) {
Value::Str(factor) => factor.trim().parse::<f64>().is_ok_and(|f| f == 0.0),
Value::F64(factor) => *factor == 0.0,
_ => false,
})
}
/// The value inside the variants the portal wraps it in (`Read` wraps it twice).
fn plain<'v>(value: &'v Value<'v>) -> &'v Value<'v> {
match value {
Value::Value(inner) => plain(inner),
other => other,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn windows_animation_setting_is_bit_33_of_the_mask() {
// Windows' default mask, then one from a machine with "Animation effects" switched off.
assert!(!animations_off(&[0x9E, 0x1E, 0x07, 0x80, 0x12, 0x00, 0x00, 0x00]));
assert!(animations_off(&[0x90, 0x12, 0x03, 0x80, 0x10, 0x00, 0x00, 0x00]));
}
#[test]
fn a_short_mask_leaves_animations_on() {
assert!(!animations_off(&[]));
assert!(!animations_off(&[0x90, 0x12, 0x03, 0x80]));
}
}
+147
View File
@@ -0,0 +1,147 @@
//! The desktop appearance portal supplies the theme that winit cannot read on Wayland.
use std::sync::Arc;
use std::sync::atomic::{AtomicU8, Ordering};
use std::time::Duration;
use zbus::blocking::{Proxy, connection::Builder};
use zbus::zvariant::{OwnedValue, Value};
const APPEARANCE: &str = "org.freedesktop.appearance";
const COLOR_SCHEME: &str = "color-scheme";
#[derive(Default)]
pub struct Monitor(Arc<AtomicU8>);
impl Monitor {
pub fn start(wake: impl Fn() + Send + 'static) -> Self {
let monitor = Self::default();
let state = Arc::downgrade(&monitor.0);
if let Err(e) = std::thread::Builder::new().name("system-theme".into()).spawn(move || {
let publish = |scheme| {
if let Some(state) = state.upgrade()
&& state.swap(scheme, Ordering::Relaxed) != scheme
{
log::debug!("desktop appearance: {:?}", theme(scheme));
wake();
}
};
// Retry after the portal goes away, backing off to every 5 minutes where there's none.
let mut retry = Duration::from_secs(5);
while state.strong_count() > 0 {
match watch(&publish) {
Ok(()) => retry = Duration::from_secs(5),
Err(e) => log::debug!("desktop appearance portal: {e}"),
}
publish(0);
std::thread::sleep(retry);
retry = (retry * 2).min(Duration::from_secs(300));
}
}) {
log::warn!("couldn't monitor the system theme: {e}");
}
monitor
}
pub fn apply(&self, input: &mut egui::RawInput) {
if let Some(theme) = theme(self.0.load(Ordering::Relaxed)) {
input.system_theme = Some(theme);
}
}
}
fn theme(scheme: u8) -> Option<egui::Theme> {
match scheme {
1 => Some(egui::Theme::Dark),
2 => Some(egui::Theme::Light),
_ => None,
}
}
fn scheme(value: OwnedValue) -> u8 {
// Read returns an extra variant wrapper; ReadOne and SettingChanged do not.
let value = match Value::from(value) {
Value::Value(value) => *value,
value => value,
};
match u32::try_from(value) {
Ok(1) => 1,
Ok(2) => 2,
_ => 0,
}
}
fn watch(publish: &impl Fn(u8)) -> zbus::Result<()> {
let connection = Builder::session()?.method_timeout(Duration::from_secs(3)).build()?;
let proxy = Proxy::new(&connection, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings")?;
// Subscribe before reading so a theme switch during startup is not missed.
let changes = proxy.receive_signal_with_args("SettingChanged", &[(0, APPEARANCE), (1, COLOR_SCHEME)])?;
let initial: OwnedValue = proxy.call("ReadOne", &(APPEARANCE, COLOR_SCHEME)).or_else(|_| proxy.call("Read", &(APPEARANCE, COLOR_SCHEME)))?;
publish(scheme(initial));
for message in changes {
let (namespace, key, value): (String, String, OwnedValue) = message.body().deserialize()?;
if namespace == APPEARANCE && key == COLOR_SCHEME {
publish(scheme(value));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[ignore = "requires a session bus and a desktop portal with an explicit color preference"]
fn running_desktop_portal_reaches_egui() {
let (send, receive) = std::sync::mpsc::channel();
let monitor = Monitor::start(move || {
let _ = send.send(());
});
assert!(receive.recv_timeout(Duration::from_secs(10)).is_ok());
let mut input = egui::RawInput::default();
monitor.apply(&mut input);
assert!(input.system_theme.is_some());
let ctx = egui::Context::default();
ctx.set_theme(egui::ThemePreference::System);
ctx.begin_pass(input.clone());
assert_eq!(Some(ctx.theme()), input.system_theme);
ctx.end_pass().textures_delta.clear();
}
#[test]
fn portal_values_include_no_preference_and_unknown_values() {
for (value, expected) in [(0, None), (1, Some(egui::Theme::Dark)), (2, Some(egui::Theme::Light)), (3, None), (u32::MAX, None)] {
assert_eq!(theme(scheme(OwnedValue::from(value))), expected);
}
assert_eq!(scheme(OwnedValue::from(true)), 0);
}
#[test]
fn legacy_read_unwraps_the_extra_variant() {
let value = Value::Value(Box::new(Value::U32(2)));
assert_eq!(value.try_to_owned().map(scheme).ok(), Some(2));
}
#[test]
fn portal_changes_preserve_explicit_choices_and_unknown_falls_back_to_winit() {
let monitor = Monitor::default();
let ctx = egui::Context::default();
for preference in [egui::ThemePreference::System, egui::ThemePreference::Light, egui::ThemePreference::Dark] {
ctx.set_theme(preference);
for scheme in [1, 2, 0] {
monitor.0.store(scheme, Ordering::Relaxed);
let mut input = egui::RawInput { system_theme: Some(egui::Theme::Dark), ..Default::default() };
monitor.apply(&mut input);
let expected = match preference {
egui::ThemePreference::Light => egui::Theme::Light,
egui::ThemePreference::Dark => egui::Theme::Dark,
egui::ThemePreference::System => theme(scheme).unwrap_or(egui::Theme::Dark),
};
ctx.begin_pass(input);
assert_eq!(ctx.theme(), expected);
ctx.end_pass().textures_delta.clear();
}
}
}
}
+27 -1
View File
@@ -40,6 +40,15 @@ fn load_icon() -> Result<tray_icon::Icon, String> {
tray_icon::Icon::from_rgba(img.into_raw(), w, h).map_err(|e| e.to_string())
}
/// Names the icon for Linux and the BSDs. tray-icon's default id is "<pid>-<n>" and the title is
/// empty, and StatusNotifierItem hosts (KDE, GNOME's AppIndicator extension) fall back to the id
/// when there is no title, so the tray showed a number like "61965-1" instead of the app name.
/// Windows and macOS keep tray-icon's defaults: there the title is drawn next to the icon (macOS)
/// or unsupported (Windows), and the id never reaches the user.
fn identity(builder: TrayIconBuilder) -> TrayIconBuilder {
if cfg!(any(target_os = "macos", target_os = "windows")) { builder } else { builder.with_id("artcraft-launcher").with_title("ArtCraft Launcher") }
}
impl Tray {
/// Creates the tray icon. `wake` repaints the UI so it sees the action, even when hidden.
/// `on_quit` runs when Quit is picked, before the UI can see it: the UI may never get to.
@@ -55,7 +64,7 @@ impl Tray {
menu.append_items(&[&show, &check, &PredefinedMenuItem::separator(), &website, &github, &discord, &PredefinedMenuItem::separator(), &quit])
.map_err(|e| e.to_string())?;
let links = [(website.id().clone(), WEBSITE_URL), (github.id().clone(), GITHUB_URL), (discord.id().clone(), DISCORD_URL)];
let icon = TrayIconBuilder::new()
let icon = identity(TrayIconBuilder::new())
.with_menu(Box::new(menu))
.with_icon(load_icon()?)
.with_icon_as_template(cfg!(target_os = "macos"))
@@ -133,3 +142,20 @@ impl Tray {
(&self.show, &self.check, &self.quit)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn linux_tray_has_a_stable_id_not_the_process_id() {
let id = identity(TrayIconBuilder::new()).id().clone();
let pid = std::process::id().to_string();
if cfg!(any(target_os = "macos", target_os = "windows")) {
// Left as tray-icon picks it there: "<pid>-<n>".
assert!(id.as_ref().starts_with(&pid), "{id:?}");
} else {
assert_eq!(id.as_ref(), "artcraft-launcher");
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+33 -53
View File
@@ -1,4 +1,4 @@
//! The Craft App Suite: the twelve apps the launcher knows about.
//! The Craft App Suite: the thirteen apps the launcher knows about.
//!
//! The list is data so a later version can fetch it from an API; [`SUITE`] is the built-in copy.
//!
@@ -10,9 +10,9 @@ use serde::{Deserialize, Serialize};
/// Which generation of the suite an app belongs to.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum AppGroup {
/// The seven original apps, each with a page on getartcraft.com.
/// The seven original apps.
Classic,
/// The five apps added after them.
/// The six apps added after them.
New,
}
@@ -34,8 +34,6 @@ pub struct CraftApp {
pub name: &'static str,
/// What it's for, in a few words (English; the UI translates it by app id).
pub tagline: &'static str,
/// The familiar app it reimplements, for orientation.
pub like: &'static str,
pub group: AppGroup,
/// Brand colour (sRGB), for surfaces: getartcraft.com's `--app`.
pub color: [u8; 3],
@@ -47,14 +45,11 @@ pub struct CraftApp {
pub repo: &'static str,
/// Older names its release assets may carry (PdfCraft shipped as PrintCraft until 0.2.1).
pub former_ids: &'static [&'static str],
/// Older display names its installers registered (PdfCraft's MSI said PrintCraft until 0.2.1).
pub former_names: &'static [&'static str],
}
impl CraftApp {
/// The product it's inspired by, with its registered-trademark sign (`Adobe Photoshop®`).
pub fn inspired_by(&self) -> String {
format!("{}®", self.like)
}
/// `ai.storyteller.<id>`: macOS bundle id, Flatpak id and Linux desktop-file id.
pub fn bundle_id(&self) -> String {
format!("ai.storyteller.{}", self.id)
@@ -68,9 +63,9 @@ impl CraftApp {
format!("https://github.com/{}", self.repo)
}
/// The app's page on getartcraft.com (classic apps only).
pub fn website(&self) -> Option<String> {
(self.group == AppGroup::Classic).then(|| format!("https://getartcraft.com/apps/{}", self.id))
/// The app's page on getartcraft.com.
pub fn website(&self) -> String {
format!("https://getartcraft.com/apps/{}", self.id)
}
/// Asset stems this app's releases may use, current first.
@@ -78,6 +73,11 @@ impl CraftApp {
std::iter::once(self.id).chain(self.former_ids.iter().copied())
}
/// Display names this app's installers may have registered, current first.
pub fn names(&self) -> impl Iterator<Item = &'static str> + '_ {
std::iter::once(self.name).chain(self.former_names.iter().copied())
}
pub fn by_id(id: &str) -> Option<&'static CraftApp> {
SUITE.iter().find(|a| a.id == id)
}
@@ -87,21 +87,19 @@ const fn app(
id: &'static str,
name: &'static str,
tagline: &'static str,
like: &'static str,
group: AppGroup,
[color, ink_light, ink_dark]: [[u8; 3]; 3],
repo: &'static str,
) -> CraftApp {
CraftApp { id, name, tagline, like, group, color, ink_light, ink_dark, repo, former_ids: &[] }
CraftApp { id, name, tagline, group, color, ink_light, ink_dark, repo, former_ids: &[], former_names: &[] }
}
/// The Craft App Suite, in display order: the seven classic apps, then the five new ones.
pub const SUITE: [CraftApp; 12] = [
/// The Craft App Suite, in display order: the seven classic apps, then the six new ones.
pub const SUITE: [CraftApp; 13] = [
app(
"photocraft",
"PhotoCraft",
"Image editing",
"Adobe Photoshop",
AppGroup::Classic,
[[0x2f, 0x7b, 0xf5], [0x1a, 0x5b, 0xc4], [0x7f, 0xb0, 0xff]],
"storytold/photocraft",
@@ -110,7 +108,6 @@ pub const SUITE: [CraftApp; 12] = [
"vectorcraft",
"VectorCraft",
"Vector illustration",
"Adobe Illustrator",
AppGroup::Classic,
[[0xe8, 0x57, 0x3f], [0xb8, 0x3a, 0x24], [0xff, 0x8a, 0x70]],
"storytold/vectorcraft",
@@ -119,7 +116,6 @@ pub const SUITE: [CraftApp; 12] = [
"filmcraft",
"FilmCraft",
"Video editing, color and sound",
"Adobe Premiere Pro",
AppGroup::Classic,
[[0x8b, 0x5c, 0xf6], [0x6a, 0x3f, 0xd6], [0xb6, 0x9c, 0xff]],
"storytold/filmcraft",
@@ -128,18 +124,17 @@ pub const SUITE: [CraftApp; 12] = [
"lightcraft",
"LightCraft",
"Photo library and raw development",
"Adobe Lightroom",
AppGroup::Classic,
[[0xf2, 0xa5, 0x16], [0x8a, 0x58, 0x00], [0xff, 0xc1, 0x4d]],
"storytold/lightcraft",
),
CraftApp {
former_ids: &["printcraft"],
former_names: &["PrintCraft"],
..app(
"pdfcraft",
"PdfCraft",
"Reading, organizing and protecting PDFs",
"Adobe Acrobat",
AppGroup::Classic,
[[0x12, 0xa5, 0x8a], [0x0a, 0x75, 0x63], [0x3f, 0xd6, 0xb8]],
"storytold/pdfcraft",
@@ -149,7 +144,6 @@ pub const SUITE: [CraftApp; 12] = [
"effectcraft",
"EffectCraft",
"Motion graphics and visual effects",
"Adobe After Effects",
AppGroup::Classic,
[[0xe0, 0x36, 0x8f], [0xb0, 0x20, 0x6c], [0xff, 0x7a, 0xb8]],
"storytold/effectcraft",
@@ -158,7 +152,6 @@ pub const SUITE: [CraftApp; 12] = [
"designcraft",
"DesignCraft",
"Page layout and publishing",
"Adobe InDesign",
AppGroup::Classic,
[[0x7b, 0xb5, 0x1c], [0x45, 0x70, 0x08], [0xa6, 0xe0, 0x4a]],
"storytold/designcraft",
@@ -167,7 +160,6 @@ pub const SUITE: [CraftApp; 12] = [
"wordcraft",
"WordCraft",
"Writing and document design",
"Microsoft Word",
AppGroup::New,
[[0x3f, 0x5f, 0xe0], [0x2a, 0x44, 0xb8], [0x8e, 0xa2, 0xff]],
"storytold/wordcraft",
@@ -176,7 +168,6 @@ pub const SUITE: [CraftApp; 12] = [
"deckcraft",
"DeckCraft",
"Presentations and slide shows",
"Microsoft PowerPoint",
AppGroup::New,
[[0xf0, 0x7a, 0x1e], [0xa8, 0x4f, 0x0a], [0xff, 0xa0, 0x60]],
"storytold/deckcraft",
@@ -185,7 +176,6 @@ pub const SUITE: [CraftApp; 12] = [
"gridcraft",
"GridCraft",
"Spreadsheets and data",
"Microsoft Excel",
AppGroup::New,
[[0x1f, 0x9d, 0x55], [0x12, 0x70, 0x3a], [0x4f, 0xd6, 0x8a]],
"storytold/gridcraft",
@@ -194,7 +184,6 @@ pub const SUITE: [CraftApp; 12] = [
"cadcraft",
"CADCraft",
"Computer-aided design and drafting",
"Autodesk AutoCAD",
AppGroup::New,
[[0x3e, 0xa6, 0xe8], [0x1a, 0x6f, 0xa8], [0x7c, 0xc8, 0xff]],
"storytold/cadcraft",
@@ -203,23 +192,25 @@ pub const SUITE: [CraftApp; 12] = [
"soundcraft",
"SoundCraft",
"Recording, editing and mixing audio",
"Avid Pro Tools",
AppGroup::New,
[[0x1f, 0xa5, 0xc9], [0x0e, 0x72, 0x90], [0x5f, 0xd0, 0xee]],
"storytold/soundcraft",
),
app(
"figcraft",
"FigCraft",
"Interface design and prototyping",
AppGroup::New,
[[0x02, 0xda, 0x96], [0x04, 0x7a, 0x56], [0x4f, 0xe8, 0xb6]],
"storytold/figcraft",
),
];
/// Shown wherever the products the apps are inspired by are named.
pub const TRADEMARK_NOTICE: &str = "Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are either \
registered trademarks or trademarks of Adobe in the United States and/or other countries. Microsoft, Word, PowerPoint and Excel are \
trademarks of the Microsoft group of companies. Autodesk and AutoCAD are registered trademarks or trademarks of Autodesk, Inc. Avid \
and Pro Tools are trademarks or registered trademarks of Avid Technology, Inc. All other trademarks are the property of their \
respective owners. These names are used only to describe what each Craft App is for; the Craft App Suite is independent and is not \
affiliated with, sponsored by or endorsed by any of these companies.";
/// ArtCraft's website.
pub const WEBSITE_URL: &str = "https://getartcraft.com";
/// The GitHub account every Craft App's repository lives under: the only one the launcher
/// installs from, whatever the release feed says.
pub const GITHUB_OWNER: &str = "storytold";
/// ArtCraft on GitHub: every Craft App's source.
pub const GITHUB_URL: &str = "https://github.com/storytold";
/// ArtCraft's social accounts: (service, URL). Shown as small icons, less prominent than Discord.
@@ -241,7 +232,6 @@ pub const LAUNCHER: CraftApp = app(
"artcraft-launcher",
"ArtCraft Launcher",
"Installs and updates the Craft App Suite",
"",
AppGroup::New,
[[0x0b, 0x0b, 0x0c], [0x0b, 0x0b, 0x0c], [0xf2, 0xf1, 0xee]],
"storytold/craft-launcher",
@@ -257,9 +247,9 @@ mod tests {
use super::*;
#[test]
fn suite_has_seven_classic_and_five_new_apps_with_unique_ids() {
fn suite_has_seven_classic_and_six_new_apps_with_unique_ids() {
assert_eq!(SUITE.iter().filter(|a| a.group == AppGroup::Classic).count(), 7);
assert_eq!(SUITE.iter().filter(|a| a.group == AppGroup::New).count(), 5);
assert_eq!(SUITE.iter().filter(|a| a.group == AppGroup::New).count(), 6);
let mut ids: Vec<_> = SUITE.iter().map(|a| a.id).collect();
ids.sort_unstable();
ids.dedup();
@@ -270,22 +260,12 @@ mod tests {
}
}
#[test]
fn every_inspiring_product_is_covered_by_the_trademark_notice() {
for a in &SUITE {
assert!(a.inspired_by().ends_with('®'));
// The company and the product name both appear in the notice.
let (company, product) = a.like.split_once(' ').unwrap();
assert!(TRADEMARK_NOTICE.contains(company), "{company}");
assert!(TRADEMARK_NOTICE.contains(product), "{product}");
}
}
#[test]
fn pdfcraft_also_answers_to_printcraft() {
let pdf = CraftApp::by_id("pdfcraft").unwrap();
assert_eq!(pdf.stems().collect::<Vec<_>>(), ["pdfcraft", "printcraft"]);
assert_eq!(pdf.website().as_deref(), Some("https://getartcraft.com/apps/pdfcraft"));
assert_eq!(CraftApp::by_id("wordcraft").unwrap().website(), None);
assert_eq!(pdf.names().collect::<Vec<_>>(), ["PdfCraft", "PrintCraft"]);
assert_eq!(pdf.website(), "https://getartcraft.com/apps/pdfcraft");
assert_eq!(CraftApp::by_id("wordcraft").unwrap().website(), "https://getartcraft.com/apps/wordcraft");
}
}
+483
View File
@@ -0,0 +1,483 @@
//! Hand-written product pages: what each app is, in the launcher's own words.
//!
//! The text is compiled in, so it works offline and on the web, and nothing from the network is
//! ever shown as page copy. What changes with every release (version, date, size, notes) comes
//! from the GitHub release instead. An app without an entry here simply has no details page.
//!
//! The pictures are not compiled in: they are fetched when a page is opened (see
//! [`AppDetails::screenshot_url`]), so the repository and the binary stay small.
/// One feature: a short name and a sentence.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Feature {
pub title: &'static str,
pub text: &'static str,
}
/// A screenshot: `key` names the file to fetch (`<app>/<key>.jpg` in the pictures repository).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Shot {
pub key: &'static str,
pub caption: &'static str,
}
const fn shot(key: &'static str, caption: &'static str) -> Shot {
Shot { key, caption }
}
/// The editorial half of an app's page.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct AppDetails {
/// Matches [`crate::CraftApp::id`].
pub id: &'static str,
/// One line under the name.
pub headline: &'static str,
/// One or two paragraphs, separated by a blank line.
pub about: &'static str,
pub features: &'static [Feature],
/// The first is the lead picture. All are 8:5.
pub shots: &'static [Shot],
/// Where it runs, for display (`"macOS, Windows, Linux, web"`).
pub platforms: &'static str,
}
const fn feature(title: &'static str, text: &'static str) -> Feature {
Feature { title, text }
}
/// Where the pictures live: one 1200 px wide, 8:5 JPEG per [`Shot`], at `<app>/<key>.jpg`.
///
/// Pinned to a tag (`SCREENSHOTS_REV`) so that what a launcher fetches never changes under it, and
/// so that a saved copy never goes stale. To change a picture, publish a new tag and bump the
/// revision: older launchers keep working from the tag they know.
pub const SCREENSHOTS_BASE: &str = "https://raw.githubusercontent.com/storytold/craft-launcher-assets";
/// The tag of the pictures repository the launcher reads. Also part of the saved copies' path.
pub const SCREENSHOTS_REV: &str = "v1";
/// The largest picture the launcher accepts. The real ones are 60 to 150 KB.
pub const MAX_SCREENSHOT_BYTES: usize = 2 << 20;
/// True when `bytes` start like a JPEG file. A captive portal's login page, a proxy's error page or
/// a truncated answer is refused before it is saved or decoded.
pub fn looks_like_jpeg(bytes: &[u8]) -> bool {
bytes.starts_with(&[0xFF, 0xD8, 0xFF])
}
/// Where every Craft App runs (getartcraft.com lists the same for each of them).
const PLATFORMS: &str = "macOS, Windows, Linux, FreeBSD, web";
pub const DETAILS: [AppDetails; 13] = [
AppDetails {
id: "photocraft",
headline: "The image editor you already know how to use.",
about: "Layers, masks, adjustment layers, layer styles, type, vectors and brushes in a native app \
written in Rust. Open source, offline, and yours.\n\n\
PhotoCraft keeps the menus, shortcuts and panels your hands already know, so there is \
nothing to relearn.",
features: &[
feature("Native GPU canvas", "No Electron and no web view: a fast native compositor."),
feature("Real layered files", "Opens and saves layered PSD and PSB documents."),
feature("Non-destructive editing", "Adjustment layers, live-preview filters, smart objects and layer styles."),
feature("On-device selection", "Subject and object selection and mask refinement run locally."),
feature("Serious color", "8-, 16- and 32-bit RGB, CMYK, Lab and Grayscale, with ICC color management and soft proofing."),
],
shots: &[
shot("demo", "Curves and type layers"),
shot("adjustments", "Levels with a live histogram"),
shot("layer-styles", "Layer styles on live type"),
shot("masks", "Selections and masked adjustments"),
],
platforms: PLATFORMS,
},
AppDetails {
id: "vectorcraft",
headline: "Vector illustration, reimagined in pure Rust.",
about: "A native vector illustration app with the pen, panels and shortcuts illustrators expect. \
Open source, and it also runs in a browser.",
features: &[
feature(
"The workflow you know",
"Pen, direct selection, shape booleans, snapping guides and an appearance panel, laid out the way you expect.",
),
feature("Genuinely fast", "Drawings with thousands of shapes stay responsive on a retina display."),
feature(
"Booleans that don't fail",
"Shape booleans work on the exact curves, so there is no \"cannot perform operation\", and undo is unlimited.",
),
feature("Live, editable effects", "Blends, envelope distort, gradient mesh, repeats, glows and drop shadows all stay editable."),
feature("Image tracing", "Turn raster images into clean vector paths, with presets for the common cases."),
feature("Open formats", "Native JSON documents, SVG and PDF import and export, and PNG, JPEG and WebP output."),
],
shots: &[
shot("neon", "The Neon Drive poster"),
shot("ribbons", "Live blends"),
shot("sheet", "A feature sheet and the Layers panel"),
shot("bezier", "Pathfinder and exact booleans"),
],
platforms: PLATFORMS,
},
AppDetails {
id: "filmcraft",
headline: "Professional video editing, rebuilt from scratch.",
about: "A native video editor written in pure Rust, with source and program monitors, a real timeline and \
every trim mode.",
features: &[
feature("A timeline you already know", "Source and program monitors, three-point editing, every trim mode and J/K/L trimming."),
feature("Frame-exact time", "An integer timebase keeps 23.976 and 29.97 drop-frame footage exact, with no drift."),
feature("Its own codecs", "No FFmpeg inside: native support for H.264, HEVC, ProRes, VP9, AAC and Opus."),
feature("Color grading", "Curves, color wheels, HSL secondaries, LUTs and HDR and log workflows, with scopes to check the result."),
feature(
"Effects and keyframes",
"Video effects and transitions, keyframes with value and velocity graphs, and a linear-light GPU compositor.",
),
feature("Broadcast-ready audio", "EBU R128 loudness meters, a mixer with automation and native audio effects."),
],
shots: &[
shot("color", "The Color workspace, with scopes"),
shot("assembly", "The Assembly workspace"),
shot("keyframes", "Keyframes in Effect Controls"),
],
platforms: PLATFORMS,
},
AppDetails {
id: "lightcraft",
headline: "Your photos. Your pixels. Your machine.",
about: "A fast photo library and non-destructive raw developer, written from scratch in pure Rust. \
No account, no cloud, no telemetry and no subscription.",
features: &[
feature("Non-destructive by design", "A wide-gamut, 32-bit float pipeline that never touches your originals."),
feature("Every control you reach for", "Light, color, effects, tone curve, a color mixer, color grading wheels and built-in presets."),
feature("Precise masking", "Brush, gradient and range masks, plus sky, subject and background masks."),
feature("Native raw decoding", "Pure-Rust decoders for the main camera raw formats: DNG, CR2, ARW, NEF, RAF, RW2, PEF and ORF."),
feature("A library that keeps up", "Albums, ratings, flags and search, with grids that stay smooth on large catalogs."),
feature("Instant feedback", "The GPU pipeline updates the preview as you move a slider, even on large raw files."),
],
shots: &[
shot("hero", "The Edit view"),
shot("curve", "The tone curve"),
shot("grading", "Color grading wheels"),
shot("masking", "A radial mask over a sky mask"),
],
platforms: PLATFORMS,
},
AppDetails {
id: "pdfcraft",
headline: "The open-source PDF workbench.",
about: "Read, organize, combine, split and secure PDFs in a fast, native app written in Rust from the \
ground up. No account, no telemetry and no cloud.",
features: &[
feature("Faithful rendering", "World scripts, vertical Japanese, color emoji and transparency."),
feature("Search as you type", "Instant find, and text selection that follows the document's real reading order."),
feature("Organize like cards", "Rotate, delete, insert and reorder pages, with deep undo behind every change."),
feature("Combine, extract, split", "Assemble and break apart documents while keeping links, form fields, layers and bookmarks intact."),
feature("Fearless saves", "Incremental, atomic saves with autosave and crash recovery."),
feature("Built-in security", "Opens files from RC4 to AES-256 encryption and honors the author's permissions."),
],
shots: &[],
platforms: PLATFORMS,
},
AppDetails {
id: "effectcraft",
headline: "Motion graphics and visual effects, in pure Rust.",
about: "A native compositor with compositions, layers, keyframes, effects, expressions, 3D cameras and \
lights, and a render queue. Young, moving fast, and already usable.",
features: &[
feature(
"Animate the way you know",
"Familiar panels and keyframes (linear, bezier, hold and eased), with a graph editor for value and speed curves.",
),
feature(
"Layers of every kind",
"Solids, shapes, text, footage, nested compositions, nulls and adjustment layers, with parenting, track mattes and blend modes.",
),
feature(
"A large effects library",
"Blur, color correction, distortion, generators, keying, particles and simulation, stylize and time effects.",
),
feature("Real 3D", "3D layers with cameras, depth of field, and lights that cast soft ray-traced shadows."),
feature("Expressions", "JavaScript expressions with wiggle, loops and layer references, edited inline."),
feature("Export anywhere", "H.264 and ProRes with alpha, PNG, TIFF and EXR sequences, GIF, and Lottie import and export."),
],
shots: &[
shot("hero", "An animated composition"),
shot("graph", "The Graph Editor"),
shot("effects", "Particles and Effect Controls"),
shot("3d", "3D layers with cameras and lights"),
],
platforms: PLATFORMS,
},
AppDetails {
id: "designcraft",
headline: "Page layout and publishing, rebuilt in pure Rust.",
about: "A native layout app with spreads, parent pages, threaded stories, styles, swatches, text wrap and \
a paragraph composer. No subscription, no license server and no telemetry.",
features: &[
feature("The layout tools you know", "Spreads and parent pages, frames and threaded stories, with paragraph and character styles."),
feature(
"Beautiful type",
"A Knuth–Plass paragraph composer, dictionary hyphenation, optical margin alignment and baseline grids, with line breaks that match on screen and in PDF.",
),
feature("Print-ready PDF", "Real selectable text with embedded fonts, CMYK and spot colors, bleed, crop marks and PDF/A output."),
feature("Fast", "Multithreaded rendering, instant undo and cached text composition."),
feature("Open formats", "A documented native format, PNG export and layout interchange import and export."),
feature("Built for automation", "Every menu item, tool, panel and dialog can be driven over a JSON control channel and an MCP server."),
],
shots: &[shot("spread", "A magazine spread with a threaded story")],
platforms: PLATFORMS,
},
AppDetails {
id: "wordcraft",
headline: "The familiar word processor, native and offline.",
about: "A word processor with the ribbon, shortcuts and behavior you already know, written in Rust. \
Open source, and built from public specifications.",
features: &[
feature(
"Familiar",
"Ribbon tabs and groups, a live Styles gallery and the editing behavior you expect, from list continuation to Format Painter.",
),
feature(
"Your files",
"Opens and saves .docx, and also .odt, .rtf, .html, .md, .tex and .txt. Opens older .doc files and exports PDF with real, selectable text.",
),
feature("Fast", "Paragraph layout is cached, so typing stays responsive even in long documents."),
feature("Private", "Spelling, grammar and everything else work offline."),
feature("Everywhere", "One Rust codebase for macOS, Windows, Linux, BSD and the web."),
feature(
"Built for agents",
"Every action is a command, driven from the ribbon, shortcuts, a command-line tool, a JSON control channel or an MCP server.",
),
],
shots: &[
shot("hero", "The Home tab and the Navigation pane"),
shot("review", "Track Changes and comments"),
shot("design", "The Design tab"),
shot("layout", "The Layout tab"),
],
platforms: "macOS, Windows, Linux, BSD, web",
},
AppDetails {
id: "deckcraft",
headline: "Presentations, rebuilt in Rust.",
about: "A presentation app with slide masters, shapes, animations and a presenter view, written in Rust. \
In alpha, heading for beta.",
features: &[
feature("Make decks", "Slide masters and layouts with placeholders that inherit formatting, themes, sections, notes and comments."),
feature(
"Draw",
"Preset shapes with adjust handles, connectors that stay glued to shapes, freeform tools, merged shapes, gradients, shadows and glow.",
),
feature("Write", "In-place text editing with full font and paragraph formatting, bullets and numbering, columns and autofit."),
feature("Add content", "Pictures with crop and corrections, styled tables, charts, audio and video."),
feature(
"Present",
"Transitions including Morph, animations and motion paths, a full-screen slide show, presenter view and rehearsed timings.",
),
feature("Exchange files", "Opens and saves PowerPoint .pptx and exports PDF (slides, notes pages and handouts), PNG and JPEG."),
],
shots: &[
shot("editor", "Editing a slide, with sections and notes"),
shot("shape", "The Shape Format tab"),
shot("sorter", "The slide sorter"),
shot("format", "The Format Shape pane"),
],
platforms: "macOS, Windows, Linux, BSD, web",
},
AppDetails {
id: "gridcraft",
headline: "Spreadsheets you already know how to use.",
about: "A spreadsheet with the ribbon, formula bar, sheet tabs and shortcuts you know, on a calculation engine \
written in Rust. No subscription, no license server and no telemetry.",
features: &[
feature(
"Familiar",
"The ribbon, formula bar, Name Box, fill handle, point-mode formulas, Format Cells, Paste Special, conditional formatting, tables and charts.",
),
feature(
"Compatible",
"XLSX is the native format, with formulas, tables, conditional formats, charts, pivot tables and print settings round-tripping. CSV and TSV too.",
),
feature(
"Capable",
"A dependency-graph calculation engine with dynamic arrays, LET and LAMBDA, structured table references, sort, AutoFilter and Flash Fill.",
),
feature("Fast", "Copy-on-write workbooks, incremental recalculation and a grid that only lays out what is on screen."),
feature(
"Agent-native",
"Every ribbon button, menu item, dialog and keystroke is a command, available over a JSON control channel, an MCP server or the CLI.",
),
feature("Everywhere", "One Rust codebase for the desktop and the web."),
],
shots: &[
shot("hero", "A quarterly sales dashboard"),
shot("budget", "A household budget"),
shot("pivot", "A pivot table"),
shot("chart", "Formatting a chart"),
],
platforms: "macOS, Windows, Linux, BSD, web",
},
AppDetails {
id: "cadcraft",
headline: "Computer-aided drafting, in pure Rust.",
about: "A drafting app with the command line, object snaps and workflow that drafters already know. Free, with no \
account and no subscription. In early, fast development.",
features: &[
feature(
"The workflow you know",
"Type L, click two points, type @5<45 and press Enter. Prompts with clickable keywords, polar tracking, direct distance entry and grips work the way drafting habit expects.",
),
feature(
"Open files",
"Reads and writes DXF natively (R12 through 2018) and opens and saves DWG (R13 through 2018) with the open-source acadrust library. Exports SVG and PNG.",
),
feature("Fast and native", "Pure Rust and egui, with no Electron and no web view."),
feature(
"A full drafting toolset",
"Draw, modify, dimension, hatch and block commands, layers with a properties manager, paper-space layouts and PDF plotting.",
),
feature("Built for agents", "Every menu item, tool and prompt is a command, available over MCP, a JSON control channel or the CLI."),
],
shots: &[shot("apartment", "An apartment floor plan"), shot("bracket", "A mechanical bracket drawing"), shot("layout", "A layout sheet")],
platforms: PLATFORMS,
},
AppDetails {
id: "soundcraft",
headline: "Recording, editing and mixing, in Rust.",
about: "A digital audio workstation that follows the workflow audio engineers already know: an Edit window for \
arranging and editing, and a Mix window for balancing. Pre-alpha.",
features: &[
feature(
"Editing",
"Shuffle, Slip, Spot and Grid modes, Smart, Grabber, Trim and other tools, fades and crossfades, playlists with comping lanes and clip gain.",
),
feature(
"Mixing",
"Faders and pans with automation, sends, busses, aux inputs, VCA masters, routing folders and automatic plugin delay compensation.",
),
feature(
"Automation",
"Volume, pan, mute, send and every plugin parameter, with Write, Touch, Latch and Trim passes recorded live from the faders.",
),
feature(
"Plugins",
"EQ, dynamics, reverbs, modulation effects, a subtractive synth and a drum synth, all original, and CLAP plugins load too.",
),
],
shots: &[
shot("edit", "The Edit window with a nine-track session"),
shot("mix", "The Mix window"),
shot("automation", "Automation and memory locations"),
shot("score", "The Score Editor"),
],
platforms: PLATFORMS,
},
AppDetails {
id: "figcraft",
headline: "Interface design and prototyping, in pure Rust.",
about: "Design screens, components and prototypes in a native app that opens instantly and works offline, \
with no account and no telemetry. Early alpha.",
features: &[
feature(
"Auto layout",
"Rows, columns, wrap and grids with padding, gaps and Hug, Fill or Fixed sizing, plus constraints and layout grids.",
),
feature(
"Components",
"Instances with overrides, variants and properties, styles, and variables with modes bound to fills, strokes and effects.",
),
feature("Vectors", "Real vector networks, the pen and pencil, the shape builder, editable booleans and variable width strokes."),
feature(
"Prototypes",
"Connections, triggers and animated transitions, variables with conditionals, flows and a presentation view with device frames.",
),
feature(
"Hand off",
"Inspect mode with measurements, annotations, ready-for-dev status, and code for CSS, HTML, React, SwiftUI and Compose.",
),
feature(
"Your files",
"Plain .figcraft files on your own disk. SVG, PNG, JPEG, WebP and PDF in and out, and .fig files open, best effort.",
),
],
shots: &[],
platforms: PLATFORMS,
},
];
impl AppDetails {
/// Where the picture `key` of this app is published, or `None` if the page has no such picture.
/// The only way to turn a request into an address: callers never build one from their own text.
pub fn screenshot_url(&self, key: &str) -> Option<String> {
self.shots.iter().find(|s| s.key == key).map(|s| format!("{SCREENSHOTS_BASE}/{SCREENSHOTS_REV}/{}/{}.jpg", self.id, s.key))
}
pub fn by_id(id: &str) -> Option<&'static AppDetails> {
DETAILS.iter().find(|d| d.id == id)
}
pub fn paragraphs(&self) -> impl Iterator<Item = &'static str> {
self.about.split("\n\n").map(str::trim).filter(|p| !p.is_empty())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{CraftApp, SUITE};
#[test]
fn every_page_belongs_to_a_suite_app_once() {
let mut ids: Vec<_> = DETAILS.iter().map(|d| d.id).collect();
for id in &ids {
assert!(CraftApp::by_id(id).is_some(), "{id}");
}
ids.sort_unstable();
ids.dedup();
assert_eq!(ids.len(), DETAILS.len());
}
#[test]
fn every_app_has_a_page() {
for a in &SUITE {
assert!(AppDetails::by_id(a.id).is_some(), "{}", a.id);
}
}
#[test]
fn picture_addresses_come_only_from_the_pages_own_pictures() {
let page = AppDetails::by_id("photocraft").unwrap();
assert_eq!(
page.screenshot_url("demo").as_deref(),
Some("https://raw.githubusercontent.com/storytold/craft-launcher-assets/v1/photocraft/demo.jpg")
);
for key in ["", "nope", "../vectorcraft/neon", "demo/../masks", "demo.jpg", "/etc/passwd", "demo?x=1", "demo#"] {
assert_eq!(page.screenshot_url(key), None, "{key:?}");
}
// Every picture of every page has an address of the one shape, with nothing to escape.
for d in &DETAILS {
for s in d.shots {
let url = d.screenshot_url(s.key).unwrap();
let path = url.strip_prefix(&format!("{SCREENSHOTS_BASE}/{SCREENSHOTS_REV}/")).unwrap();
assert_eq!(path, format!("{}/{}.jpg", d.id, s.key));
assert!(path.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '/' | '-' | '.')), "{path}");
}
}
}
#[test]
fn only_jpeg_files_are_accepted() {
assert!(looks_like_jpeg(&[0xFF, 0xD8, 0xFF, 0xE0, 0, 0x10]));
assert!(!looks_like_jpeg(b""));
assert!(!looks_like_jpeg(&[0xFF, 0xD8]));
assert!(!looks_like_jpeg(b"<!DOCTYPE html>"));
// A PNG's signature.
assert!(!looks_like_jpeg(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]));
}
#[test]
fn pages_are_filled_in() {
for d in &DETAILS {
assert!(!d.headline.is_empty() && !d.features.is_empty() && d.paragraphs().next().is_some(), "{}", d.id);
}
}
}
+36 -10
View File
@@ -17,7 +17,7 @@ use std::collections::BTreeMap;
use serde::Deserialize;
use serde::de::{DeserializeOwned, Deserializer};
use crate::catalog::CraftApp;
use crate::catalog::{CraftApp, GITHUB_OWNER};
use crate::release::{self, Asset, Release};
use crate::time::parse_rfc3339;
use crate::version;
@@ -36,6 +36,7 @@ pub struct Feed {
/// When the least recently fetched app was fetched (Unix seconds).
pub last_fetched: Option<i64>,
pub app_renames: Vec<AppRename>,
/// Repositories that moved, both under [`GITHUB_OWNER`]: a rename anywhere else is dropped.
pub repo_renames: Vec<RepoRename>,
pub announcements: Vec<Notice>,
pub alerts: Vec<Notice>,
@@ -140,7 +141,8 @@ impl Feed {
self
}
/// The repo to ask GitHub about for `app`, following the feed's repo renames.
/// The repo to ask GitHub about for `app`, following the feed's repo renames. Always under
/// [`GITHUB_OWNER`]: the feed can move an app within ArtCraft's account, never out of it.
pub fn repo_for(&self, app: &CraftApp) -> String {
let mut repo = app.repo.to_string();
// Follow a chain of moves (a → b → c), without looping forever.
@@ -364,10 +366,12 @@ struct RawRepoRename {
}
impl RawRepoRename {
/// `None` unless both names are `owner/name` under [`GITHUB_OWNER`]. A feed that could send
/// an app's downloads to someone else's repository would make its checksums worthless (#90).
fn into_rename(self) -> Option<RepoRename> {
let ok = |r: &str| {
let mut parts = r.split('/');
matches!((parts.next(), parts.next(), parts.next()), (Some(a), Some(b), None) if !a.is_empty() && !b.is_empty()
matches!((parts.next(), parts.next(), parts.next()), (Some(a), Some(b), None) if a.eq_ignore_ascii_case(GITHUB_OWNER) && !b.is_empty()
&& r.bytes().all(|c| c.is_ascii_alphanumeric() || b"-_./".contains(&c)))
};
(ok(&self.old_name) && ok(&self.new_name)).then(|| RepoRename {
@@ -524,17 +528,39 @@ mod tests {
let moved = sample()
.replace(
"\"repo_renames\": []",
r#""repo_renames": [{"old_name": "storytold/photocraft", "new_name": "art/photocraft", "renamed_at": null}]"#,
r#""repo_renames": [{"old_name": "Storytold/photocraft", "new_name": "storytold/photocraft-app", "renamed_at": null}]"#,
)
.replace(
"\"name\": \"PhotoCraft\", \"repo\": \"storytold/photocraft\"",
"\"name\": \"PhotoCraft\", \"repo\": \"storytold/photocraft-app\"",
)
.replace("github.com/storytold/photocraft/releases", "github.com/storytold/photocraft-app/releases");
let feed = Feed::parse(&moved).unwrap();
assert_eq!(feed.repo_for(app("photocraft")), "storytold/photocraft-app");
let FeedAnswer::Fresh(Some(r)) = feed.answer(app("photocraft"), NOW) else { panic!() };
assert!(r.assets[0].url.starts_with("https://github.com/storytold/photocraft-app/releases/download/"));
// The same entry without the rename: the repo doesn't match, so GitHub decides.
let unannounced = moved.replace(r#"{"old_name": "Storytold/photocraft", "new_name": "storytold/photocraft-app", "renamed_at": null}"#, "");
let feed = Feed::parse(&unannounced).unwrap();
assert_eq!(feed.answer(app("photocraft"), NOW), FeedAnswer::AskGitHub);
}
#[test]
fn a_rename_out_of_storytold_is_dropped() {
// Even a feed that vouches for the new repository, with matching links and checksums,
// can't send an app's downloads to another account (#90).
let moved = sample()
.replace(
"\"repo_renames\": []",
r#""repo_renames": [{"old_name": "storytold/photocraft", "new_name": "art/photocraft", "renamed_at": null},
{"old_name": "art/photocraft", "new_name": "storytold/photocraft", "renamed_at": null},
{"old_name": "storytold/photocraft", "new_name": "storytold.evil.example/photocraft", "renamed_at": null}]"#,
)
.replace("\"name\": \"PhotoCraft\", \"repo\": \"storytold/photocraft\"", "\"name\": \"PhotoCraft\", \"repo\": \"art/photocraft\"")
.replace("github.com/storytold/photocraft/releases", "github.com/art/photocraft/releases");
let feed = Feed::parse(&moved).unwrap();
assert_eq!(feed.repo_for(app("photocraft")), "art/photocraft");
let FeedAnswer::Fresh(Some(r)) = feed.answer(app("photocraft"), NOW) else { panic!() };
assert!(r.assets[0].url.starts_with("https://github.com/art/photocraft/releases/download/"));
// The same entry without the rename: the repo doesn't match, so GitHub decides.
let unannounced = moved.replace(r#"{"old_name": "storytold/photocraft", "new_name": "art/photocraft", "renamed_at": null}"#, "");
let feed = Feed::parse(&unannounced).unwrap();
assert!(feed.repo_renames.is_empty(), "{:?}", feed.repo_renames);
assert_eq!(feed.repo_for(app("photocraft")), "storytold/photocraft");
assert_eq!(feed.answer(app("photocraft"), NOW), FeedAnswer::AskGitHub);
}
+1
View File
@@ -7,6 +7,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod catalog;
pub mod details;
pub mod distro;
pub mod feed;
pub mod platform;
+3
View File
@@ -263,6 +263,9 @@ mod tests {
{
assert_eq!(parse_asset_name(other, &["photocraft"]), None, "{other}");
}
// FigCraft also ships its collaboration server; that tarball is not the app.
assert_eq!(parse_asset_name("figcraft-server-0.5.1-linux-x86_64.tar.gz", &["figcraft"]), None);
assert!(parse_asset_name("figcraft-0.5.1-linux-x86_64.tar.gz", &["figcraft"]).is_some());
}
#[test]
+13
View File
@@ -43,6 +43,12 @@ pub enum Command {
},
/// Download the newer ArtCraft Launcher, put it in place and restart into it.
UpdateLauncher,
/// Fetch the pictures of an app's details page (`core::details`), one [`Event::Screenshot`]
/// each, the lead picture first. Pictures already saved on this machine arrive without
/// touching the network. Asking again while they are on their way does nothing.
FetchScreenshots {
app: String,
},
/// Change how the backend behaves.
Configure(BackendOptions),
}
@@ -187,6 +193,13 @@ pub enum Event {
app: String,
result: Result<String, String>,
},
/// One picture of an app's details page, or why it couldn't be had (offline, not published,
/// not a JPEG). `key` is the [`crate::details::Shot::key`].
Screenshot {
app: String,
key: String,
result: Result<Vec<u8>, String>,
},
CheckStarted,
CheckFinished {
/// Unix seconds.
+1 -4
View File
@@ -22,6 +22,7 @@ serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
dirs = "6"
tempfile = "3"
sha2 = "0.10"
# HTTPS over rustls, with the OS verifying certificates (rustls-platform-verifier). No bundled
# root list: webpki-roots and webpki-root-certs are CDLA-licensed.
@@ -36,7 +37,3 @@ plist = "1"
[target.'cfg(target_os = "windows")'.dependencies]
winreg = "0.55"
[dev-dependencies]
tempfile = "3"
+39 -8
View File
@@ -77,6 +77,8 @@ struct Shared {
feed_ok_at: Mutex<Option<i64>>,
/// How often ArtCraft's API may be called.
feed_throttle: Mutex<FeedThrottle>,
/// Apps whose details pictures are on their way (a second request meanwhile does nothing).
pictures_busy: Mutex<std::collections::HashSet<&'static str>>,
}
impl Shared {
@@ -131,6 +133,7 @@ impl NativeBackend {
feed_failures: Mutex::new(0),
feed_ok_at: Mutex::new(None),
feed_throttle: Mutex::new(FeedThrottle::load(&paths.feed_throttle(), crate::now())),
pictures_busy: Mutex::new(std::collections::HashSet::new()),
last_check: Mutex::new(None),
});
let backend = Self { shared, paths, http, source, platform: native_platform(), self_install: crate::self_update::current() };
@@ -339,7 +342,7 @@ impl NativeBackend {
shared.emit(Event::Progress { app: app.id.to_string(), phase: JobPhase::Removing });
let mut phase = |phase| shared.emit(Event::Progress { app: app.id.to_string(), phase });
match &existing {
Some(inst) => install::uninstall(app, inst, record.as_ref(), &stop, &mut phase).map(|()| {
Some(inst) => install::uninstall(app, inst, record.as_ref(), &paths, &stop, &mut phase).map(|()| {
let mut m = shared.manifest.lock().unwrap_or_else(PoisonError::into_inner);
if m.apps.remove(app.id).is_some() {
let _ = m.save(&paths.manifest());
@@ -476,7 +479,7 @@ fn run_install(
shared.emit(Event::Progress { app: app.id.to_string(), phase: p });
};
let mut job = Job { http, paths, stop, phase: &mut phase };
let (new_record, verified) = install::install(app, &release, &asset, kind, existing, record, &mut job)?;
let new_record = install::install(app, &release, &asset, kind, existing, record, &mut job)?;
{
let mut m = shared.manifest.lock().unwrap_or_else(PoisonError::into_inner);
let changed = match new_record {
@@ -491,11 +494,7 @@ fn run_install(
log::warn!("couldn't save the install record: {e}");
}
}
let note = match verified {
http::Verified::Checksum => "",
http::Verified::SizeOnly => " (this release publishes no checksum, so only its size was checked)",
};
Ok(format!("{}{note}", installed_text(app, existing.is_some(), &release)))
Ok(installed_text(app, existing.is_some(), &release))
}
/// What a finished install tells the user.
@@ -862,6 +861,36 @@ fn native_platform() -> Option<Platform> {
Some(p)
}
/// Clears an app's "pictures on their way" mark when the thread fetching them ends, panic or not.
struct PicturesBusy {
shared: Arc<Shared>,
app: &'static str,
}
impl Drop for PicturesBusy {
fn drop(&mut self) {
self.shared.pictures_busy.lock().unwrap_or_else(PoisonError::into_inner).remove(self.app);
}
}
impl NativeBackend {
/// Starts fetching the pictures of an app's details page, unless they are already on their way.
/// An app without a page, or a name that isn't an app, is ignored.
fn fetch_screenshots(&self, app: &str) {
let Some(details) = artcraft_launcher_core::details::AppDetails::by_id(app) else { return };
if !self.shared.pictures_busy.lock().unwrap_or_else(PoisonError::into_inner).insert(details.id) {
return;
}
let (shared, http, paths) = (self.shared.clone(), self.http.clone(), self.paths.clone());
std::thread::spawn(move || {
let _busy = PicturesBusy { shared: shared.clone(), app: details.id };
crate::screenshots::fetch_all(http.as_ref(), &paths, details, |key, result| {
shared.emit(Event::Screenshot { app: details.id.to_string(), key: key.to_string(), result });
});
});
}
}
impl Backend for NativeBackend {
fn send(&mut self, command: Command) {
match command {
@@ -893,7 +922,7 @@ impl Backend for NativeBackend {
Command::Launch { app } => {
let Some(def) = CraftApp::by_id(&app) else { return };
let result = match self.installed(&app) {
Some(inst) => install::launch(def, &inst),
Some(inst) => install::launch(def, &inst, &self.paths),
None => Err(format!("{} isn't installed", def.name)),
};
if let Err(e) = result {
@@ -910,6 +939,7 @@ impl Backend for NativeBackend {
}
}
Command::UpdateLauncher => self.update_launcher(),
Command::FetchScreenshots { app } => self.fetch_screenshots(&app),
Command::Configure(options) => {
let changed_pre = {
let mut o = self.shared.options.lock().unwrap_or_else(PoisonError::into_inner);
@@ -1010,6 +1040,7 @@ mod tests {
feed_failures: Mutex::new(0),
feed_ok_at: Mutex::new(None),
feed_throttle: Mutex::new(FeedThrottle::new()),
pictures_busy: Mutex::new(std::collections::HashSet::new()),
}
}
+1
View File
@@ -31,6 +31,7 @@ pub fn meta(app: &CraftApp) -> (&'static str, Option<&'static str>) {
"gridcraft" => ("Office;Spreadsheet;", None),
"cadcraft" => ("Graphics;Engineering;VectorGraphics;", None),
"soundcraft" => ("AudioVideo;Audio;Midi;Mixer;Sequencer;Recorder;", None),
"figcraft" => ("Graphics;2DGraphics;VectorGraphics;X-Design;", Some("figcraft")),
_ => ("Graphics;", None),
}
}
+182 -24
View File
@@ -8,6 +8,8 @@
//! installs.
//! - FreeBSD: tarball installs and `/usr/local/bin/<app>`.
use std::path::{Path, PathBuf};
use artcraft_launcher_core::{CraftApp, InstallSource, Installed, version};
use crate::manifest::Manifest;
@@ -86,6 +88,55 @@ pub(crate) fn appimage_version(name: &str, stems: &[&str]) -> Option<Option<Stri
(parsed.kind == artcraft_launcher_core::PackageKind::AppImage && parsed.token.starts_with("linux-")).then_some(Some(parsed.version))
}
/// Who our Windows Installer packages name as their publisher.
#[cfg_attr(not(windows), allow(dead_code))]
const PUBLISHER: &str = "Learning Machines LLC";
/// Whether an uninstall entry's `DisplayName` and `Publisher` are `app`'s, under any name its
/// installers have registered.
#[cfg_attr(not(windows), allow(dead_code))]
fn is_installer_entry(app: &CraftApp, display_name: &str, publisher: &str) -> bool {
publisher == PUBLISHER && app.names().any(|n| n == display_name)
}
/// Where a Windows install of `app` may keep its exe, in the order to try: the entry's
/// `InstallLocation`, then `<App>` under each Program Files root for each name the app has had,
/// and in each folder `<stem>.exe` for each stem, current first. Doesn't touch the disk.
#[cfg_attr(not(windows), allow(dead_code))]
fn exe_candidates(app: &CraftApp, location: Option<&Path>, program_files: &[PathBuf]) -> Vec<PathBuf> {
let in_program_files = program_files.iter().flat_map(|root| app.names().map(move |n| root.join(n)));
let dirs = location.map(Path::to_path_buf).into_iter().chain(in_program_files);
dirs.flat_map(|d| app.stems().map(move |s| d.join(format!("{s}.exe")))).collect()
}
/// Version from `<app> --version` output. Binaries print the machine id (`photocraft 0.7.0`),
/// the display name (`SoundCraft 0.5.0`), or a former id (`printcraft 0.2.1` for PdfCraft).
#[cfg_attr(not(any(target_os = "linux", target_os = "freebsd")), allow(dead_code))]
fn parse_cli_version(line: &str, app: &CraftApp) -> Option<version::Version> {
let line = line.trim();
for prefix in app.stems().chain(std::iter::once(app.name)) {
if let Some(rest) = strip_cli_prefix(line, prefix)
&& let Some(tok) = rest.split_whitespace().next()
&& let Some(v) = version::parse(tok)
{
return Some(v);
}
}
version::parse_package(line)
}
fn strip_cli_prefix<'a>(line: &'a str, prefix: &str) -> Option<&'a str> {
let (head, rest) = line.split_at_checked(prefix.len())?;
if !head.eq_ignore_ascii_case(prefix) {
return None;
}
match rest.chars().next() {
None => Some(rest),
Some(c) if c.is_ascii_whitespace() => Some(rest),
_ => None,
}
}
#[cfg(target_os = "macos")]
pub mod macos {
use std::path::{Path, PathBuf};
@@ -132,7 +183,6 @@ pub mod windows {
use super::*;
const UNINSTALL: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall";
const PUBLISHER: &str = "Learning Machines LLC";
/// An MSI install found in the registry.
pub struct MsiInstall {
@@ -150,7 +200,7 @@ pub mod windows {
let Ok(key) = root.open_subkey_with_flags(&code, KEY_READ | flags) else { continue };
let name: String = key.get_value("DisplayName").unwrap_or_default();
let publisher: String = key.get_value("Publisher").unwrap_or_default();
if name != app.name || publisher != PUBLISHER || out.iter().any(|m: &MsiInstall| m.product_code == code) {
if !is_installer_entry(app, &name, &publisher) || out.iter().any(|m: &MsiInstall| m.product_code == code) {
continue;
}
let version = key.get_value::<String, _>("DisplayVersion").ok().and_then(|v| version::parse(&v));
@@ -170,25 +220,26 @@ pub mod windows {
/// The app's exe: from `App Paths`, the install folder, or Program Files.
fn exe_path(app: &CraftApp, location: Option<PathBuf>) -> Option<PathBuf> {
let exe = format!("{}.exe", app.id);
let app_paths = format!(r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\{exe}");
for flags in [KEY_WOW64_64KEY, KEY_WOW64_32KEY] {
if let Ok(key) = RegKey::predef(HKEY_LOCAL_MACHINE).open_subkey_with_flags(&app_paths, KEY_READ | flags)
&& let Ok(path) = key.get_value::<String, _>("")
{
let p = PathBuf::from(path.trim_matches('"'));
if p.exists() {
return Some(p);
for stem in app.stems() {
let app_paths = format!(r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\{stem}.exe");
for flags in [KEY_WOW64_64KEY, KEY_WOW64_32KEY] {
if let Ok(key) = RegKey::predef(HKEY_LOCAL_MACHINE).open_subkey_with_flags(&app_paths, KEY_READ | flags)
&& let Ok(path) = key.get_value::<String, _>("")
{
let p = PathBuf::from(path.trim_matches('"'));
if p.exists() {
return Some(p);
}
}
}
}
let mut dirs: Vec<PathBuf> = location.into_iter().collect();
let mut roots = Vec::new();
for var in ["ProgramW6432", "ProgramFiles", "ProgramFiles(x86)"] {
if let Some(pf) = std::env::var_os(var) {
dirs.push(PathBuf::from(pf).join(app.name));
roots.push(PathBuf::from(pf));
}
}
dirs.into_iter().map(|d| d.join(&exe)).find(|p| p.exists())
exe_candidates(app, location.as_deref(), &roots).into_iter().find(|p| p.exists())
}
pub fn detect(app: &CraftApp) -> Option<Installed> {
@@ -232,7 +283,11 @@ pub mod linux {
if let Some(list) = capture("rpm", &["-qa", "--qf", "%{NAME}|%{VERSION}\n"], t) {
add(InstallSource::Rpm, packages::parse_rpm(&list));
}
if let Some(list) = capture("pacman", &["-Q"], t) {
// Only run `pacman` where its database exists: elsewhere `pacman` on PATH may be an unrelated program
// (the Pac-Man game in /usr/games on Debian-family systems).
if pacman_db_present(Path::new(PACMAN_DB))
&& let Some(list) = capture("pacman", &["-Q"], t)
{
add(InstallSource::Pacman, packages::parse_pacman(&list));
}
let arch = format!("--arch={}", std::env::consts::ARCH);
@@ -249,6 +304,14 @@ pub mod linux {
out
}
/// The local package database of Arch's pacman.
const PACMAN_DB: &str = "/var/lib/pacman/local";
/// True when `db` is a directory, i.e. this system really uses pacman.
pub(crate) fn pacman_db_present(db: &Path) -> bool {
db.is_dir()
}
/// Where a package put the app's binary: on `PATH`, else `/usr/bin/<id>`.
fn binary_on_path(id: &str) -> String {
std::env::var_os("PATH")
@@ -327,7 +390,7 @@ pub mod linux {
/// to whatever installed it, since it can't tell which package manager that was.
fn detect_system_install(app: &CraftApp) -> Option<Installed> {
let bin = find_binary(app.id)?;
Some(Installed { version: binary_version(&bin, app.id), source: InstallSource::Binary, location: bin.display().to_string(), package: None })
Some(Installed { version: binary_version(&bin, app), source: InstallSource::Binary, location: bin.display().to_string(), package: None })
}
fn find_binary(id: &str) -> Option<PathBuf> {
@@ -336,10 +399,9 @@ pub mod linux {
on_path.or_else(|| usual.map(|d| d.join(id)).find(|p| p.is_file()))
}
pub(crate) fn binary_version(bin: &Path, id: &str) -> Option<version::Version> {
pub(crate) fn binary_version(bin: &Path, app: &CraftApp) -> Option<version::Version> {
let out = crate::process::capture(bin.to_str()?, &["--version"], Duration::from_secs(5))?;
let rest = out.trim().strip_prefix(id)?.trim_start();
version::parse(rest.split_whitespace().next()?)
parse_cli_version(out.trim(), app)
}
}
@@ -355,14 +417,13 @@ pub mod freebsd {
if !bin.exists() {
return None;
}
Some(Installed { version: binary_version(&bin, app.id), source: InstallSource::Binary, location: bin.display().to_string(), package: None })
Some(Installed { version: binary_version(&bin, app), source: InstallSource::Binary, location: bin.display().to_string(), package: None })
}
/// `<app> --version` prints `<app> 0.5.0 (sha, date)`.
fn binary_version(bin: &Path, id: &str) -> Option<version::Version> {
fn binary_version(bin: &Path, app: &CraftApp) -> Option<version::Version> {
let out = crate::process::capture(bin.to_str()?, &["--version"], Duration::from_secs(5))?;
let rest = out.trim().strip_prefix(id)?.trim_start();
version::parse(rest.split_whitespace().next()?)
parse_cli_version(out.trim(), app)
}
}
@@ -384,6 +445,14 @@ mod linux_tests {
assert!(!ours("unrelated-package"));
}
#[test]
fn pacman_is_only_queried_where_its_database_exists() {
let root = tempfile::tempdir().unwrap();
assert!(!linux::pacman_db_present(&root.path().join("local")));
std::fs::create_dir(root.path().join("local")).unwrap();
assert!(linux::pacman_db_present(&root.path().join("local")));
}
#[test]
fn reads_binary_version_from_cli() {
let photo = CraftApp::by_id("photocraft").unwrap();
@@ -392,13 +461,31 @@ mod linux_tests {
std::fs::write(&bin, "#!/bin/sh\necho 'photocraft 0.6.0 (0c72d9542, 2026-10-10)'\n").unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
let ver = linux::binary_version(&bin, photo.id);
let ver = linux::binary_version(&bin, photo);
assert_eq!(ver.unwrap().to_string(), "0.6.0");
}
#[test]
fn reads_binary_version_when_the_cli_uses_the_display_name_or_a_former_id() {
let temp_dir = tempfile::tempdir().unwrap();
use std::os::unix::fs::PermissionsExt;
let write = |name: &str, script: &str| {
let bin = temp_dir.path().join(name);
std::fs::write(&bin, script).unwrap();
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
bin
};
let sound = write("soundcraft", "#!/bin/sh\necho 'SoundCraft 0.5.0'\n");
assert_eq!(linux::binary_version(&sound, CraftApp::by_id("soundcraft").unwrap()).unwrap().to_string(), "0.5.0");
let pdf = write("pdfcraft", "#!/bin/sh\necho 'printcraft 0.2.1'\n");
assert_eq!(linux::binary_version(&pdf, CraftApp::by_id("pdfcraft").unwrap()).unwrap().to_string(), "0.2.1");
}
}
#[cfg(test)]
mod tests {
use artcraft_launcher_core::SUITE;
use super::*;
#[test]
@@ -426,6 +513,18 @@ mod tests {
}
}
#[test]
fn parse_cli_version_accepts_id_display_name_and_former_id() {
let photo = CraftApp::by_id("photocraft").unwrap();
let sound = CraftApp::by_id("soundcraft").unwrap();
let pdf = CraftApp::by_id("pdfcraft").unwrap();
assert_eq!(parse_cli_version("photocraft 0.7.0 (dev build)", photo).unwrap().to_string(), "0.7.0");
assert_eq!(parse_cli_version("SoundCraft 0.5.0", sound).unwrap().to_string(), "0.5.0");
assert_eq!(parse_cli_version("printcraft 0.2.1", pdf).unwrap().to_string(), "0.2.1");
assert_eq!(parse_cli_version("PdfCraft 0.6.0", pdf).unwrap().to_string(), "0.6.0");
assert!(strip_cli_prefix("photocraft-cli 1.0.0", "photocraft").is_none());
}
#[cfg(target_os = "linux")]
#[test]
fn finds_appimages_in_every_folder_newest_first() {
@@ -451,4 +550,63 @@ mod tests {
]
);
}
const LEARNING_MACHINES: &str = "Learning Machines LLC";
fn roots() -> Vec<PathBuf> {
vec![PathBuf::from(r"C:\Program Files"), PathBuf::from(r"C:\Program Files (x86)")]
}
#[test]
fn pdfcraft_matches_its_former_printcraft_installer_name() {
let pdf = CraftApp::by_id("pdfcraft").unwrap();
assert!(is_installer_entry(pdf, "PrintCraft", LEARNING_MACHINES), "a PrintCraft entry from {LEARNING_MACHINES} should be PdfCraft's");
}
#[test]
fn pdfcraft_still_matches_its_own_installer_name() {
let pdf = CraftApp::by_id("pdfcraft").unwrap();
assert!(is_installer_entry(pdf, "PdfCraft", LEARNING_MACHINES));
}
#[test]
fn printcraft_entries_match_nothing_else() {
let pdf = CraftApp::by_id("pdfcraft").unwrap();
assert!(!is_installer_entry(pdf, "PrintCraft", "Some Other Publisher"));
assert!(!is_installer_entry(pdf, "PhotoCraft", LEARNING_MACHINES));
for a in SUITE.iter().filter(|a| a.id != "pdfcraft") {
assert!(!is_installer_entry(a, "PrintCraft", LEARNING_MACHINES), "PrintCraft matched {}", a.name);
}
}
#[test]
fn pdfcraft_exe_is_sought_under_every_name_it_has_shipped_as() {
let pdf = CraftApp::by_id("pdfcraft").unwrap();
let location = PathBuf::from(r"D:\Apps\PrintCraft");
let roots = roots();
let mut want = vec![location.join("pdfcraft.exe"), location.join("printcraft.exe")];
for root in &roots {
for dir in ["PdfCraft", "PrintCraft"] {
want.push(root.join(dir).join("pdfcraft.exe"));
want.push(root.join(dir).join("printcraft.exe"));
}
}
assert_eq!(exe_candidates(pdf, Some(&location), &roots), want);
}
#[test]
fn apps_without_former_names_match_and_find_their_exe_as_before() {
let roots = roots();
let location = PathBuf::from(r"D:\Apps\Somewhere");
for a in SUITE.iter().filter(|a| a.former_ids.is_empty() && a.former_names.is_empty()) {
assert!(is_installer_entry(a, a.name, LEARNING_MACHINES), "{}", a.name);
assert!(!is_installer_entry(a, a.name, "Some Other Publisher"), "{}", a.name);
assert!(!is_installer_entry(a, &a.name.to_lowercase(), LEARNING_MACHINES), "{}", a.name);
let exe = format!("{}.exe", a.id);
let in_program_files: Vec<PathBuf> = roots.iter().map(|r| r.join(a.name).join(&exe)).collect();
assert_eq!(exe_candidates(a, None, &roots), in_program_files);
let with_location: Vec<PathBuf> = std::iter::once(location.join(&exe)).chain(in_program_files).collect();
assert_eq!(exe_candidates(a, Some(&location), &roots), with_location);
}
}
}
+60 -55
View File
@@ -160,6 +160,27 @@ impl Http {
}
}
/// GET a small binary file (following redirects). `Ok(None)` for a 404. A file of more than
/// `max` bytes is refused: by its announced length before a byte is read, and by a hard limit
/// on the body when the length wasn't announced or was wrong.
pub fn get_bytes(&self, url: &str, max: usize) -> Result<Option<Vec<u8>>, String> {
let mut resp = self.small.get(url).call().map_err(|e| format!("couldn't reach {} ({e})", host(url)))?;
match resp.status().as_u16() {
200 => {
let announced = resp.headers().get("content-length").and_then(|v| v.to_str().ok()).and_then(|v| v.parse::<u64>().ok());
let too_big = || format!("{} sent more than {max} bytes", host(url));
if announced.is_some_and(|n| n > max as u64) {
return Err(too_big());
}
// ureq errors once the limit is reached, so ask for one byte more than we accept.
let body = resp.body_mut().with_config().limit(max as u64 + 1).read_to_vec().map_err(|e| format!("unreadable answer ({e})"))?;
if body.len() > max { Err(too_big()) } else { Ok(Some(body)) }
}
404 => Ok(None),
s => Err(format!("{} answered HTTP {s}", host(url))),
}
}
/// Where `url` redirects to, without following it.
pub fn redirect_target(&self, url: &str) -> Result<Option<String>, String> {
let resp = self.no_redirect.head(url).call().map_err(|e| format!("couldn't reach {} ({e})", host(url)))?;
@@ -178,24 +199,15 @@ impl Http {
/// its partial file; a file that fails its checksum is fetched once more from scratch before
/// giving up. When the connection stays down, the download ends paused (`PAUSED`), never
/// losing what it has. `stop` pauses or cancels it between reads.
pub fn download(
&self,
url: &str,
dest: &Path,
size: u64,
sha256: Option<&str>,
stop: &Stop,
progress: &mut dyn FnMut(Transfer),
) -> Result<Verified, String> {
pub fn download(&self, url: &str, dest: &Path, size: u64, sha256: &str, stop: &Stop, progress: &mut dyn FnMut(Transfer)) -> Result<(), String> {
if let Some(dir) = dest.parent() {
std::fs::create_dir_all(dir).map_err(|e| format!("couldn't create {} ({e})", dir.display()))?;
}
let verified = if sha256.is_some() { Verified::Checksum } else { Verified::SizeOnly };
// A finished copy from an earlier attempt that never got installed. Marking it as just
// used, before checking it, keeps `clean_downloads` away from it while it's installed.
if dest.is_file() && touch(dest) && matches_published(dest, size, sha256) {
if dest.is_file() && touch(dest) && matches_published(dest, sha256) {
progress(Transfer::Bytes { done: size, total: size });
return Ok(verified);
return Ok(());
}
// The partial file is named after the release file and its size; if it still turns out
// to belong to something else, the checksum catches it and the download starts over.
@@ -210,9 +222,9 @@ impl Http {
}
return Err(e);
}
if matches_published(&part, size, sha256) {
if matches_published(&part, sha256) {
std::fs::rename(&part, dest).map_err(|e| format!("couldn't save the download ({e})"))?;
return Ok(verified);
return Ok(());
}
let _ = std::fs::remove_file(&part);
if fresh_start_used {
@@ -359,15 +371,6 @@ pub enum Transfer {
},
}
/// How a finished download was checked.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Verified {
/// Against the release's published SHA-256.
Checksum,
/// The release publishes no checksum: only its size could be checked.
SizeOnly,
}
/// Asks a running download to pause (keeping its partial file) or cancel (deleting it).
#[derive(Debug, Default)]
pub struct Stop(AtomicU8);
@@ -395,12 +398,9 @@ impl Stop {
}
}
/// Whether a file on disk is the published one: its checksum when there is one, else its size.
fn matches_published(path: &Path, size: u64, sha256: Option<&str>) -> bool {
match sha256 {
Some(sha) => sha256_file(path).is_ok_and(|h| h.eq_ignore_ascii_case(sha)),
None => size > 0 && std::fs::metadata(path).is_ok_and(|m| m.len() == size),
}
/// Whether a file on disk is the published one: its SHA-256 matches.
fn matches_published(path: &Path, sha256: &str) -> bool {
sha256_file(path).is_ok_and(|h| h.eq_ignore_ascii_case(sha256))
}
/// How much of a download is already on disk (a paused or interrupted one).
@@ -564,18 +564,18 @@ pub(crate) mod tests {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("file.bin");
let http = Http::new().unwrap();
http.download(&url, &dest, len, Some(&sha), &Stop::default(), &mut |_| {}).unwrap();
http.download(&url, &dest, len, &sha, &Stop::default(), &mut |_| {}).unwrap();
assert!(finished(&dest, len) && !finished(&dest, 0));
// Old enough to be cleaned up, were it not about to be used.
let old = std::time::SystemTime::now() - Duration::from_secs(40 * 86_400);
std::fs::File::options().write(true).open(&dest).unwrap().set_modified(old).unwrap();
let mut last = (0, 0);
let again = http.download(&url, &dest, len, Some(&sha), &Stop::default(), &mut |t| {
let again = http.download(&url, &dest, len, &sha, &Stop::default(), &mut |t| {
if let Transfer::Bytes { done, total } = t {
last = (done, total);
}
});
assert_eq!(again, Ok(Verified::Checksum));
assert_eq!(again, Ok(()));
assert_eq!(last, (len, len));
assert_eq!(requests.lock().unwrap().len(), 1);
// A cleanup while the installer runs leaves the reused file alone.
@@ -593,10 +593,10 @@ pub(crate) mod tests {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("file.bin");
let http = Http::new().unwrap();
http.download(&url, &dest, len, Some(&sha), &Stop::default(), &mut |_| {}).unwrap();
http.download(&url, &dest, len, &sha, &Stop::default(), &mut |_| {}).unwrap();
// The same size, so only the checksum can tell.
std::fs::write(&dest, vec![b'x'; body.len()]).unwrap();
http.download(&url, &dest, len, Some(&sha), &Stop::default(), &mut |_| {}).unwrap();
http.download(&url, &dest, len, &sha, &Stop::default(), &mut |_| {}).unwrap();
assert_eq!(std::fs::read(&dest).unwrap(), body);
assert_eq!(requests.lock().unwrap().len(), 2);
}
@@ -689,15 +689,12 @@ pub(crate) mod tests {
let dest = dir.path().join("file.bin");
let http = Http::new().unwrap();
let mut last = (0, 0);
let result = http.download(&url, &dest, body.len() as u64, Some(&sha), &Stop::default(), &mut |t| {
let result = http.download(&url, &dest, body.len() as u64, &sha, &Stop::default(), &mut |t| {
if let Transfer::Bytes { done, total } = t {
last = (done, total);
}
});
let result = result.map(|v| {
assert_eq!(v, Verified::Checksum);
std::fs::read(&dest).unwrap()
});
let result = result.map(|()| std::fs::read(&dest).unwrap());
if result.is_ok() {
assert_eq!(last, (body.len() as u64, body.len() as u64));
}
@@ -705,6 +702,25 @@ pub(crate) mod tests {
(result, seen)
}
#[test]
fn small_binary_files_are_read_whole_and_a_404_is_none() {
let (body, _) = body();
let http = Http::new().unwrap();
let (url, _) = serve(body.clone(), vec![Reply::Full]);
assert_eq!(http.get_bytes(&url, body.len()).unwrap(), Some(body.clone()), "a body of exactly the cap is fine");
let (url, _) = serve(body, vec![Reply::Missing]);
assert_eq!(http.get_bytes(&url, 1 << 20).unwrap(), None);
}
#[test]
fn a_file_over_the_cap_is_refused() {
let (body, _) = body();
let http = Http::new().unwrap();
let (url, _) = serve(body.clone(), vec![Reply::Full]);
let err = http.get_bytes(&url, body.len() - 1).unwrap_err();
assert!(err.contains("more than"), "{err}");
}
#[test]
fn a_complete_body_of_exactly_the_published_size_succeeds() {
// The bug this guards: a reader limit equal to the size failed on the final read.
@@ -733,17 +749,6 @@ pub(crate) mod tests {
assert!(result.unwrap_err().contains("checksum"));
}
#[test]
fn without_a_checksum_the_size_is_still_checked() {
let (body, _) = body();
let (url, _) = serve(body.clone(), vec![Reply::Full]);
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("file.bin");
let v = Http::new().unwrap().download(&url, &dest, body.len() as u64, None, &Stop::default(), &mut |_| {}).unwrap();
assert_eq!(v, Verified::SizeOnly);
assert_eq!(std::fs::read(&dest).unwrap(), body);
}
#[test]
fn pausing_keeps_the_partial_file_and_resuming_continues_it() {
let (body, sha) = body();
@@ -755,7 +760,7 @@ pub(crate) mod tests {
let stop = Stop::default();
// Pause as soon as the first bytes arrive.
let err = http
.download(&url, &dest, len, Some(&sha), &stop, &mut |t| {
.download(&url, &dest, len, &sha, &stop, &mut |t| {
if matches!(t, Transfer::Bytes { done, .. } if done > 0) {
stop.pause();
}
@@ -764,7 +769,7 @@ pub(crate) mod tests {
assert_eq!(err, PAUSED);
let kept = partial_bytes(&dest, len).unwrap();
assert!(kept > 0 && kept < len, "{kept}");
http.download(&url, &dest, len, Some(&sha), &Stop::default(), &mut |_| {}).unwrap();
http.download(&url, &dest, len, &sha, &Stop::default(), &mut |_| {}).unwrap();
assert_eq!(std::fs::read(&dest).unwrap(), body);
assert_eq!(ranges.lock().unwrap()[1], Some(kept.to_string()));
}
@@ -779,7 +784,7 @@ pub(crate) mod tests {
let stop = Stop::default();
let err = Http::new()
.unwrap()
.download(&url, &dest, len, Some(&sha), &stop, &mut |t| {
.download(&url, &dest, len, &sha, &stop, &mut |t| {
if matches!(t, Transfer::Bytes { done, .. } if done > 0) {
stop.cancel();
}
@@ -804,7 +809,7 @@ pub(crate) mod tests {
let dest = dir.path().join("file.bin");
std::fs::write(part_path(&dest, body.len() as u64), &body[..500_000]).unwrap();
assert_eq!(partial_bytes(&dest, body.len() as u64), Some(500_000));
Http::new().unwrap().download(&url, &dest, body.len() as u64, Some(&sha), &Stop::default(), &mut |_| {}).unwrap();
Http::new().unwrap().download(&url, &dest, body.len() as u64, &sha, &Stop::default(), &mut |_| {}).unwrap();
assert_eq!(std::fs::read(&dest).unwrap(), body);
assert_eq!(*ranges.lock().unwrap(), [Some("500000".to_string())]);
assert!(!part_path(&dest, body.len() as u64).exists());
@@ -825,7 +830,7 @@ pub(crate) mod tests {
let sha = asset.sha256.clone().or_else(|| sums.iter().find(|(_, n)| *n == asset.name).map(|(h, _)| h.clone())).unwrap();
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join(&asset.name);
http.download(&asset.url, &dest, asset.size, Some(&sha), &Stop::default(), &mut |_| {}).unwrap();
http.download(&asset.url, &dest, asset.size, &sha, &Stop::default(), &mut |_| {}).unwrap();
assert_eq!(std::fs::metadata(&dest).unwrap().len(), asset.size);
}
+357 -55
View File
@@ -11,6 +11,10 @@
//! `~/.local/share` so it shows up like any other app. A Flatpak or AppImage launcher prefers
//! its own format.
//! - FreeBSD: the release's `/usr/local` tree, with a polkit prompt (or `~/.local` without one).
//! - Linux, inside the Flatpak launcher with no usable host Flatpak (none installed, or
//! `flatpak-spawn` not allowed): the release tarball, unpacked into the launcher's own folder
//! (`<data>/apps/<app>`) and run inside the same sandbox with `HOME` pointed at a `home/`
//! folder beside it, so the app's settings stay in the sandbox too.
//!
//! Every download is checked against its published SHA-256 before anything is unpacked, a
//! running app is never replaced, and a new copy is fully in place before the old one goes.
@@ -21,7 +25,7 @@ use artcraft_launcher_core::protocol::{CANCELLED, INSTALL_CANCELLED, JobPhase};
use artcraft_launcher_core::release::{self, Asset, Release};
use artcraft_launcher_core::{CraftApp, InstallSource, Installed, PackageKind};
use crate::http::{Http, Stop, Transfer, Verified};
use crate::http::{Http, Stop, Transfer};
use crate::manifest::Record;
use crate::paths::Paths;
use crate::process;
@@ -45,7 +49,7 @@ pub fn install(
existing: Option<&Installed>,
previous: Option<&Record>,
job: &mut Job<'_>,
) -> Result<(Option<Record>, Verified), String> {
) -> Result<Option<Record>, String> {
let _ = previous;
if let Some(inst) = existing
&& inst.source.launcher_can_update()
@@ -54,7 +58,7 @@ pub fn install(
return Err(format!("{} is running. Quit it, then try again.", app.name));
}
log::info!("{}: starting {} to {} ({})", app.name, if existing.is_some() { "update" } else { "install" }, release.version_label(), kind.label());
let (file, verified) = download(release, asset, job)?;
let file = download(release, asset, job)?;
log::info!("{}: download ready ({}), running installer...", app.name, file.display());
// Past this point the install can't be interrupted halfway.
(job.phase)(JobPhase::Installing);
@@ -74,6 +78,8 @@ pub fn install(
}),
#[cfg(target_os = "freebsd")]
PackageKind::TarGz => freebsd::install_tree(app, &file, job.paths).map(|(bin, files, root)| Some(record(&version, kind, bin, files, root))),
#[cfg(target_os = "linux")]
PackageKind::TarGz => linux::install_tree(app, &file, job.paths).map(|path| Some(record(&version, kind, path, Vec::new(), false))),
other => Err(format!("{} isn't supported on this system", other.label())),
};
match &result {
@@ -81,18 +87,13 @@ pub fn install(
Err(e) if e == CANCELLED => log::info!("{}: installation cancelled", app.name),
Err(e) => log::error!("{}: installation failed: {e}", app.name),
}
if !keep_download(result.is_ok(), verified) {
// A failed install keeps the download: the next try checks it against its published checksum
// again and skips the transfer.
if result.is_ok() {
let _ = std::fs::remove_file(&file);
}
// The system's own prompt was dismissed: the download itself wasn't cancelled.
result.map(|r| (r, verified)).map_err(|e| if e == CANCELLED { INSTALL_CANCELLED.to_string() } else { e })
}
/// Whether the download stays on disk once the install step has ended. A failed install keeps it:
/// the next try checks it against its published checksum again and skips the transfer. Without a
/// checksum there's nothing to check it against later, so it goes.
fn keep_download(installed: bool, verified: Verified) -> bool {
!installed && verified == Verified::Checksum
result.map_err(|e| if e == CANCELLED { INSTALL_CANCELLED.to_string() } else { e })
}
/// Where `asset` is downloaded to (and its partial file kept while unfinished).
@@ -150,10 +151,10 @@ pub fn available_kinds() -> Vec<PackageKind> {
Vec::new()
}
/// Fetches `asset` into the download folder, checked against its published SHA-256: GitHub's
/// per-file digest, else the release's `SHA256SUMS.txt`. A release with neither still installs,
/// checked by size only, and says so.
pub(crate) fn download(release: &Release, asset: &Asset, job: &mut Job<'_>) -> Result<(PathBuf, Verified), String> {
/// Fetches `asset` into the download folder, checked against its published SHA-256: the feed's
/// or GitHub's per-file digest, else the release's `SHA256SUMS.txt`. A release with neither is
/// refused before anything is fetched: a file nothing vouches for is never installed (#90).
pub(crate) fn download(release: &Release, asset: &Asset, job: &mut Job<'_>) -> Result<PathBuf, String> {
if !release::valid_file_name(&asset.name) {
return Err("the release has an unexpected file name".into());
}
@@ -161,17 +162,20 @@ pub(crate) fn download(release: &Release, asset: &Asset, job: &mut Job<'_>) -> R
Some(s) => Some(s.clone()),
None => published_checksum(release, asset, job.http)?,
};
if sha.is_none() {
log::warn!("{}: no published checksum; checking the size only", asset.name);
}
let Some(sha) = sha else {
return Err(format!(
"the release publishes no checksum for {}, so it can't be verified and wasn't installed. Try again later, or report it on GitHub.",
asset.name
));
};
let dest = download_path(job.paths, asset);
let phase = &mut *job.phase;
let verified = job.http.download(&asset.url, &dest, asset.size, sha.as_deref(), job.stop, &mut |t| match t {
job.http.download(&asset.url, &dest, asset.size, &sha, job.stop, &mut |t| match t {
Transfer::Bytes { done, total } => phase(JobPhase::Downloading { done, total }),
Transfer::Reconnecting { done, total } => phase(JobPhase::Reconnecting { done, total }),
})?;
(job.phase)(JobPhase::Verifying);
Ok((dest, verified))
Ok(dest)
}
/// The file's line in the release's `SHA256SUMS.txt`. `Ok(None)` only when the release really
@@ -207,8 +211,15 @@ pub fn managed_elsewhere(app: &CraftApp, installed: &Installed, uninstall: bool)
/// Removes an installed copy. `phase` hears when the removal has to wait for another install;
/// `stop` cancels it while it waits.
pub fn uninstall(app: &CraftApp, installed: &Installed, record: Option<&Record>, stop: &Stop, phase: &mut dyn FnMut(JobPhase)) -> Result<(), String> {
let _ = (record, stop, &phase);
pub fn uninstall(
app: &CraftApp,
installed: &Installed,
record: Option<&Record>,
paths: &Paths,
stop: &Stop,
phase: &mut dyn FnMut(JobPhase),
) -> Result<(), String> {
let _ = (record, stop, &phase, paths);
let path = PathBuf::from(&installed.location);
if let Some(msg) = managed_elsewhere(app, installed, true) {
return Err(msg);
@@ -233,7 +244,13 @@ pub fn uninstall(app: &CraftApp, installed: &Installed, record: Option<&Record>,
None => Err("the launcher has no record of these files".into()),
},
#[cfg(target_os = "linux")]
InstallSource::AppImage | InstallSource::Launcher => remove_appimage(app, &path, record),
InstallSource::AppImage => remove_appimage(app, &path, record),
// A tarball the launcher unpacked into its own folder: take the tree out and leave the
// app's settings (`home/`) alone, so a reinstall finds its projects where they were.
#[cfg(target_os = "linux")]
InstallSource::Launcher if record.is_some_and(|r| r.kind == PackageKind::TarGz) => linux::remove_tree(app, paths),
#[cfg(target_os = "linux")]
InstallSource::Launcher => remove_appimage(app, &path, record),
other => Err(format!("{} can't be removed from here", other.label())),
}
}
@@ -258,7 +275,8 @@ fn remove_appimage(app: &CraftApp, path: &Path, record: Option<&Record>) -> Resu
}
/// Starts an installed app.
pub fn launch(app: &CraftApp, installed: &Installed) -> Result<(), String> {
pub fn launch(app: &CraftApp, installed: &Installed, paths: &Paths) -> Result<(), String> {
let _ = paths;
let path = Path::new(&installed.location);
match installed.source {
InstallSource::Flatpak => {
@@ -267,6 +285,23 @@ pub fn launch(app: &CraftApp, installed: &Installed) -> Result<(), String> {
process::spawn_detached(cmd)
}
InstallSource::MacApp => process::open(path.as_os_str()),
// A tarball the launcher unpacked into its own folder: run it here, against its own
// home, rather than on the host through `flatpak-spawn` (which is what `launch_binary`
// would do inside the sandbox). Its settings then land beside it instead of in the
// launcher's or the host's home folder.
#[cfg(target_os = "linux")]
InstallSource::Launcher if path.starts_with(paths.apps()) => {
let home = paths.app_home(app.id);
prepare_home(&home);
let mut cmd = process::command(path);
for (name, value) in home_env(&home) {
cmd.env(name, value);
}
if let Some(dir) = path.parent() {
cmd.current_dir(dir);
}
process::spawn_detached(cmd)
}
_ if cfg!(target_os = "macos") && path.extension().is_some_and(|e| e == "app") => process::open(path.as_os_str()),
_ => {
if !path.exists() {
@@ -278,26 +313,77 @@ pub fn launch(app: &CraftApp, installed: &Installed) -> Result<(), String> {
.map_err(|e| format!("Couldn't open {}: {e}", app.name))
}
/// The environment a Craft App unpacked by the launcher runs with: its own `HOME` and the XDG
/// base directories inside it. Apps read their settings from these, so everything they keep
/// (settings, projects, caches) stays in that one folder.
#[cfg(target_os = "linux")]
fn home_env(home: &Path) -> [(&'static str, PathBuf); 5] {
[
("HOME", home.to_path_buf()),
("XDG_CONFIG_HOME", home.join(".config")),
("XDG_DATA_HOME", home.join(".local/share")),
("XDG_CACHE_HOME", home.join(".cache")),
("XDG_STATE_HOME", home.join(".local/state")),
]
}
/// Creates the folders an app unpacked by the launcher expects to find, and links the host's
/// `~/.vst3` and `~/.clap` (SoundCraft's plugin folders) in when they exist, so an app running
/// inside the sandbox still sees the plugins installed on the host. Every failure here is
/// ignored: the app is worth starting even if its home is not quite ready.
#[cfg(target_os = "linux")]
fn prepare_home(home: &Path) {
for (_, dir) in home_env(home).iter().skip(1) {
let _ = std::fs::create_dir_all(dir);
}
let Some(real) = dirs::home_dir() else { return };
if real == home {
return;
}
for name in [".vst3", ".clap"] {
let link = home.join(name);
if link.symlink_metadata().is_ok() {
continue;
}
if real.join(name).is_dir() {
let _ = std::os::unix::fs::symlink(real.join(name), link);
}
}
}
/// Moves `staged` into place at `dest`, keeping the old copy until the new one is there.
#[allow(dead_code)]
fn swap_into_place(staged: &Path, dest: &Path) -> Result<(), String> {
let old = dest.with_extension("old");
if old.exists() {
let _ = if old.is_dir() { std::fs::remove_dir_all(&old) } else { std::fs::remove_file(&old) };
}
let had_old = dest.exists();
if had_old {
std::fs::rename(dest, &old).map_err(|e| format!("couldn't move the old copy aside ({e})"))?;
}
if let Err(e) = std::fs::rename(staged, dest) {
if had_old {
let _ = std::fs::rename(&old, dest);
swap_into_place_via(staged, dest, |from, to| std::fs::rename(from, to))
}
fn swap_into_place_via(staged: &Path, dest: &Path, rename: impl Fn(&Path, &Path) -> std::io::Result<()>) -> Result<(), String> {
// Reserve a directory of our own beside the destination. A predictable sibling such as
// `app.old` may be a user's backup, and must never be removed or overwritten.
let backup = if dest.exists() {
let parent = dest.parent().ok_or("the installed app has no parent folder")?;
let backup = tempfile::Builder::new()
.prefix(".artcraft-launcher-backup-")
.tempdir_in(parent)
.map_err(|e| format!("couldn't create a backup folder in {} ({e})", parent.display()))?;
rename(dest, &backup.path().join("old")).map_err(|e| format!("couldn't move the old copy aside ({e})"))?;
Some(backup)
} else {
None
};
if let Err(e) = rename(staged, dest) {
if let Some(backup) = backup
&& let Err(restore) = rename(&backup.path().join("old"), dest)
{
// A failed rollback must not let TempDir's cleanup delete the only old copy.
let saved = backup.keep().join("old");
return Err(format!(
"couldn't put the new copy in place ({e}); couldn't restore the old copy ({restore}); it was kept at {}",
saved.display()
));
}
return Err(format!("couldn't put the new copy in place ({e})"));
}
if had_old {
let _ = if old.is_dir() { std::fs::remove_dir_all(&old) } else { std::fs::remove_file(&old) };
}
Ok(())
}
@@ -431,10 +517,19 @@ pub(crate) mod linux {
}
}
/// Fedora Atomic (Silverblue, Kinoite, Bazzite…): rpm-ostree manages the root filesystem and
/// `dnf install` of a local package is refused (Bazzite wraps `/usr/bin/dnf` to say so).
fn immutable_ostree() -> bool {
Path::new("/run/ostree-booted").exists()
}
/// What this system can install, ordered with the best default first.
///
/// The default package format aligns with how the user installed the launcher itself:
/// - Running as a Flatpak: prefers Flatpak (per-user, desktop integration).
/// - Running as a Flatpak: prefers Flatpak (per-user, desktop integration). When the host
/// has no usable Flatpak (none installed, or `flatpak-spawn` refused) it prefers the
/// release tarball instead, which the launcher unpacks and runs inside the sandbox; an
/// AppImage cannot mount there and a distro package needs root, so both are dead ends.
/// - Running as an AppImage: prefers AppImage.
/// - Otherwise (system install): prefers the distro's own package format (`.deb` on the
/// Debian family, `.rpm` on the Fedora, Red Hat and SUSE families, read from `os-release`
@@ -446,6 +541,8 @@ pub(crate) mod linux {
let root = which("pkexec").is_some();
let os = ["/etc/os-release", "/usr/lib/os-release"].iter().find_map(|p| std::fs::read_to_string(p).ok()).unwrap_or_default();
let native = distro::native_package(&distro::parse_os_release(&os));
// An ostree system can't take .deb or .rpm installs, whatever os-release says.
let root = root && !immutable_ostree();
let can_deb = root && native == Some(PackageKind::Deb) && which("apt-get").is_some() && which("dpkg").is_some();
let can_rpm = root && native == Some(PackageKind::Rpm) && which("rpm").is_some();
order_available_kinds(is_flatpak, is_appimage, flatpak, can_deb, can_rpm)
@@ -461,6 +558,8 @@ pub(crate) mod linux {
let mut kinds = Vec::new();
if is_flatpak && flatpak_available {
kinds.push(PackageKind::Flatpak);
} else if is_flatpak {
kinds.push(PackageKind::TarGz);
} else if is_appimage {
kinds.push(PackageKind::AppImage);
}
@@ -475,6 +574,10 @@ pub(crate) mod linux {
if flatpak_available && !kinds.contains(&PackageKind::Flatpak) {
kinds.push(PackageKind::Flatpak);
}
// The tarball runs inside the sandbox, so it is only ever offered there.
if is_flatpak && !kinds.contains(&PackageKind::TarGz) {
kinds.push(PackageKind::TarGz);
}
if !kinds.contains(&PackageKind::AppImage) {
kinds.push(PackageKind::AppImage);
}
@@ -586,6 +689,49 @@ pub(crate) mod linux {
Ok((dest, removed))
}
/// Unpacks a release tarball (`bin/<app>`, `share/…`) into `<data>/apps/<app>`, a folder
/// only the launcher owns, and returns the app's binary. This is the fallback for the
/// Flatpak launcher when the host has no usable Flatpak: the app then runs inside the
/// sandbox instead of on the host. `bin` and `share` are swapped in one at a time, so an
/// update leaves the app's own `home/` beside them untouched.
pub fn install_tree(app: &CraftApp, file: &Path, paths: &Paths) -> Result<PathBuf, String> {
let staged = paths.cache.join(format!("{}.new", app.id));
let result = unpack_tree(app, file, &staged, paths);
let _ = std::fs::remove_dir_all(&staged);
result
}
fn unpack_tree(app: &CraftApp, file: &Path, staged: &Path, paths: &Paths) -> Result<PathBuf, String> {
let tree = tree::unpack(app, file, staged)?;
let root = paths.apps().join(app.id);
std::fs::create_dir_all(&root).map_err(|e| format!("couldn't create {} ({e})", root.display()))?;
for dir in ["bin", "share"] {
if tree.join(dir).is_dir() {
swap_into_place(&tree.join(dir), &root.join(dir))?;
}
}
let bin = root.join("bin").join(app.id);
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755))
.map_err(|e| format!("couldn't make {} runnable ({e})", bin.display()))?;
Ok(bin)
}
/// Removes a tree the launcher unpacked in [`install_tree`], keeping the app's `home/`: the
/// settings and projects in it are the user's, not the installer's. Only the two folders the
/// launcher put there are removed, and only inside the launcher's own apps folder.
pub fn remove_tree(app: &CraftApp, paths: &Paths) -> Result<(), String> {
let root = paths.apps().join(app.id);
let mut removed = false;
for dir in ["bin", "share"] {
let path = root.join(dir);
if path.starts_with(paths.apps()) && path.is_dir() {
std::fs::remove_dir_all(&path).map_err(|e| format!("couldn't remove {} ({e})", path.display()))?;
removed = true;
}
}
if removed { Ok(()) } else { Err(format!("{} isn't installed in this folder", app.name)) }
}
/// Where menu entries and icons go: `$XDG_DATA_HOME`, else `~/.local/share`. Inside the
/// launcher's own Flatpak that variable points into the sandbox, so the host's is used.
pub fn host_data_dir() -> Option<PathBuf> {
@@ -613,6 +759,7 @@ pub(crate) mod linux {
"gridcraft" => include_bytes!("../../../assets/app-icons/gridcraft.png"),
"cadcraft" => include_bytes!("../../../assets/app-icons/cadcraft.png"),
"soundcraft" => include_bytes!("../../../assets/app-icons/soundcraft.png"),
"figcraft" => include_bytes!("../../../assets/app-icons/figcraft.png"),
_ => return None,
})
}
@@ -1095,21 +1242,86 @@ mod tests {
swap_into_place(&staged, &dest).unwrap();
assert_eq!(std::fs::read_to_string(dest.join("v")).unwrap(), "new");
assert!(!staged.exists() && !dir.path().join("app.old").exists());
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
// A missing staged copy leaves the installed one alone.
assert!(swap_into_place(&staged, &dest).is_err());
assert_eq!(std::fs::read_to_string(dest.join("v")).unwrap(), "new");
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
}
#[test]
fn only_a_failed_install_of_a_checksummed_download_keeps_it() {
for (installed, verified, keep) in [
(false, Verified::Checksum, true),
(false, Verified::SizeOnly, false),
(true, Verified::Checksum, false),
(true, Verified::SizeOnly, false),
] {
assert_eq!(keep_download(installed, verified), keep, "installed: {installed}, {verified:?}");
}
fn swap_preserves_an_unrelated_backup_file() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("app");
let staged = dir.path().join("app.new");
let unrelated = dest.with_extension("old");
std::fs::write(&dest, b"installed app").unwrap();
std::fs::write(&staged, b"updated app").unwrap();
std::fs::write(&unrelated, b"user backup").unwrap();
swap_into_place(&staged, &dest).unwrap();
assert_eq!(std::fs::read(&unrelated).unwrap(), b"user backup");
assert_eq!(std::fs::read(&dest).unwrap(), b"updated app");
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 2);
}
#[test]
fn swap_preserves_an_unrelated_backup_directory() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("app");
let staged = dir.path().join("app.new");
let unrelated = dest.with_extension("old");
std::fs::create_dir(&dest).unwrap();
std::fs::write(dest.join("v"), b"installed app").unwrap();
std::fs::create_dir(&staged).unwrap();
std::fs::write(staged.join("v"), b"updated app").unwrap();
std::fs::create_dir(&unrelated).unwrap();
std::fs::write(unrelated.join("keep.txt"), b"user backup").unwrap();
swap_into_place(&staged, &dest).unwrap();
assert_eq!(std::fs::read(unrelated.join("keep.txt")).unwrap(), b"user backup");
assert_eq!(std::fs::read(dest.join("v")).unwrap(), b"updated app");
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 2);
}
#[cfg(unix)]
#[test]
fn swap_preserves_a_dangling_backup_symlink() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("app");
let staged = dir.path().join("app.new");
let unrelated = dest.with_extension("old");
let target = dir.path().join("missing");
std::fs::write(&dest, b"installed app").unwrap();
std::fs::write(&staged, b"updated app").unwrap();
std::os::unix::fs::symlink(&target, &unrelated).unwrap();
swap_into_place(&staged, &dest).unwrap();
assert_eq!(std::fs::read_link(&unrelated).unwrap(), target);
assert_eq!(std::fs::read(&dest).unwrap(), b"updated app");
}
#[test]
fn a_failed_rollback_keeps_the_old_app_and_reports_its_backup_path() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("app");
let staged = dir.path().join("app.new");
std::fs::write(&dest, b"installed app").unwrap();
std::fs::write(&staged, b"updated app").unwrap();
let calls = std::cell::Cell::new(0);
let err = swap_into_place_via(&staged, &dest, |from, to| {
calls.set(calls.get() + 1);
match calls.get() {
1 => std::fs::rename(from, to),
2 => Err(std::io::Error::other("staged move failed")),
_ => Err(std::io::Error::other("restore failed")),
}
})
.unwrap_err();
assert_eq!(calls.get(), 3);
assert!(err.contains("staged move failed") && err.contains("restore failed"));
let backup = std::fs::read_dir(dir.path()).unwrap().map(|e| e.unwrap().path()).find(|p| p.is_dir()).unwrap();
let saved = backup.join("old");
assert_eq!(std::fs::read(&saved).unwrap(), b"installed app");
assert!(err.contains(&saved.display().to_string()));
assert_eq!(std::fs::read(&staged).unwrap(), b"updated app");
}
#[test]
@@ -1135,14 +1347,29 @@ mod tests {
}
assert_eq!(requests.lock().unwrap().len(), 1, "the second try reuses the kept file");
assert_eq!(phases.iter().filter(|p| **p == JobPhase::Installing).count(), 2);
}
// Checked by size only: not kept.
let unchecked = Asset { name: "wordcraft-0.3.1-test.pkg".into(), sha256: None, ..asset };
let mut phase = |_: JobPhase| {};
#[test]
fn a_release_without_a_checksum_is_refused_before_anything_is_fetched() {
use crate::http::tests::{Reply, body, serve};
let app = CraftApp::by_id("wordcraft").unwrap();
let (body, _) = body();
let (url, requests) = serve(body.clone(), vec![Reply::Full]);
// Neither a per-file digest nor a SHA256SUMS.txt in the release.
let asset = Asset { name: "wordcraft-0.3.1-test.pkg".into(), size: body.len() as u64, url, sha256: None };
let release = Release { assets: vec![asset.clone()], ..Release::default() };
let dir = tempfile::tempdir().unwrap();
let paths = Paths::under(dir.path());
let http = Http::new().unwrap();
let kind = if cfg!(windows) { PackageKind::Dmg } else { PackageKind::Msi };
let mut phases = Vec::new();
let mut phase = |p: JobPhase| phases.push(p);
let mut job = Job { http: &http, paths: &paths, stop: &Stop::default(), phase: &mut phase };
install(app, &release, &unchecked, kind, None, None, &mut job).unwrap_err();
assert!(!download_path(&paths, &unchecked).exists());
assert_eq!(requests.lock().unwrap().len(), 2);
let err = install(app, &release, &asset, kind, None, None, &mut job).unwrap_err();
assert!(err.contains("publishes no checksum"), "{err}");
assert!(!download_path(&paths, &asset).exists());
assert!(requests.lock().unwrap().is_empty(), "nothing was fetched");
assert!(!phases.contains(&JobPhase::Installing));
}
#[cfg(unix)]
@@ -1192,6 +1419,57 @@ mod tests {
assert!(tree::unpack(app, &bad, &dir.path().join("staging")).is_err());
}
#[cfg(target_os = "linux")]
#[test]
fn a_tarball_install_lives_in_the_launchers_folder_and_keeps_the_apps_home() {
let app = CraftApp::by_id("wordcraft").unwrap();
let dir = tempfile::tempdir().unwrap();
let paths = Paths::under(dir.path());
let file = dir.path().join("wordcraft-0.3.0-linux-x86_64.tar.gz");
tarball(
&file,
&[
"wordcraft-0.3.0-linux-x86_64/bin/wordcraft",
"wordcraft-0.3.0-linux-x86_64/bin/wordcraft-cli",
"wordcraft-0.3.0-linux-x86_64/share/applications/ai.storyteller.wordcraft.desktop",
],
);
let bin = linux::install_tree(app, &file, &paths).unwrap();
let root = paths.apps().join("wordcraft");
assert_eq!(bin, root.join("bin").join("wordcraft"));
assert!(bin.is_file(), "the app's binary wasn't unpacked");
assert!(root.join("share/applications/ai.storyteller.wordcraft.desktop").is_file(), "share/ wasn't unpacked");
assert!(!paths.cache.join("wordcraft.new").exists(), "the staging folder was left behind");
// The app's own home sits beside the tree, and an update keeps it.
let home = paths.app_home(app.id);
std::fs::create_dir_all(home.join(".config/wordcraft")).unwrap();
std::fs::write(home.join(".config/wordcraft/settings.toml"), "keep me").unwrap();
linux::install_tree(app, &file, &paths).unwrap();
assert_eq!(std::fs::read_to_string(home.join(".config/wordcraft/settings.toml")).unwrap(), "keep me");
// Removing the install takes the tree and nothing else.
linux::remove_tree(app, &paths).unwrap();
assert!(!bin.exists() && !root.join("share").exists());
assert!(home.join(".config/wordcraft/settings.toml").is_file(), "the app's settings were removed with it");
assert!(linux::remove_tree(app, &paths).is_err(), "a second removal says there is nothing there");
}
#[cfg(target_os = "linux")]
#[test]
fn an_app_sees_its_own_home_and_the_xdg_folders_inside_it() {
let home = PathBuf::from("/data/apps/photocraft/home");
let env = home_env(&home);
assert_eq!(env[0], ("HOME", home.clone()));
for (name, dir) in &env[1..] {
assert!(dir.starts_with(&home), "{name}: {}", dir.display());
}
for wanted in ["XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "XDG_STATE_HOME"] {
assert!(env.iter().any(|(name, _)| *name == wanted), "{wanted} isn't set");
}
}
#[cfg(target_os = "linux")]
#[test]
fn several_debs_fit_one_apt_call_and_share_its_result() {
@@ -1353,6 +1631,18 @@ mod tests {
crate::desktop_entry::is_ours(&std::fs::read_to_string(path).unwrap())
}
#[cfg(target_os = "linux")]
#[test]
fn immutable_ostree_offers_no_deb_or_rpm() {
if !Path::new("/run/ostree-booted").exists() {
return;
}
let kinds = available_kinds();
assert!(!kinds.contains(&PackageKind::Rpm), "{kinds:?}");
assert!(!kinds.contains(&PackageKind::Deb), "{kinds:?}");
assert!(kinds.contains(&PackageKind::AppImage), "{kinds:?}");
}
#[test]
fn updates_keep_the_installed_format() {
let inst = |source| Installed { version: None, source, location: String::new(), package: None };
@@ -1397,6 +1687,18 @@ mod tests {
let kinds = order_available_kinds(false, false, true, false, false);
assert_eq!(kinds.first(), Some(&PackageKind::Flatpak));
assert_eq!(kinds.get(1), Some(&PackageKind::AppImage));
// The tarball runs inside the sandbox, so it is only offered there: with a usable host
// Flatpak it stays an alternative, without one it is the default (an AppImage cannot
// mount in a sandbox, and a distro package needs root).
let kinds = order_available_kinds(true, false, true, false, false);
assert_eq!(kinds.first(), Some(&PackageKind::Flatpak));
assert_eq!(kinds.get(1), Some(&PackageKind::TarGz));
let kinds = order_available_kinds(true, false, false, false, false);
assert_eq!(kinds, [PackageKind::TarGz, PackageKind::AppImage]);
for (appimage, flatpak) in [(true, true), (false, false), (false, true)] {
assert!(!order_available_kinds(false, appimage, flatpak, true, true).contains(&PackageKind::TarGz));
}
}
#[cfg(target_os = "macos")]
+1
View File
@@ -20,6 +20,7 @@ pub mod paths;
pub mod process;
pub mod proxy;
pub mod queue;
pub mod screenshots;
pub mod self_update;
pub mod single_instance;
pub mod source;
+20
View File
@@ -46,14 +46,34 @@ impl Paths {
self.cache.join("feed-throttle.json")
}
/// A picture of an app's details page, saved after its first download: under the revision of the
/// pictures repository it was fetched from, so a new revision never reads an old picture.
pub fn screenshot(&self, app: &str, key: &str) -> PathBuf {
self.cache.join("screenshots").join(artcraft_launcher_core::details::SCREENSHOTS_REV).join(app).join(format!("{key}.jpg"))
}
pub fn manifest(&self) -> PathBuf {
self.data.join("installed.json")
}
/// The launcher's own log files (`artcraft-launcher.log` and the previous runs').
pub fn logs(&self) -> PathBuf {
self.data.join("logs")
}
/// Where tarball installs (FreeBSD, Linux without AppImage) are unpacked: `<data>/apps/<app>`.
pub fn apps(&self) -> PathBuf {
self.data.join("apps")
}
/// The folder an app unpacked in [`Self::apps`] sees as its home: `<data>/apps/<app>/home`.
/// A Craft App keeps its settings in the XDG folders (`~/.config`, `~/.local/share`,
/// `~/.cache`), so pointing `HOME` and those variables here keeps every app's configuration
/// beside it, inside the folder the launcher owns.
#[cfg(target_os = "linux")]
pub fn app_home(&self, id: &str) -> PathBuf {
self.apps().join(id).join("home")
}
}
/// Writes `bytes` to `path` via a temporary file and a rename, so a crash never leaves half a file.
+149
View File
@@ -0,0 +1,149 @@
//! The pictures on an app's details page. They are not part of the launcher: opening a page
//! fetches them once and keeps them in the cache folder, so the next time (and any time offline)
//! they come from disk.
use std::path::Path;
use artcraft_launcher_core::details::{AppDetails, MAX_SCREENSHOT_BYTES, looks_like_jpeg};
use crate::http::Http;
use crate::paths::{Paths, write_atomic};
/// The picture saved at `saved`, if there is one and it's still a JPEG of a sane size. A damaged or
/// swapped file is ignored, and the picture is fetched again over it.
fn saved_copy(saved: &Path) -> Option<Vec<u8>> {
if std::fs::metadata(saved).ok()?.len() > MAX_SCREENSHOT_BYTES as u64 {
return None;
}
std::fs::read(saved).ok().filter(|bytes| looks_like_jpeg(bytes))
}
/// Why a picture couldn't be had.
#[derive(Debug, PartialEq, Eq)]
enum Failure {
/// This picture only: it isn't published, or what came back isn't a picture.
Picture(String),
/// The network or the server: the other pictures would fail the same way.
Network(String),
}
/// Downloads the picture at `url` and saves it at `saved`. A saved file that can't be written is
/// only a missed cache: the picture is still shown.
fn download(http: &Http, url: &str, saved: &Path) -> Result<Vec<u8>, Failure> {
let bytes = match http.get_bytes(url, MAX_SCREENSHOT_BYTES) {
Ok(Some(bytes)) => bytes,
Ok(None) => return Err(Failure::Picture("this picture isn't published".into())),
Err(why) => return Err(Failure::Network(why)),
};
// A captive portal's login page or a proxy's error page arrives as a 200: don't keep it.
if !looks_like_jpeg(&bytes) {
return Err(Failure::Picture("the answer isn't a JPEG picture".into()));
}
if let Err(e) = write_atomic(saved, &bytes) {
log::warn!("couldn't keep {}: {e}", saved.display());
}
Ok(bytes)
}
/// Every picture of `details`, lead first, handed to `emit` one by one as it is had. Saved copies
/// need no network. Once the network or the server has failed, the pictures still to fetch fail at
/// once with the same reason instead of each waiting for its own timeout; saved ones are still
/// served. A picture that is merely missing doesn't hold the others back.
pub fn fetch_all(http: Option<&Http>, paths: &Paths, details: &AppDetails, mut emit: impl FnMut(&'static str, Result<Vec<u8>, String>)) {
let mut failed: Option<String> = None;
for shot in details.shots {
let saved = paths.screenshot(details.id, shot.key);
if let Some(bytes) = saved_copy(&saved) {
emit(shot.key, Ok(bytes));
continue;
}
if let Some(why) = &failed {
emit(shot.key, Err(why.clone()));
continue;
}
let result = match (http, details.screenshot_url(shot.key)) {
(Some(http), Some(url)) => download(http, &url, &saved),
(None, _) => Err(Failure::Network("HTTPS isn't available".into())),
(_, None) => Err(Failure::Picture("this page has no such picture".into())),
};
let result = result.map_err(|failure| {
let why = match failure {
Failure::Network(why) => {
failed = Some(why.clone());
why
}
Failure::Picture(why) => why,
};
log::warn!("picture {}/{} unavailable: {why}", details.id, shot.key);
why
});
emit(shot.key, result);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::http::tests::{Reply, serve};
/// A body that starts like a JPEG, and its address on the test server.
fn jpeg() -> Vec<u8> {
let mut bytes = vec![0xFF, 0xD8, 0xFF, 0xE0];
bytes.extend((0..5_000u32).map(|i| (i % 251) as u8));
bytes
}
#[test]
fn a_downloaded_picture_is_saved_where_it_will_be_read_from() {
let dir = tempfile::tempdir().unwrap();
let saved = dir.path().join("x").join("pic.jpg");
let http = Http::new().unwrap();
let (url, _) = serve(jpeg(), vec![Reply::Full]);
assert_eq!(download(&http, &url, &saved).unwrap(), jpeg());
assert_eq!(saved_copy(&saved), Some(jpeg()));
}
#[test]
fn what_is_not_a_picture_is_not_kept() {
let dir = tempfile::tempdir().unwrap();
let saved = dir.path().join("pic.jpg");
let http = Http::new().unwrap();
let (url, _) = serve(b"<html>Sign in to the Wi-Fi</html>".to_vec(), vec![Reply::Full]);
assert_eq!(download(&http, &url, &saved), Err(Failure::Picture("the answer isn't a JPEG picture".into())));
assert!(!saved.exists());
let (url, _) = serve(Vec::new(), vec![Reply::Missing]);
assert_eq!(download(&http, &url, &saved), Err(Failure::Picture("this picture isn't published".into())));
assert!(!saved.exists());
// A server that can't be reached is the network's failure, not this picture's.
let gone = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap();
assert!(matches!(download(&http, &format!("http://{gone}/pic.jpg"), &saved), Err(Failure::Network(_))));
}
#[test]
fn a_damaged_saved_copy_is_ignored() {
let dir = tempfile::tempdir().unwrap();
let saved = dir.path().join("pic.jpg");
assert_eq!(saved_copy(&saved), None, "nothing saved");
std::fs::write(&saved, b"<html>oops</html>").unwrap();
assert_eq!(saved_copy(&saved), None, "not a JPEG");
std::fs::write(&saved, vec![0xFF; MAX_SCREENSHOT_BYTES + 1]).unwrap();
assert_eq!(saved_copy(&saved), None, "too big");
std::fs::write(&saved, jpeg()).unwrap();
assert_eq!(saved_copy(&saved), Some(jpeg()));
}
#[test]
fn saved_pictures_need_no_network_and_a_failure_ends_the_run() {
let paths = Paths::under(tempfile::tempdir().unwrap().path());
let page = AppDetails::by_id("photocraft").unwrap();
// The second picture is saved already; the first and the others need the network, which is not there.
let second = paths.screenshot(page.id, page.shots[1].key);
write_atomic(&second, &jpeg()).unwrap();
let mut seen = Vec::new();
fetch_all(None, &paths, page, |key, result| seen.push((key, result)));
let keys: Vec<_> = seen.iter().map(|(k, _)| *k).collect();
assert_eq!(keys, page.shots.iter().map(|s| s.key).collect::<Vec<_>>(), "every picture is answered, in order");
assert!(seen[0].1.is_err() && seen[2].1.is_err() && seen[3].1.is_err());
assert_eq!(seen[1].1.as_ref().unwrap(), &jpeg());
}
}
+1 -1
View File
@@ -169,7 +169,7 @@ pub fn update(
.ok_or_else(|| format!("ArtCraft Launcher {} has no {} for {}", release.version_label(), install.kind().label(), platform.label()))?;
let asset = asset.clone();
let mut job = Job { http, paths, stop, phase };
let (file, _) = install::download(release, &asset, &mut job)?;
let file = install::download(release, &asset, &mut job)?;
// The restart ends this process: nothing else may be under way.
last()?;
phase(JobPhase::Installing);
+2 -2
View File
@@ -14,7 +14,7 @@ workspace = true
artcraft-launcher-core = { workspace = true }
egui = { workspace = true }
egui_extras = { workspace = true, features = ["image", "svg"] }
image = { version = "0.25", default-features = false, features = ["png"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
serde = { workspace = true }
log = { workspace = true }
@@ -27,4 +27,4 @@ skrifa = { version = "0.44", default-features = false, features = ["std"] }
[dev-dependencies]
serde_json = { workspace = true }
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
image = { version = "0.25", default-features = false, features = ["png"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
+106 -45
View File
@@ -11,6 +11,7 @@ use artcraft_launcher_core::{Platform, SUITE};
use crate::i18n::set_current;
use crate::model::AppEntry;
use crate::motion;
use crate::settings::{Filter, Settings, SortKey, ViewMode};
use crate::theme::{self, Tokens};
@@ -52,6 +53,11 @@ pub trait Host {
fn set_login_item(&mut self, _on: bool) -> Result<(), String> {
Err("not supported here".into())
}
/// The OS asks for fewer animations (Windows' "Animation effects" switched off, macOS'
/// "Reduce motion"). Asked about once a second while the window draws, so it must be quick.
fn reduce_motion(&self) -> bool {
false
}
/// Quit the launcher for real (not just hide to the tray).
fn quit(&mut self) {}
}
@@ -70,6 +76,19 @@ pub(crate) enum Dialog {
LauncherUpdate(artcraft_launcher_core::Release),
}
impl Dialog {
/// What sets this dialog apart from the others, for egui's memory: each one keeps its own
/// size there, so a small prompt never opens at the height of the last dialog shown (#88).
pub(crate) fn key(&self) -> (&'static str, &str) {
match self {
Dialog::Notes(id) => ("notes", id),
Dialog::ConfirmUninstall(id) => ("uninstall", id),
Dialog::Settings => ("settings", ""),
Dialog::LauncherUpdate(r) => ("launcher-update", &r.tag),
}
}
}
pub struct LauncherUi {
pub(crate) backend: Box<dyn Backend>,
pub settings: Settings,
@@ -91,14 +110,30 @@ pub struct LauncherUi {
pub(crate) search: String,
pub(crate) toasts: Vec<Toast>,
pub(crate) dialog: Option<Dialog>,
/// The app whose details drawer is open (or closing: see `detail_shown`).
pub(crate) detail: Option<String>,
/// The app the drawer is drawn for: it outlives `detail` while the drawer slides out.
pub(crate) detail_shown: Option<String>,
/// Which screenshot the drawer shows.
pub(crate) detail_shot: usize,
/// The screenshot is enlarged over the whole window.
pub(crate) detail_zoom: bool,
/// The details pictures the backend has sent (or been asked for), by app and picture key.
pub(crate) pictures: std::collections::HashMap<(String, String), crate::details::Picture>,
started: bool,
applied_theme: Option<theme::ThemeChoice>,
/// The OS asks for fewer animations: what the host last said, and the context's time to ask
/// it again at.
pub(crate) reduce_motion: bool,
motion_recheck: f64,
/// What the context's styles were last set for.
applied_motion: Option<bool>,
/// Fonts registered with `set_fonts` only exist from the next frame on; drawing with the named
/// weights before then would abort.
fonts_ready: bool,
/// Whether the installed fonts include the system's Chinese font. Added the first time Chinese
/// is the language in use, then kept for the session.
cjk_fonts: bool,
/// The CJK language whose system font is installed, if any.
/// Set when such a language comes into use, then kept for the session.
pub(crate) cjk_font: Option<crate::i18n::Lang>,
sent_options: Option<BackendOptions>,
/// Unix seconds of the last look at what's installed (startup counts).
last_detect: i64,
@@ -109,12 +144,12 @@ pub struct LauncherUi {
pub(crate) auto_update_attempted: std::collections::BTreeSet<(String, String)>,
}
/// The language to draw in: the preference, or English where it needs a Chinese font the system
/// The language to draw in: the preference, or English where it needs a CJK font the system
/// doesn't have (Settings says so), rather than boxes for every character. The only place the
/// Chinese font gets loaded: when Chinese is selected, or Auto resolves to it.
/// CJK font gets loaded: when such a language is selected, or Auto resolves to it.
pub(crate) fn effective_lang(pref: &str) -> crate::i18n::Lang {
let lang = crate::i18n::Lang::from_pref(pref);
if lang.needs_cjk_font() && !theme::cjk_font_available() { crate::i18n::Lang::EN } else { lang }
if lang.needs_cjk_font() && !theme::cjk_font_available(lang) { crate::i18n::Lang::EN } else { lang }
}
/// How often to look again at what's installed, to notice apps installed or removed outside
@@ -125,8 +160,9 @@ const RESCAN_ON_FOCUS_AFTER: i64 = 30;
impl LauncherUi {
pub fn new(ctx: &egui::Context, backend: Box<dyn Backend>, settings: Settings) -> Self {
let cjk_fonts = effective_lang(&settings.language).needs_cjk_font();
theme::install_fonts(ctx, cjk_fonts);
let lang = effective_lang(&settings.language);
let cjk_font = lang.needs_cjk_font().then_some(lang);
theme::install_fonts(ctx, cjk_font);
egui_extras::install_image_loaders(ctx);
let caps = backend.capabilities();
let platform = backend.platform();
@@ -147,10 +183,18 @@ impl LauncherUi {
search: String::new(),
toasts: Vec::new(),
dialog: None,
detail: None,
detail_shown: None,
detail_shot: 0,
detail_zoom: false,
pictures: std::collections::HashMap::new(),
started: false,
applied_theme: None,
reduce_motion: false,
motion_recheck: 0.0,
applied_motion: None,
fonts_ready: false,
cjk_fonts,
cjk_font,
sent_options: None,
last_detect: 0,
was_focused: true,
@@ -158,19 +202,31 @@ impl LauncherUi {
}
}
/// Moves the UI to another window's context: fonts, image loaders and the theme belong to a
/// context, and toasts are timed on its clock.
/// Moves the UI to another window's context: fonts, image loaders, the theme and the animation
/// timings belong to a context, and toasts and the next look at the OS's animation setting are
/// timed on its clock.
pub fn attach(&mut self, ctx: &egui::Context) {
theme::install_fonts(ctx, self.cjk_fonts);
theme::install_fonts(ctx, self.cjk_font);
egui_extras::install_image_loaders(ctx);
self.fonts_ready = false;
self.applied_theme = None;
self.applied_motion = None;
self.motion_recheck = 0.0;
self.toasts.clear();
}
/// Installed apps with an update waiting (the tray shows the count).
/// Input on its way to egui (the shell's `raw_input_hook`): with fewer animations asked for,
/// a mouse wheel scrolls at once instead of gliding.
pub fn raw_input(&self, ctx: &egui::Context, raw: &mut egui::RawInput) {
if self.reduce_motion {
motion::unsmooth_wheel(ctx, raw);
}
}
/// Installed apps with an update waiting (the tray shows the count). Hidden apps are left out,
/// so the badge matches the Updates list.
pub fn update_count(&self) -> usize {
self.entries.iter().filter(|e| e.status(self.platform, self.caps.manage_apps) == AppStatus::UpdateAvailable).count()
self.entries.iter().filter(|e| self.in_filter(e, Filter::Updates)).count()
}
/// Something is downloading or installing, the launcher's own update included: quitting now
@@ -269,6 +325,10 @@ impl LauncherUi {
}
true
}
Event::Screenshot { app, key, result } => {
self.pictures.insert((app, key), crate::details::Picture::from_result(result));
true
}
Event::Detected { app, installed } => {
if let Some(e) = self.entry_mut(&app) {
e.installed = installed;
@@ -363,7 +423,7 @@ impl LauncherUi {
fn announce_updates(&mut self, host: &mut dyn Host) {
let mut fresh = Vec::new();
for e in &self.entries {
if e.status(self.platform, self.caps.manage_apps) != AppStatus::UpdateAvailable {
if !self.in_filter(e, Filter::Updates) {
continue;
}
let Some(v) = e.latest_label() else { continue };
@@ -400,7 +460,7 @@ impl LauncherUi {
let to_update: Vec<(String, String)> = self
.entries
.iter()
.filter(|e| e.status(self.platform, true) == AppStatus::UpdateAvailable && e.job.is_none() && !e.ready)
.filter(|e| self.in_filter(e, Filter::Updates) && e.job.is_none() && !e.ready)
.filter_map(|e| {
let v = e.latest_label()?;
if self.auto_update_attempted.contains(&(e.app.id.to_string(), v.clone())) { None } else { Some((e.app.id.to_string(), v)) }
@@ -431,10 +491,10 @@ impl LauncherUi {
// Set the current language for this frame based on settings.
let lang = effective_lang(&self.settings.language);
set_current(lang);
// `effective_lang` only picks Chinese once its font has loaded.
if lang.needs_cjk_font() && !self.cjk_fonts {
theme::install_fonts(&ctx, true);
self.cjk_fonts = true;
// `effective_lang` only picks a CJK language once its font has loaded.
if lang.needs_cjk_font() && self.cjk_font != Some(lang) {
theme::install_fonts(&ctx, Some(lang));
self.cjk_font = Some(lang);
self.fonts_ready = false;
}
if !self.fonts_ready {
@@ -446,6 +506,15 @@ impl LauncherUi {
theme::apply(&ctx, self.settings.theme);
self.applied_theme = Some(self.settings.theme);
}
let now = ctx.input(|i| i.time);
if now >= self.motion_recheck {
self.reduce_motion = host.reduce_motion();
self.motion_recheck = now + motion::RECHECK_EVERY;
}
if self.applied_motion != Some(self.reduce_motion) {
motion::apply(&ctx, self.reduce_motion);
self.applied_motion = Some(self.reduce_motion);
}
let tokens = Tokens::get(&ctx);
if self.entries.iter().any(|e| e.job.is_some()) || self.checking {
ctx.request_repaint_after(Duration::from_millis(50));
@@ -485,10 +554,10 @@ impl LauncherUi {
ViewMode::Table => self.table(ui, &ids),
}
}
self.trademark_notice(ui);
ui.add_space(16.0);
});
});
self.details_drawer(&ctx);
self.dialogs(&ctx, host);
self.draw_toasts(&ctx);
if host.title_bar() == TitleBar::Custom {
@@ -504,17 +573,8 @@ impl LauncherUi {
.iter()
.enumerate()
.filter(|(_, e)| {
let s = e.status(self.platform, self.caps.manage_apps);
let keep = match self.settings.filter {
Filter::All => true,
Filter::Installed => s.is_installed(),
Filter::Updates => s == AppStatus::UpdateAvailable,
Filter::NotInstalled => !s.is_installed(),
};
keep && (needle.is_empty()
|| e.app.name.to_lowercase().contains(&needle)
|| e.app.tagline.to_lowercase().contains(&needle)
|| e.app.like.to_lowercase().contains(&needle))
self.in_filter(e, self.settings.filter)
&& (needle.is_empty() || e.app.name.to_lowercase().contains(&needle) || e.app.tagline.to_lowercase().contains(&needle))
})
.collect();
let platform = self.platform;
@@ -534,7 +594,6 @@ impl LauncherUi {
let ord = match self.settings.sort {
SortKey::Suite => dir(ia.cmp(ib)),
SortKey::Name => dir(a.app.name.cmp(b.app.name)),
SortKey::InspiredBy => dir(a.app.like.cmp(b.app.like)),
SortKey::Status => {
dir(crate::model::status_rank(&a.status(platform, manage)).cmp(&crate::model::status_rank(&b.status(platform, manage))))
}
@@ -582,18 +641,20 @@ impl LauncherUi {
}
pub(crate) fn count(&self, filter: Filter) -> usize {
self.entries
.iter()
.filter(|e| {
let s = e.status(self.platform, self.caps.manage_apps);
match filter {
Filter::All => true,
Filter::Installed => s.is_installed(),
Filter::Updates => s == AppStatus::UpdateAvailable,
Filter::NotInstalled => !s.is_installed(),
}
})
.count()
self.entries.iter().filter(|e| self.in_filter(e, filter)).count()
}
/// Whether `entry` belongs in `filter`. Hidden apps stay out of every list except Hidden.
pub(crate) fn in_filter(&self, entry: &AppEntry, filter: Filter) -> bool {
let hidden = self.settings.hidden.contains(entry.app.id);
let status = entry.status(self.platform, self.caps.manage_apps);
match filter {
Filter::Hidden => hidden,
Filter::All => !hidden,
Filter::Installed => !hidden && status.is_installed(),
Filter::Updates => !hidden && status == AppStatus::UpdateAvailable,
Filter::NotInstalled => !hidden && !status.is_installed(),
}
}
fn draw_toasts(&mut self, ctx: &egui::Context) {
+189 -18
View File
@@ -1,43 +1,86 @@
//! A Chinese font from the operating system, for the Simplified Chinese interface. The bundled
//! faces have no CJK glyphs, and a CJK font is too large to ship (10 MB or more), so the desktop
//! build borrows the one every system that can show Chinese already has. Desktop only.
//! A CJK font from the operating system, for the CJK interface languages. The bundled faces have
//! no CJK glyphs, and a CJK font is too large to ship (10 MB or more), so the desktop build
//! borrows the one every system that can show them already has. Desktop only.
use std::path::{Path, PathBuf};
use egui::epaint::text::VariationCoords;
use egui::{FontData, FontTweak};
use skrifa::instance::{LocationRef, Size};
use skrifa::{FontRef, MetadataProvider, string::StringId};
use crate::i18n::Script;
/// Larger files aren't read: PingFang, the largest candidate, is about 80 MB.
const MAX_FONT_BYTES: u64 = 100 * 1024 * 1024;
/// Faces looked at in one collection file (PingFang has 24).
const MAX_FACES: u32 = 64;
/// A font file that may be installed, and the family of its Simplified Chinese face.
/// The bundled body font (Inter), whose baseline a fallback face is nudged onto.
const BODY_FONT: &[u8] = include_bytes!("../../../assets/fonts/Inter-Variable.ttf");
/// `(ascent, ascent + |descent| + leading) / units_per_em` of face `index` in `data`.
///
/// egui puts a fallback glyph's baseline at that face's own ascent and only centres the two line
/// boxes (`galley_from_rows`), so a face whose metrics differ from the body font's rides off the
/// baseline: a face with a half-em line gap, for one, sat about a quarter em high. [`load`]
/// cancels the difference with a `FontTweak`.
fn face_metrics(data: &[u8], index: u32) -> (f32, f32) {
let Ok(font) = FontRef::from_index(data, index) else { return (0.0, 0.0) };
let metrics = font.metrics(Size::unscaled(), LocationRef::default());
let upem = f32::from(metrics.units_per_em);
if upem <= 0.0 {
return (0.0, 0.0);
}
(metrics.ascent / upem, (metrics.ascent - metrics.descent + metrics.leading) / upem)
}
/// The script's name, for the log lines.
fn script_name(script: Script) -> &'static str {
match script {
Script::Chinese => "Chinese",
Script::Japanese => "Japanese",
}
}
/// The families looked for, for the "none found" log line.
fn script_families(script: Script) -> &'static str {
match script {
Script::Chinese => "PingFang SC, Hiragino Sans GB, Heiti SC, Microsoft YaHei, SimSun, Noto Sans CJK SC and WenQuanYi",
Script::Japanese => "Hiragino Sans, Yu Gothic, Meiryo, MS Gothic, Noto Sans CJK JP, Noto Sans JP, IPAGothic and TakaoGothic",
}
}
/// A font file that may be installed, and the family of its face for this script.
struct Candidate {
path: PathBuf,
family: &'static str,
}
/// The system's Simplified Chinese font, or `None` (logged) when it has none this knows about.
/// The system's font for `script`, or `None` (logged) when it has none this knows about.
/// Reads the file (once per process: the caller caches the answer).
pub(crate) fn load() -> Option<FontData> {
for Candidate { path, family } in candidates() {
pub(crate) fn load(script: Script) -> Option<FontData> {
for Candidate { path, family } in candidates(script) {
let Some(bytes) = read(&path) else { continue };
let Some(index) = face_index(&bytes, family) else {
log::debug!("no {family} face in {}", path.display());
continue;
};
log::info!("Chinese text uses {family} from {} (face {index})", path.display());
// Regular weight, in case the face is variable (Noto Sans CJK VF).
let tweak = FontTweak { coords: VariationCoords::new([(*b"wght", 400.0)]), ..Default::default() };
log::info!("{} text uses {family} from {} (face {index})", script_name(script), path.display());
// Regular weight, in case the face is variable (Noto Sans CJK VF), and a nudge onto the
// body font's baseline (see `face_metrics`).
let (body_ascent, body_height) = face_metrics(BODY_FONT, 0);
let (face_ascent, face_height) = face_metrics(&bytes, index);
let tweak = FontTweak {
coords: VariationCoords::new([(*b"wght", 400.0)]),
y_offset_factor: (body_ascent - face_ascent) + 0.5 * (face_height - body_height),
..Default::default()
};
let mut font = FontData::from_owned(bytes).tweak(tweak);
font.index = index;
return Some(font);
}
log::warn!(
"no Chinese font found on this system (looked for PingFang, Hiragino Sans GB, Heiti, Microsoft YaHei, SimSun, Noto Sans CJK and WenQuanYi)"
);
log::warn!("no {} font found on this system (looked for {})", script_name(script), script_families(script));
None
}
@@ -75,8 +118,16 @@ pub(crate) fn face_index(data: &[u8], family: &str) -> Option<u32> {
first
}
/// Where each system keeps a font for `script`, best first.
fn candidates(script: Script) -> Vec<Candidate> {
match script {
Script::Chinese => chinese_candidates(),
Script::Japanese => japanese_candidates(),
}
}
/// Where each system keeps a Simplified Chinese font, best first.
fn candidates() -> Vec<Candidate> {
fn chinese_candidates() -> Vec<Candidate> {
let mut out = Vec::new();
let mut add = |path: PathBuf, family: &'static str| out.push(Candidate { path, family });
#[cfg(target_os = "macos")]
@@ -129,6 +180,81 @@ fn candidates() -> Vec<Candidate> {
out
}
/// Where each system keeps a Japanese font, best first.
fn japanese_candidates() -> Vec<Candidate> {
let mut out = Vec::new();
let mut add = |path: PathBuf, family: &'static str| out.push(Candidate { path, family });
#[cfg(target_os = "macos")]
{
// Hiragino Sans (its file keeps the Japanese name); it may be a downloaded font asset.
for file in ["ヒラギノ角ゴシック W3.ttc", "ヒラギノ角ゴシック W4.ttc", "ヒラギノ角ゴシック W6.ttc"] {
add(Path::new("/System/Library/Fonts").join(file), "Hiragino Sans");
for path in mac_font_assets(file) {
add(path, "Hiragino Sans");
}
}
add("/System/Library/Fonts/ヒラギノ丸ゴ ProN W4.ttc".into(), "Hiragino Maru Gothic ProN");
}
#[cfg(target_os = "windows")]
{
let root = std::env::var_os("SystemRoot").map_or_else(|| PathBuf::from(r"C:\Windows"), PathBuf::from);
add(root.join("Fonts").join("YuGothR.ttc"), "Yu Gothic");
add(root.join("Fonts").join("meiryo.ttc"), "Meiryo");
add(root.join("Fonts").join("msgothic.ttc"), "MS Gothic");
}
#[cfg(all(unix, not(target_os = "macos")))]
{
// Debian/Ubuntu, Arch, Fedora (static and variable) and FreeBSD's ports.
const NOTO_DIRS: [&str; 8] = [
"/usr/share/fonts/opentype/noto",
"/usr/share/fonts/noto-cjk",
"/usr/share/fonts/google-noto-cjk",
"/usr/share/fonts/google-noto-sans-cjk-fonts",
"/usr/share/fonts/google-noto-sans-cjk-vf-fonts",
"/usr/share/fonts/noto",
"/usr/local/share/fonts/noto",
"/usr/local/share/fonts/noto-cjk",
];
for file in ["NotoSansCJK-Regular.ttc", "NotoSansCJKjp-Regular.otf", "NotoSansCJK-VF.ttc"] {
for dir in NOTO_DIRS {
add(Path::new(dir).join(file), "Noto Sans CJK JP");
}
}
// Standalone Noto Sans JP, and the classic Japanese faces (ipafont-gothic, Takao, VL).
const JP_DIRS: [&str; 8] = [
"/usr/share/fonts/opentype/noto",
"/usr/share/fonts/truetype/noto",
"/usr/share/fonts/noto",
"/usr/share/fonts/truetype/ipafont-gothic",
"/usr/share/fonts/truetype/ipafont",
"/usr/share/fonts/truetype/takao-gothic",
"/usr/share/fonts/truetype/vlgothic",
"/usr/local/share/fonts",
];
for file in ["NotoSansJP-Regular.otf", "NotoSansJP-VF.ttf"] {
for dir in JP_DIRS {
add(Path::new(dir).join(file), "Noto Sans JP");
}
}
for (file, family) in [("ipag.ttf", "IPAGothic"), ("ipagp.ttf", "IPAPGothic")] {
for dir in JP_DIRS {
add(Path::new(dir).join(file), family);
}
}
for (file, family) in [("TakaoGothic.ttf", "TakaoGothic"), ("TakaoPGothic.ttf", "TakaoPGothic")] {
for dir in JP_DIRS {
add(Path::new(dir).join(file), family);
}
}
for (file, family) in [("VL-Gothic-Regular.ttf", "VL Gothic"), ("VL-PGothic-Regular.ttf", "VL PGothic")] {
for dir in JP_DIRS {
add(Path::new(dir).join(file), family);
}
}
}
out
}
/// `file` in macOS's downloaded font assets:
/// `/System/Library/AssetsV2/com_apple_MobileAsset_Font*/*.asset/AssetData/<file>`.
#[cfg(target_os = "macos")]
@@ -163,6 +289,49 @@ mod tests {
assert_eq!(face_index(&[0u8; 64], "PingFang SC"), None);
}
/// The baseline nudge reads a face's ascent and height; the reference is Inter's.
#[test]
fn face_metrics_reads_font_metrics() {
let (ascent, height) = face_metrics(BODY_FONT, 0);
assert!((ascent - 0.9688).abs() < 0.005, "{ascent}");
assert!((height - 1.21).abs() < 0.005, "{height}");
assert_eq!(face_metrics(b"not a font", 0), (0.0, 0.0));
}
/// A system CJK face sits on the Latin baseline, thanks to the nudge in [`load`]. Without a
/// CJK font (CI, the web) there is nothing to check.
#[test]
fn cjk_sits_on_the_latin_baseline() {
use egui::{Color32, FontFamily, FontId};
for info in crate::i18n::LANGUAGES.iter().filter(|l| l.script.is_some()) {
let Some(lang) = crate::i18n::Lang::from_code(info.code) else { continue };
let Some(font) = crate::theme::cjk_font(lang) else { continue };
let y_offset_factor = font.tweak.y_offset_factor;
// A character the language's own catalog uses, so the sample fits its script: the face
// must draw it, on the Latin baseline.
let Some(sample) =
info.source.lines().filter_map(|line| line.split('\t').nth(2)).flat_map(str::chars).find(|c| !c.is_ascii() && c.is_alphabetic())
else {
continue;
};
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx, Some(lang));
let mut output = ctx.run_ui(egui::RawInput::default(), |_| {});
output.textures_delta.clear();
let size = 20.0;
let font_id = FontId::new(size, FontFamily::Proportional);
let galley = ctx.fonts_mut(|f| f.layout_no_wrap(format!("A{sample}"), font_id.clone(), Color32::WHITE));
assert!(ctx.fonts_mut(|f| f.has_glyphs(&font_id, &sample.to_string())), "{}: no glyph for {sample:?}", lang.code());
let glyphs = &galley.rows[0].row.glyphs;
let pos = |c: char| glyphs.iter().find(|g| g.chr == c).map(|g| g.pos.y);
// `pos.y` is the baseline before the nudge (which moves the ink, not `pos`).
if let (Some(latin), Some(cjk)) = (pos('A'), pos(sample)) {
let rendered = cjk + y_offset_factor * size;
assert!((rendered - latin).abs() < 1.0, "{}: latin {latin}, cjk {rendered}, nudge {y_offset_factor}", lang.code());
}
}
}
#[test]
fn face_index_finds_families_in_real_fonts() {
// The bundled Latin faces stand in for system fonts the test machine may not have.
@@ -174,10 +343,12 @@ mod tests {
/// Whatever this machine has loads without panicking, and a found face really is the family's.
#[test]
fn system_font_loads_when_present() {
if let Some(font) = load() {
let family = candidates().into_iter().find(|c| c.path.is_file()).map(|c| c.family);
assert!(family.is_some());
assert!(FontRef::from_index(&font.font, font.index).is_ok());
for script in Script::ALL {
if let Some(font) = load(script) {
let family = candidates(script).into_iter().find(|c| c.path.is_file()).map(|c| c.family);
assert!(family.is_some());
assert!(FontRef::from_index(&font.font, font.index).is_ok());
}
}
}
}
+475
View File
@@ -0,0 +1,475 @@
//! An app's details drawer: it slides in from the right over a dimmed grid. Hand-written copy and
//! screenshots come from `core::details`; the live facts (version, date, download size, release
//! notes) from the app's latest GitHub release.
use artcraft_launcher_core::CraftApp;
use std::sync::Arc;
use artcraft_launcher_core::details::AppDetails;
use artcraft_launcher_core::platform::pick_asset;
use artcraft_launcher_core::protocol::Command;
use egui::{Color32, CornerRadius, Id, Margin, Order, Rect, RichText, Sense, Stroke, Ui, emath::easing, pos2, vec2};
use crate::app::LauncherUi;
use crate::model::AppEntry;
use crate::theme::{self, Tokens, rgb, tint};
use crate::views::{FOOTER_H, HEADER_H, date_text};
use crate::widgets::{self, Glyph, Kind};
/// Screenshots are 8:5.
const SHOT_ASPECT: f32 = 0.625;
const PAD: f32 = 24.0;
/// As tall as the name, headline, "Like …" and version lines beside it, so the icon fills the row.
const ICON: f32 = 112.0;
/// The previous / next buttons over a picture.
const ARROW: f32 = 36.0;
const SLIDE_SECS: f32 = 0.22;
/// A details picture, as the backend has sent it.
pub(crate) enum Picture {
/// Asked for, not here yet.
Loading,
Ready(Arc<[u8]>),
/// Why it couldn't be had (offline, not published…).
Failed(String),
}
impl Picture {
pub(crate) fn from_result(result: Result<Vec<u8>, String>) -> Self {
match result {
Ok(bytes) => Self::Ready(bytes.into()),
Err(why) => Self::Failed(why),
}
}
}
impl LauncherUi {
/// Asks the backend for the pictures of `app`'s page that we don't have. Pictures that failed
/// earlier are asked for again: opening the page is the "try again".
fn request_pictures(&mut self, app: &str) {
let Some(details) = AppDetails::by_id(app) else { return };
let mut missing = false;
for shot in details.shots {
let key = (details.id.to_string(), shot.key.to_string());
if !matches!(self.pictures.get(&key), Some(Picture::Ready(_) | Picture::Loading)) {
self.pictures.insert(key, Picture::Loading);
missing = true;
}
}
if missing {
self.send(Command::FetchScreenshots { app: details.id.to_string() });
}
}
/// Why the picture couldn't be had, if that is what happened to it.
fn failure(&self, app: &str, key: &str) -> Option<String> {
match self.pictures.get(&(app.to_string(), key.to_string())) {
Some(Picture::Failed(why)) => Some(why.clone()),
_ => None,
}
}
/// The picture to draw, once it has arrived.
fn picture_source(&self, app: &str, key: &str) -> Option<egui::ImageSource<'static>> {
match self.pictures.get(&(app.to_string(), key.to_string())) {
Some(Picture::Ready(bytes)) => Some(egui::ImageSource::Bytes {
uri: format!("bytes://screenshots/{app}/{key}.jpg").into(),
bytes: egui::load::Bytes::Shared(bytes.clone()),
}),
_ => None,
}
}
/// Draws the drawer for `self.detail`, sliding in and out. Call it every frame, after the
/// main panels, so it covers them.
pub(crate) fn details_drawer(&mut self, ctx: &egui::Context) {
if let Some(id) = &self.detail
&& self.detail_shown.as_ref() != Some(id)
{
self.detail_shown = Some(id.clone());
self.detail_shot = 0;
self.detail_zoom = false;
let app = id.clone();
self.request_pictures(&app);
}
// Asked every frame, even while closed, so the first opening starts from 0 and slides.
let open = self.detail.is_some();
let slide = if self.reduce_motion { 0.0 } else { SLIDE_SECS };
let k = ctx.animate_bool_with_time_and_easing(Id::new("details-slide"), open, slide, easing::cubic_out);
let Some(id) = self.detail_shown.clone() else { return };
let Some(details) = AppDetails::by_id(&id) else {
self.detail = None;
self.detail_shown = None;
return;
};
if k <= 0.0 && !open {
self.detail_shown = None;
return;
}
let Some(entry) = self.entry(&id).cloned() else { return };
if !open {
self.detail_zoom = false;
}
self.details_keys(ctx, details, open);
let t = Tokens::get(ctx);
let area = drawer_area(ctx);
let width = (area.width() * 0.5).clamp(440.0, 680.0).min(area.width());
let left = area.right() - width * k;
self.details_scrim(ctx, area, k, &t);
// Not constrained to the screen: the drawer is partly off it while it slides, and a constrained
// area would keep its contents in place while only the panel behind them moved.
egui::Area::new(Id::new("details-drawer")).order(Order::Foreground).fixed_pos(pos2(left, area.top())).constrain(false).show(ctx, |ui| {
let panel = Rect::from_min_size(pos2(left, area.top()), vec2(width, area.height()));
let painter = ui.painter();
painter.add(theme::shadow(&t).as_shape(panel, CornerRadius::ZERO));
painter.rect_filled(panel, CornerRadius::ZERO, t.bg);
painter.vline(panel.left() + 0.5, panel.y_range(), Stroke::new(1.0, t.line_strong));
ui.set_min_size(panel.size());
ui.set_max_size(panel.size());
// A bar that stays on screen says there is more below the fold. egui's default one is
// floating and invisible until the pointer is over it, so the drawer uses the solid style.
ui.spacing_mut().scroll = egui::style::ScrollStyle::solid();
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.spacing_mut().item_spacing.y = 8.0;
self.details_hero(ui, &entry, details, &t);
egui::Frame::new().inner_margin(Margin::same(PAD as i8)).show(ui, |ui| {
ui.set_width(ui.available_width());
self.gallery(ui, details, &t, rgb(entry.app.color));
ui.add_space(8.0);
for p in details.paragraphs() {
ui.add(egui::Label::new(RichText::new(p).size(14.5).color(t.ink)).wrap());
}
ui.add_space(14.0);
features(ui, details, entry.app, &t);
ui.add_space(14.0);
self.release_section(ui, &entry, details, &t);
});
});
});
if self.detail_zoom && open {
self.lightbox(ctx, details, &t);
}
}
/// Esc closes the enlarged picture first, then the drawer; ← and → flip through the pictures.
/// Nothing happens under a dialog, and the arrows leave a text field alone.
fn details_keys(&mut self, ctx: &egui::Context, details: &AppDetails, open: bool) {
if !open || self.dialog.is_some() {
return;
}
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
if self.detail_zoom {
self.detail_zoom = false;
} else {
self.detail = None;
}
}
let shots = details.shots.len();
if shots > 1 && !ctx.egui_wants_keyboard_input() {
if ctx.input(|i| i.key_pressed(egui::Key::ArrowRight)) {
self.flip_shot(shots, true);
}
if ctx.input(|i| i.key_pressed(egui::Key::ArrowLeft)) {
self.flip_shot(shots, false);
}
}
}
/// Moves to the next (or previous) of `count` pictures, wrapping around.
fn flip_shot(&mut self, count: usize, forward: bool) {
if count < 2 {
return;
}
let at = self.detail_shot.min(count - 1);
self.detail_shot = if forward { (at + 1) % count } else { (at + count - 1) % count };
}
/// The page behind the drawer, dimmed (egui can't blur what is under a layer); a click on it closes the drawer.
fn details_scrim(&mut self, ctx: &egui::Context, area: Rect, k: f32, t: &Tokens) {
egui::Area::new(Id::new("details-scrim")).order(Order::Middle).fixed_pos(area.min).show(ctx, |ui| {
let (rect, resp) = ui.allocate_exact_size(area.size(), Sense::click());
ui.painter().rect_filled(rect, CornerRadius::ZERO, theme::scrim(t, k));
if resp.on_hover_cursor(egui::CursorIcon::Default).clicked() {
self.detail = None;
}
});
}
/// The drawer's top: a wash of the app's colour under a 2 px rule, the close button, the name,
/// version and main button, and the app's links.
fn details_hero(&mut self, ui: &mut Ui, entry: &AppEntry, details: &AppDetails, t: &Tokens) {
let app = entry.app;
let app_color = rgb(app.color);
let status = entry.status(self.platform, self.caps.manage_apps);
let hero = egui::Frame::new().fill(tint(app_color, if t.dark { 0.09 } else { 0.07 })).inner_margin(Margin::same(PAD as i8)).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
widgets::hud(ui, tl!("App details"), t.faint);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::glyph_button(ui, Glyph::Close, tl!("Close (Esc)")).clicked() {
self.detail = None;
}
});
});
ui.add_space(10.0);
ui.horizontal(|ui| {
if let Some(icon) = crate::icons::app(app.id) {
ui.add(egui::Image::new(icon).fit_to_exact_size(vec2(ICON, ICON)));
}
ui.add_space(8.0);
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 5.0;
widgets::wordmark(ui, app, 32.0);
ui.add(egui::Label::new(RichText::new(details.headline).size(15.0).color(t.ink)).wrap());
// "Version 0.5.0 · Oct 8, 2026", or the installed-versus-latest line.
let version = crate::views::version_line(entry, &status);
if !version.trim().is_empty() {
ui.label(RichText::new(version).size(12.5).color(t.muted));
}
});
});
ui.add_space(14.0);
// The menu leaves out the links: they are the buttons right below.
let row_w = ui.available_width();
ui.horizontal(|ui| self.actions_in(ui, entry, &status, row_w, false));
link_buttons(ui, app, row_w);
});
let r = hero.response.rect;
ui.painter().rect_filled(Rect::from_min_size(r.min, vec2(r.width(), 2.0)), CornerRadius::ZERO, app_color);
ui.painter().hline(r.x_range(), r.bottom() - 0.5, t.hairline());
}
/// The facts of the latest release in cells, then its notes.
fn release_section(&self, ui: &mut Ui, entry: &AppEntry, details: &AppDetails, t: &Tokens) {
widgets::eyebrow(ui, "02", tl!("Latest release"));
ui.add_space(4.0);
let Some(r) = entry.release() else {
ui.label(RichText::new(tl!("No release published yet")).color(t.muted));
return;
};
let size = self.platform.and_then(|p| {
let stems: Vec<&str> = entry.app.stems().collect();
pick_asset(r, &stems, p, p.default_kinds()).map(|(a, _)| widgets::human_bytes(a.size))
});
let facts: Vec<(&str, String)> = [
(tl!("Version"), Some(r.version.as_ref().map_or_else(|| r.tag.clone(), |v| v.to_string()))),
(tl!("Released"), r.published_at.map(date_text)),
(tl!("Download"), size),
(tl!("Platforms"), Some(details.platforms.to_string())),
]
.into_iter()
.filter_map(|(label, value)| value.map(|v| (label, v)))
.collect();
for pair in facts.chunks(2) {
ui.columns(2, |cols| {
for (col, (label, value)) in cols.iter_mut().zip(pair) {
egui::Frame::new().stroke(t.hairline()).inner_margin(Margin::same(12)).show(col, |ui| {
ui.set_width(ui.available_width());
ui.spacing_mut().item_spacing.y = 2.0;
widgets::hud(ui, label, t.faint);
ui.add(egui::Label::new(RichText::new(value).size(14.0).color(t.ink_strong)).wrap());
});
}
});
}
ui.add_space(8.0);
crate::notes::show(ui, &r.notes);
}
/// The current screenshot, large, over the whole window; ‹ › flip through them, a click or Esc closes it.
fn lightbox(&mut self, ctx: &egui::Context, details: &AppDetails, t: &Tokens) {
let count = details.shots.len();
let at = self.detail_shot.min(count.saturating_sub(1));
let Some(shot) = details.shots.get(at) else {
self.detail_zoom = false;
return;
};
let src = self.picture_source(details.id, shot.key);
let failed = self.failure(details.id, shot.key);
let full = ctx.content_rect();
egui::Area::new(Id::new("details-lightbox")).order(Order::Tooltip).fixed_pos(full.min).show(ctx, |ui| {
let (rect, backdrop) = ui.allocate_exact_size(full.size(), Sense::click());
ui.painter().rect_filled(rect, CornerRadius::ZERO, theme::backdrop());
// As large as the window allows, but no larger than 1400 points: the files are 1200 px wide.
let room = rect.shrink2(vec2(112.0, 96.0));
let w = room.width().min(room.height() / SHOT_ASPECT).clamp(1.0, 1400.0);
let pic = Rect::from_center_size(rect.center(), vec2(w, w * SHOT_ASPECT));
match src {
Some(src) => egui::Image::new(src).paint_at(ui, pic),
// Flipped to a picture that hasn't arrived: the same frame, saying so.
None => placeholder(ui, pic, Id::new("lightbox-placeholder"), failed.as_deref(), t, true),
}
ui.painter().rect_stroke(pic, CornerRadius::ZERO, Stroke::new(1.0, theme::backdrop_ink(40)), egui::StrokeKind::Outside);
let caption = if count > 1 { format!("{} · {}/{count}", shot.caption, at + 1) } else { shot.caption.to_string() };
let ink = theme::backdrop_ink(190);
let galley = widgets::hud_galley(ui, &caption, ink);
let caption_at = pos2(rect.center().x - galley.size().x / 2.0, pic.bottom() + 14.0);
ui.painter().galley(caption_at, galley, ink);
if count > 1 {
let y = pic.center().y - ARROW / 2.0;
let prev = Rect::from_min_size(pos2(pic.left() - 16.0 - ARROW, y), vec2(ARROW, ARROW));
let next = Rect::from_min_size(pos2(pic.right() + 16.0, y), vec2(ARROW, ARROW));
if arrow_button(ui, prev, Id::new("zoom-prev"), true, tl!("Previous picture (←)"), t) {
self.flip_shot(count, false);
}
if arrow_button(ui, next, Id::new("zoom-next"), false, tl!("Next picture (→)"), t) {
self.flip_shot(count, true);
}
}
let close =
Rect::from_min_size(pos2(rect.right() - 16.0 - theme::CONTROL_H, rect.top() + 16.0), vec2(theme::CONTROL_H, theme::CONTROL_H));
let mut done = backdrop.clicked();
ui.scope_builder(egui::UiBuilder::new().max_rect(close), |ui| {
done |= widgets::glyph_button(ui, Glyph::Close, tl!("Close (Esc)")).clicked();
});
if done {
self.detail_zoom = false;
}
});
}
/// The lead screenshot, its caption, and a row of thumbnails to switch between them. A picture
/// that hasn't arrived is a frame of the same size saying so, so nothing moves when it does.
fn gallery(&mut self, ui: &mut Ui, details: &AppDetails, t: &Tokens, app_color: Color32) {
let Some(&current) = details.shots.get(self.detail_shot).or(details.shots.first()) else { return };
let w = ui.available_width();
let pic = match self.picture_source(details.id, current.key) {
Some(lead) => {
let image = ui.add(egui::Image::new(lead).fit_to_exact_size(vec2(w, w * SHOT_ASPECT)).sense(Sense::click()));
image.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Enlarge picture")));
let rect = image.rect;
// The arrows below are added after the picture, so they keep their own clicks.
if image.on_hover_cursor(egui::CursorIcon::ZoomIn).on_hover_text(tl!("Click to enlarge")).clicked() {
self.detail_zoom = true;
}
rect
}
None => {
let (rect, _) = ui.allocate_exact_size(vec2(w, w * SHOT_ASPECT), Sense::hover());
let failed = self.failure(details.id, current.key);
placeholder(ui, rect, Id::new("lead-placeholder"), failed.as_deref(), t, false);
rect
}
};
ui.painter().rect_stroke(pic, CornerRadius::ZERO, t.hairline(), egui::StrokeKind::Inside);
let count = details.shots.len();
if count < 2 {
widgets::hud(ui, current.caption, t.faint);
return;
}
// Previous and next, over the sides of the picture.
let y = pic.center().y - ARROW / 2.0;
let prev = Rect::from_min_size(pos2(pic.left() + 10.0, y), vec2(ARROW, ARROW));
let next = Rect::from_min_size(pos2(pic.right() - 10.0 - ARROW, y), vec2(ARROW, ARROW));
if arrow_button(ui, prev, Id::new("shot-prev"), true, tl!("Previous picture (←)"), t) {
self.flip_shot(count, false);
}
if arrow_button(ui, next, Id::new("shot-next"), false, tl!("Next picture (→)"), t) {
self.flip_shot(count, true);
}
widgets::hud(ui, &format!("{} · {}/{count}", current.caption, self.detail_shot.min(count - 1) + 1), t.faint);
let gap = 8.0;
let thumb_w = ((w - gap * (count as f32 - 1.0)) / count as f32).floor().max(1.0);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = gap;
for (i, shot) in details.shots.iter().enumerate() {
let selected = i == self.detail_shot;
let size = vec2(thumb_w, thumb_w * SHOT_ASPECT);
let resp = match self.picture_source(details.id, shot.key) {
Some(src) => ui.add(egui::Image::new(src).fit_to_exact_size(size).sense(Sense::click())),
// Too small for words: a blank frame, still there to pick.
None => {
let (rect, resp) = ui.allocate_exact_size(size, Sense::click());
ui.painter().rect_filled(rect, CornerRadius::ZERO, t.sunken);
resp
}
};
// The other pictures sit under a veil of the page's own background (a little lighter on hover):
// they fade toward the page, not toward grey, in both themes.
if !selected {
let veil = if resp.hovered() { 0.25 } else { 0.55 };
ui.painter().rect_filled(resp.rect, CornerRadius::ZERO, t.bg.gamma_multiply(veil));
}
let stroke = if selected { Stroke::new(2.0, app_color) } else { t.hairline() };
ui.painter().rect_stroke(resp.rect, CornerRadius::ZERO, stroke, egui::StrokeKind::Inside);
if resp.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(shot.caption).clicked() {
self.detail_shot = i;
}
}
});
}
}
/// What stands in for a picture that isn't here: a frame of the picture's size saying why. `failed`
/// is the reason the picture couldn't be had; without one it is still on its way.
fn placeholder(ui: &mut Ui, rect: Rect, id: Id, failed: Option<&str>, t: &Tokens, over_backdrop: bool) {
let text = if failed.is_some() { tl!("Pictures couldn't be loaded. Reopen this page to try again.") } else { tl!("Loading pictures…") };
ui.painter().rect_filled(rect, CornerRadius::ZERO, if over_backdrop { theme::backdrop_panel() } else { t.sunken });
let ink = if over_backdrop { theme::backdrop_ink(190) } else { t.faint };
let galley = ui.painter().layout(text.to_string(), egui::FontId::proportional(13.5), ink, (rect.width() - 32.0).max(1.0));
ui.painter().galley(rect.center() - galley.size() / 2.0, galley, ink);
let resp = ui.interact(rect, id, Sense::hover());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, text));
if let Some(why) = failed {
resp.on_hover_text(why);
}
}
/// Between the header and the footer, which stay usable. In a window shorter than the two bars
/// together there is no room left: an empty area, never a negative one.
fn drawer_area(ctx: &egui::Context) -> Rect {
let full = ctx.content_rect();
let top = (full.top() + HEADER_H).min(full.bottom());
let bottom = (full.bottom() - FOOTER_H).max(top);
Rect::from_min_max(pos2(full.left(), top), pos2(full.right(), bottom))
}
/// The app's links as a row of buttons sharing `row_w`; the ↗ says each opens the browser, like the links on the cards.
fn link_buttons(ui: &mut Ui, app: &CraftApp, row_w: f32) {
let links = [(tl!("Website"), app.website()), (tl!("GitHub"), app.repo_url()), (tl!("Releases"), app.releases_url())];
let gap = ui.spacing().item_spacing.x;
let link_w = ((row_w - gap * (links.len() as f32 - 1.0)) / links.len() as f32).floor();
ui.horizontal(|ui| {
for (label, url) in links {
if widgets::button(ui, &format!("{label} ↗"), Kind::Secondary, link_w).on_hover_text(url.as_str()).clicked() {
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
}
}
});
}
/// The numbered feature cells.
fn features(ui: &mut Ui, details: &AppDetails, app: &CraftApp, t: &Tokens) {
widgets::eyebrow(ui, "01", tl!("Features"));
ui.add_space(4.0);
for (i, f) in details.features.iter().enumerate() {
egui::Frame::new().stroke(t.hairline()).inner_margin(Margin::symmetric(14, 12)).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.spacing_mut().item_spacing.y = 3.0;
ui.horizontal(|ui| {
widgets::hud(ui, &format!("{:02}", i + 1), t.app_ink(app));
ui.label(RichText::new(f.title).size(15.0).family(theme::semibold()).color(t.ink_strong));
});
ui.add(egui::Label::new(RichText::new(f.text).size(13.5).color(t.muted)).wrap());
});
}
}
/// A square button with a chevron, drawn over a picture: opaque enough to read on any image,
/// inverted on hover like the site's outlined buttons. True when clicked.
fn arrow_button(ui: &mut Ui, rect: Rect, id: Id, left: bool, tip: &str, t: &Tokens) -> bool {
let resp = ui.interact(rect, id, Sense::click());
let (fill, ink) = if resp.hovered() { (t.invert_bg, t.invert_fg) } else { (t.bg.gamma_multiply(0.92), t.ink) };
let painter = ui.painter();
painter.rect(rect, CornerRadius::ZERO, fill, Stroke::new(1.0, t.line_strong), egui::StrokeKind::Inside);
let (c, s) = (rect.center(), if left { -1.0 } else { 1.0 });
let points = vec![pos2(c.x - s * 3.5, c.y - 6.0), pos2(c.x + s * 3.5, c.y), pos2(c.x - s * 3.5, c.y + 6.0)];
painter.add(egui::Shape::line(points, Stroke::new(1.75, ink)));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip));
resp.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(tip).clicked()
}
+99 -46
View File
@@ -17,7 +17,9 @@ impl LauncherUi {
return;
};
let t = Tokens::get(ctx);
let modal = egui::Modal::new(egui::Id::new("dialog"))
// An area is laid out in the size it had last frame, so dialogs can't share one id: the
// uninstall prompt would open as tall as Settings and never shrink (#88).
let modal = egui::Modal::new(egui::Id::new("dialog").with(dialog.key()))
.frame(egui::Frame::new().fill(t.bg).stroke(egui::Stroke::new(1.0, t.line_strong)).shadow(theme::shadow(&t)).inner_margin(24));
let mut close = false;
let response = match &dialog {
@@ -35,7 +37,7 @@ impl LauncherUi {
fn alert_popup(&mut self, ctx: &egui::Context) {
let Some(alert) = self.alerts.iter().find(|a| !self.settings.dismissed_notices.contains(&a.id)).cloned() else { return };
let t = Tokens::get(ctx);
let modal = egui::Modal::new(egui::Id::new("feed_alert"))
let modal = egui::Modal::new(egui::Id::new("feed_alert").with(&alert.id))
.frame(egui::Frame::new().fill(t.bg).stroke(egui::Stroke::new(1.0, t.line_strong)).shadow(theme::shadow(&t)).inner_margin(24));
let mut ok = false;
modal.show(ctx, |ui| {
@@ -46,13 +48,16 @@ impl LauncherUi {
ui.add(egui::Label::new(RichText::new(&alert.body).color(t.muted)).wrap());
});
ui.add_space(14.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ok = widgets::button(ui, tl!("OK"), Kind::Primary, 90.0).clicked();
if let Some(link) = &alert.link
&& widgets::button(ui, tl!("Open link"), Kind::Secondary, 110.0).on_hover_text(link.as_str()).clicked()
{
ui.ctx().open_url(egui::OpenUrl::new_tab(link.clone()));
}
// A row: a right-to-left layout straight in the column would fill the height left.
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ok = widgets::button(ui, tl!("OK"), Kind::Primary, 90.0).clicked();
if let Some(link) = &alert.link
&& widgets::button(ui, tl!("Open link"), Kind::Secondary, 110.0).on_hover_text(link.as_str()).clicked()
{
ui.ctx().open_url(egui::OpenUrl::new_tab(link.clone()));
}
});
});
});
// Only OK acknowledges it: Escape or a click outside leaves it for later.
@@ -157,41 +162,92 @@ impl LauncherUi {
ui.label(RichText::new(how).color(t.muted));
ui.add_space(20.0);
let mut close = false;
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::button(ui, tl!("Uninstall"), Kind::Danger, 110.0).clicked() {
self.send(Command::Uninstall { app: id.to_string() });
close = true;
}
if widgets::button(ui, tl!("Cancel"), Kind::Secondary, 90.0).clicked() {
close = true;
}
// A row: a right-to-left layout straight in the column would fill the height left.
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if widgets::button(ui, tl!("Uninstall"), Kind::Danger, 110.0).clicked() {
self.send(Command::Uninstall { app: id.to_string() });
close = true;
}
if widgets::button(ui, tl!("Cancel"), Kind::Secondary, 90.0).clicked() {
close = true;
}
});
});
close
}
fn settings_dialog(&mut self, ui: &mut egui::Ui, host: &mut dyn Host) -> bool {
let t = Tokens::get(ui.ctx());
// Wide enough for the community buttons and every social icon on one row.
ui.set_width(520.0);
// Leave room for the frame (48 px) and a 16 px gutter on each side.
let viewport = ui.ctx().content_rect();
ui.set_width(520.0_f32.min((viewport.width() - 80.0).max(0.0)));
// The area remembers its previous size. Reset the available height so the scroll area
// measures against the viewport immediately, including on the first frame after a resize.
ui.set_max_height((viewport.height() - 80.0).max(0.0));
ui.spacing_mut().scroll = egui::style::ScrollStyle { foreground_color: true, ..egui::style::ScrollStyle::solid() };
let title_top = ui.cursor().top();
Self::title(ui, Some(crate::icons::artcraft_mark()), tl!("Settings"), &format!("ArtCraft Launcher {}", env!("CARGO_PKG_VERSION")));
let title_height = ui.cursor().top() - title_top;
let quit_width = widgets::button_width(ui, tl!("Quit ArtCraft Launcher"), 0.0);
let done_width = widgets::button_width(ui, tl!("Done"), 90.0);
let stack_actions = self.caps.manage_apps && quit_width + done_width + ui.spacing().item_spacing.x > ui.available_width();
let footer_height = if stack_actions { 2.0 * theme::CONTROL_H + ui.spacing().item_spacing.y } else { theme::CONTROL_H };
let body_height = (viewport.height() - 80.0 - title_height - footer_height - 24.0 - ui.spacing().item_spacing.y).max(0.0);
egui::ScrollArea::vertical().id_salt("settings-body").max_height(body_height).auto_shrink([false, true]).show(ui, |ui| {
self.settings_contents(ui, host);
});
ui.add_space(24.0);
let mut close = false;
let quit = |ui: &mut egui::Ui, host: &mut dyn Host| {
if self.caps.manage_apps && widgets::button(ui, tl!("Quit ArtCraft Launcher"), Kind::Secondary, 0.0).clicked() {
host.quit();
}
};
if stack_actions {
quit(ui, host);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
close = widgets::button(ui, tl!("Done"), Kind::Primary, 90.0).clicked();
});
} else {
ui.horizontal(|ui| {
quit(ui, host);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
close = widgets::button(ui, tl!("Done"), Kind::Primary, 90.0).clicked();
});
});
}
close
}
fn settings_contents(&mut self, ui: &mut egui::Ui, host: &mut dyn Host) {
let t = Tokens::get(ui.ctx());
let section = |ui: &mut egui::Ui, s: &str| {
ui.add_space(14.0);
widgets::hud(ui, s, t.faint);
ui.add_space(6.0);
};
let row = |ui: &mut egui::Ui, label: &str, add: &mut dyn FnMut(&mut egui::Ui)| {
ui.horizontal(|ui| {
let row = |ui: &mut egui::Ui, label: &str, control_width: f32, add: &mut dyn FnMut(&mut egui::Ui)| {
let font = egui::TextStyle::Body.resolve(ui.style());
let label_width = ui.painter().layout_no_wrap(label.to_string(), font, t.ink).size().x;
if label_width + control_width + ui.spacing().item_spacing.x > ui.available_width() {
ui.label(label);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| add(ui));
});
add(ui);
} else {
ui.horizontal(|ui| {
ui.label(label);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| add(ui));
});
}
};
let mut lang_code = self.settings.language.clone();
row(ui, tl!("Language"), &mut |ui| {
egui::ComboBox::from_id_salt("language").selected_text(Settings::language_label(&lang_code)).width(180.0).show_ui(ui, |ui| {
let installed = self.cjk_font;
row(ui, tl!("Language"), 180.0, &mut |ui| {
egui::ComboBox::from_id_salt("language").selected_text(Settings::language_label(&lang_code, installed)).width(180.0).show_ui(ui, |ui| {
ui.selectable_value(&mut lang_code, Settings::LANGUAGE_AUTO.to_string(), tl!("Auto"));
for lang in crate::i18n::Lang::all() {
ui.selectable_value(&mut lang_code, lang.code().to_string(), Settings::language_label(lang.code()));
ui.selectable_value(&mut lang_code, lang.code().to_string(), Settings::language_label(lang.code(), installed));
}
});
});
@@ -199,16 +255,17 @@ impl LauncherUi {
self.settings.language = lang_code;
crate::i18n::set_current(crate::app::effective_lang(&self.settings.language));
}
if crate::i18n::Lang::from_pref(&self.settings.language).needs_cjk_font() && !theme::cjk_font_available() {
let pref = crate::i18n::Lang::from_pref(&self.settings.language);
if pref.needs_cjk_font() && !theme::cjk_font_available(pref) {
// In English on purpose: the language asked for can't be drawn.
let note = "No Chinese font was found on this system, so the interface stays in English. \
Installing one (Noto Sans CJK, or WenQuanYi on Linux) and restarting fixes it.";
let note = "No font for this language was found on this system, so the interface stays in English. \
Installing one (for example Noto Sans CJK) and restarting fixes it.";
ui.add(egui::Label::new(RichText::new(note).size(12.0).color(t.warn)).wrap());
}
section(ui, tl!("Updates"));
let mut every = self.settings.check_every;
row(ui, tl!("Check for updates"), &mut |ui| {
row(ui, tl!("Check for updates"), 150.0, &mut |ui| {
egui::ComboBox::from_id_salt("every").selected_text(every.label()).width(150.0).show_ui(ui, |ui| {
for e in CheckEvery::ALL {
ui.selectable_value(&mut every, e, e.label());
@@ -237,7 +294,7 @@ impl LauncherUi {
None
};
let mut choice = self.settings.preferred_kinds.first().copied().or(default_kind);
row(ui, tl!("Install new apps as"), &mut |ui| {
row(ui, tl!("Install new apps as"), 210.0, &mut |ui| {
let default_label = match default_kind {
Some(k) => crate::i18n::t(k.label()),
None => tl!("System package (recommended)"),
@@ -250,6 +307,11 @@ impl LauncherUi {
for k in [PackageKind::Deb, PackageKind::Rpm, PackageKind::Flatpak, PackageKind::AppImage] {
ui.selectable_value(&mut choice, Some(k), crate::i18n::t(k.label()));
}
// The tarball is unpacked and run inside the launcher's own sandbox, so it
// is only ever on offer there (as the fallback when the host has no Flatpak).
if is_flatpak {
ui.selectable_value(&mut choice, Some(PackageKind::TarGz), crate::i18n::t(PackageKind::TarGz.label()));
}
});
});
self.settings.preferred_kinds = choice.into_iter().collect();
@@ -277,7 +339,7 @@ impl LauncherUi {
section(ui, tl!("Community"));
ui.label(RichText::new(tl!("Questions, ideas and news from the ArtCraft team")).color(t.muted));
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.horizontal_wrapped(|ui| {
use artcraft_launcher_core::catalog::{DISCORD_URL, GITHUB_URL, WEBSITE_URL};
if widgets::button(ui, tl!("Join the Discord"), Kind::Primary, 0.0).on_hover_text(DISCORD_URL).clicked() {
ui.ctx().open_url(egui::OpenUrl::new_tab(DISCORD_URL));
@@ -288,7 +350,10 @@ impl LauncherUi {
if widgets::button(ui, tl!("GitHub"), Kind::Secondary, 0.0).on_hover_text(GITHUB_URL).clicked() {
ui.ctx().open_url(egui::OpenUrl::new_tab(GITHUB_URL));
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
// Wrap the social links as a group so their icons stay together.
let count = artcraft_launcher_core::catalog::SOCIAL.len() as f32;
let width = count * (16.0 + 8.0) + (count - 1.0).max(0.0) * 4.0;
ui.allocate_ui_with_layout(vec2(width, theme::CONTROL_H), egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.spacing_mut().item_spacing.x = 4.0;
widgets::social_icons(ui, 16.0);
});
@@ -300,17 +365,5 @@ impl LauncherUi {
widgets::cells(ui, &mut theme_choice, &ThemeChoice::ALL.map(|c| (c, Cell { label: c.label(), tip: c.label(), count: None })));
});
self.settings.theme = theme_choice;
ui.add_space(24.0);
let mut close = false;
ui.horizontal(|ui| {
if self.caps.manage_apps && widgets::button(ui, tl!("Quit ArtCraft Launcher"), Kind::Secondary, 0.0).clicked() {
host.quit();
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
close = widgets::button(ui, tl!("Done"), Kind::Primary, 90.0).clicked();
});
});
close
}
}
+1 -1
View File
@@ -6,7 +6,7 @@
//! - `@id` context = keyed by command id (`tr_id`).
//! - `@plural` context = plural forms: the English `one|other` key, and the language's forms
//! separated by `|` (one form for a language whose nouns don't inflect for number, up to
//! three: `one|few|many` for Russian).
//! three: `one|few|many` for Russian, Ukrainian and Polish).
//! - `@msg` context = a status/error message, or a template whose `{placeholders}` match any text
//! (`message`, `msg`).
//! - Any other context = disambiguation for `tr_ctx`.
+314
View File
@@ -0,0 +1,314 @@
# Czech (cs) catalog for ArtCraft Launcher.
# Format: context<TAB>source<TAB>translation
# Empty context = plain UI string (tl!, t). @id = command-id keyed. @plural = one|few|other.
# @msg = status/error message template. Other context = disambiguation for tr_ctx.
# AI-assisted Czech translation, translated from the meaning of the English source strings.
# Product and app names, shortcuts, units, file formats, placeholders and trailing ellipses are kept.
Launcher Launcher
Craft App Suite Craft App Suite
Open Source Tools for Creatives, Makers, and Builders Open-source nástroje pro kreativce, tvůrce a vývojáře
Settings Nastavení
Check for updates Zkontrolovat aktualizace
Grid Mřížka
Table Tabulka
Show apps as cards Zobrazit aplikace jako karty
Show apps as a table Zobrazit aplikace v tabulce
All Vše
Updates Aktualizace
Not installed Nenainstalované
Search apps Hledat aplikace
Sort Seřadit
Suite order Pořadí v sadě
Name Název
Inspired by Inspirace
Status Stav
Installed version Nainstalovaná verze
Latest version Nejnovější verze
Newest release Nejnovější vydání
Discord Discord
GitHub GitHub
Website Web
Classic apps Klasické aplikace
Classic Klasické
New apps Nové aplikace
NEW NOVÉ
Install Nainstalovat
Update to {v} Aktualizovat na {v}
Open Otevřít
Download for {os} Stáhnout pro {os}
Download Stáhnout
Coming soon Již brzy
Checking… Kontrola…
Not available Není k dispozici
Offline Bez připojení
Downloading {done} of {total} Stahování {done} z {total}
Downloading… Stahování…
Reconnecting… Obnovování připojení…
Verifying… Ověřování…
Installing, then restarting… Instalace a restart…
Installing… Instalace…
Removing… Odebírání…
Pause download Pozastavit stahování
Cancel download Zrušit stahování
Resume download Pokračovat ve stahování
Discard download Zahodit stahování
Release notes Poznámky k vydání
Show in Finder Zobrazit ve Finderu
Show in Explorer Zobrazit v Průzkumníku
Show in Files Zobrazit v Souborech
Reinstall Znovu nainstalovat
Uninstall… Odinstalovat…
All releases Všechna vydání
ArtCraft Launcher {version} is available. Je k dispozici ArtCraft Launcher {version}.
Dismiss Skrýt
Update and restart Aktualizovat a restartovat
A new version is available. Would you like to relaunch with the updated version? Je k dispozici nová verze. Chcete aplikaci znovu spustit s aktualizovanou verzí?
Later Později
What's new Co je nového
No notes were published with this release. K tomuto vydání nebyly zveřejněny žádné poznámky.
Released {date} Vydáno {date}
Open on GitHub Otevřít na GitHubu
Close Zavřít
ArtCraft Launcher {version} ArtCraft Launcher {version}
Only when I ask Pouze na vyžádání
Automatically download and install updates Automaticky stahovat a instalovat aktualizace
Notify me when an installed app has an update Upozornit, když má nainstalovaná aplikace aktualizaci
Include pre-releases (betas and release candidates) Zahrnout předběžná vydání (beta verze a kandidáty na vydání)
Installing on Linux Instalace v Linuxu
Install new apps as Instalovat nové aplikace jako
System package (recommended) Systémový balíček (doporučeno)
Updates always use the format an app was installed with. Aktualizace vždy použijí formát, ve kterém byla aplikace nainstalována.
Running in the background Běh na pozadí
Keep running in the tray when the window is closed Po zavření okna ponechat spuštěné v oznamovací oblasti
Show the window on the current desktop Zobrazit okno na aktuální ploše
Start ArtCraft Launcher when I log in Spouštět ArtCraft Launcher po přihlášení
Community Komunita
Questions, ideas and news from the ArtCraft team Dotazy, nápady a novinky od týmu ArtCraft
Appearance Vzhled
Quit ArtCraft Launcher Ukončit ArtCraft Launcher
Done Hotovo
Uninstall {name}? Odinstalovat {name}?
The app will be moved to the Trash. Your documents and settings stay. Aplikace bude přesunuta do Koše. Vaše dokumenty a nastavení zůstanou zachovány.
Windows will remove it like any installed program. Your documents and settings stay. Windows ji odebere jako každý nainstalovaný program. Vaše dokumenty a nastavení zůstanou zachovány.
Your system's package manager will remove it, and may ask for your password. Your documents and settings stay. Odebere ji správce balíčků systému a může požádat o heslo. Vaše dokumenty a nastavení zůstanou zachovány.
The app will be removed. Your documents and settings stay. Aplikace bude odebrána. Vaše dokumenty a nastavení zůstanou zachovány.
Uninstall Odinstalovat
Cancel Zrušit
Checked {ago} Zkontrolováno {ago}
just now právě teď
yesterday včera
{month} {day}, {year} {day}. {month} {year}
Automatically Automaticky
Every Craft app Všechny aplikace Craft
Apps on this computer Aplikace v tomto počítači
Installed apps with a newer version Nainstalované aplikace s novější verzí
Apps you can install Aplikace, které lze nainstalovat
Hide App Skrýt aplikaci
Unhide App Zobrazit aplikaci
Hidden Apps Skryté aplikace
Apps you hid Aplikace, které jste skryli
Checked {ago} · using {sources} Zkontrolováno {ago} · zdroje: {sources}
{a} and {b} {a} a {b}
Join the Discord Připojit se na Discord
Learn more Další informace
Minimize Minimalizovat
Maximize Maximalizovat
Restore Obnovit
New Nové
OK OK
Open link Otevřít odkaz
Update Aktualizovat
{name} icon Ikona aplikace {name}
Loading ArtCraft Launcher… Načítání aplikace ArtCraft Launcher…
Cancel update Zrušit aktualizaci
Stop waiting; the update can be started again later Přestat čekat; aktualizaci lze spustit znovu později
Download again Stáhnout znovu
Install cancelled Instalace zrušena
Install cancelled. The download is kept for the next try. Instalace zrušena. Stažený soubor zůstane připravený pro další pokus.
Update cancelled Aktualizace zrušena
Uninstall cancelled Odinstalace zrušena
Retry install Zkusit instalaci znovu
Retry update to {version} Zkusit aktualizaci na {version} znovu
The download is still here. It's checked again, then installed. Stažený soubor je stále k dispozici. Znovu se ověří a poté nainstaluje.
Waiting for another app… Čekání na jinou aplikaci…
Waiting for other installs… Čekání na dokončení ostatních instalací…
Installer (.msi) Instalační program (.msi)
Portable (.zip) Přenosná verze (.zip)
Disk image (.dmg) Obraz disku (.dmg)
Debian package (.deb) Balíček Debian (.deb)
RPM package (.rpm) Balíček RPM (.rpm)
Flatpak bundle Balíček Flatpak
Tarball (.tar.gz) Archiv tar (.tar.gz)
photocraft Image editing Úpravy obrázků
vectorcraft Vector illustration Vektorová ilustrace
filmcraft Video editing, color and sound Střih videa, barvy a zvuk
lightcraft Photo library and raw development Katalog fotografií a vyvolávání RAW
pdfcraft Reading, organizing and protecting PDFs Čtení, organizace a ochrana souborů PDF
effectcraft Motion graphics and visual effects Pohyblivá grafika a vizuální efekty
designcraft Page layout and publishing Sazba stránek a publikování
wordcraft Writing and document design Psaní a návrh dokumentů
deckcraft Presentations and slide shows Prezentace a promítání snímků
gridcraft Spreadsheets and data Tabulky a data
cadcraft Computer-aided design and drafting Počítačem podporované navrhování a kreslení
soundcraft Recording, editing and mixing audio Nahrávání, úpravy a mixování zvuku
figcraft Interface design and prototyping Návrh rozhraní a prototypování
month Jan 1.
month Feb 2.
month Mar 3.
month Apr 4.
month May 5.
month Jun 6.
month Jul 7.
month Aug 8.
month Sep 9.
month Oct 10.
month Nov 11.
month Dec 12.
Not checked yet Zatím nezkontrolováno
GitHub's hourly limit was reached; full details return in {min} min Byl dosažen hodinový limit GitHubu; úplné informace budou znovu dostupné za {min} min
GitHub limit hit; back in {min} min Dosažen limit GitHubu; obnovení za {min} min
Installing for {platform} Instalace pro {platform}
Downloads for {platform} Stažení pro {platform}
No downloads for this system Pro tento systém nejsou k dispozici žádné soubory ke stažení
Join the ArtCraft community on Discord Připojit se ke komunitě ArtCraft na Discordu
ArtCraft on {name} ArtCraft na {name}
Couldn't open {name}: {e} {name} nelze otevřít: {e}
Download cancelled Stahování zrušeno
Available to install K dispozici k instalaci
Available K dispozici
Installed · up to date Nainstalováno · aktuální
Update available Je k dispozici aktualizace
Installed Nainstalováno
installed nainstalováno
Not available here Zde není k dispozici
Updating… Aktualizuje se…
Update paused Aktualizace pozastavena
Download paused Stahování pozastaveno
{version} installed → {latest} released Nainstalována verze {version} → vydána {latest}
Version {version} Verze {version}
{installed} installed · latest {latest} Nainstalována verze {installed} · nejnovější {latest}
Installed · latest {latest} Nainstalováno · nejnovější {latest}
Version {version} installed Nainstalována verze {version}
Version {latest} Verze {latest}
No release published yet Zatím nebylo zveřejněno žádné vydání
ArtCraft Launcher ArtCraft Launcher
App Aplikace
Latest Nejnovější
Released Vydáno
Auto Automaticky
Language Jazyk
Ascending Vzestupně
Descending Sestupně
Click to reverse the order Kliknutím obrátíte pořadí
Click to sort by this column Kliknutím seřadíte podle tohoto sloupce
Like {name} Obdoba {name}
{done} of {total} {done} z {total}
Downloading Stahuje se
Verifying checksum… Ověřování kontrolního součtu…
Resume Pokračovat
{done} of {total} downloaded Staženo {done} z {total}
{done} downloaded Staženo {done}
Checking for updates… Kontrola aktualizací…
Downloads the new version, replaces this one and opens it again Stáhne novou verzi, nahradí tuto verzi a znovu ji otevře
Couldn't check for updates: {f} Aktualizace nelze zkontrolovat: {f}
Website (coming soon) Web (již brzy)
GitHub repository Repozitář na GitHubu
ArtCraft ArtCraft
Update 1 app Aktualizovat 1 aplikaci
Update all ({count}) Aktualizovat vše ({count})
⊞ Grid ⊞ Mřížka
☰ Table ☰ Tabulka
🔍 Search apps 🔍 Hledat aplikace
{name} {version} {name} {version}
Match system Podle systému
Dark Tmavý
Light Světlý
Full changelog Úplný přehled změn
link odkaz
No apps match Žádné aplikace neodpovídají hledání
Open ArtCraft Launcher to update. Aktualizujte otevřením aplikace ArtCraft Launcher.
{name} {v} is available Je k dispozici {name} {v}
{count} updates are available Počet dostupných aktualizací: {count}
These notes weren't fetched (GitHub's request limit was reached). They're on the release page. Tyto poznámky se nepodařilo načíst (byl dosažen limit požadavků GitHubu). Najdete je na stránce vydání.
When on, opening the launcher from the menu bar brings its window to the desktop you're on. When off, macOS switches you to the desktop the window was on. Je-li tato volba zapnutá, otevření launcheru z řádku nabídek zobrazí jeho okno na aktuální ploše. Je-li vypnutá, macOS přepne na plochu, na které se okno nacházelo.
Couldn't change the login item: {e} Položku po přihlášení nelze změnit: {e}
column App Aplikace
column Installed Nainstalováno
column Latest Nejnovější
column Released Vydáno
filter Installed Nainstalováno
App details Podrobnosti o aplikaci
Close (Esc) Zavřít (Esc)
Features Funkce
Latest release Nejnovější verze
Version Verze
Platforms Platformy
Releases Vydání
Enlarge picture Zvětšit obrázek
Click to enlarge Kliknutím zvětšíte
Previous picture (←) Předchozí obrázek (←)
Next picture (→) Další obrázek (→)
Loading pictures… Načítání obrázků…
Pictures couldn't be loaded. Reopen this page to try again. Obrázky se nepodařilo načíst. Po opětovném otevření stránky se načtení zkusí znovu.
{name} details Podrobnosti: {name}
@plural {n} app|{n} apps {n} aplikace|{n} aplikace|{n} aplikací
@plural {n} min ago|{n} min ago před {n} min|před {n} min|před {n} min
@plural {n} h ago|{n} h ago před {n} h|před {n} h|před {n} h
@plural {n} day ago|{n} days ago před {n} dnem|před {n} dny|před {n} dny
@msg Couldn't open {name}: {e} {name} nelze otevřít: {e}
@msg Download cancelled Stahování zrušeno
@msg {name} updated to {version} Aplikace {name} byla aktualizována na verzi {version}
@msg {name} installed: {version} Aplikace {name} byla nainstalována ve verzi {version}
@msg {name} updated to {version} (this release publishes no checksum, so only its size was checked) Aplikace {name} byla aktualizována na verzi {version} (toto vydání neobsahuje kontrolní součet, ověřena byla pouze velikost)
@msg {name} installed: {version} (this release publishes no checksum, so only its size was checked) Aplikace {name} byla nainstalována ve verzi {version} (toto vydání neobsahuje kontrolní součet, ověřena byla pouze velikost)
@msg {name} was removed Aplikace {name} byla odebrána
@msg {name} is running. Quit it, then try again. Aplikace {name} je spuštěná. Ukončete ji a zkuste to znovu.
@id app.install Nainstalovat
@id app.update Aktualizovat na {v}
@id app.open Otevřít
@id app.download Stáhnout pro {os}
@id app.reveal Zobrazit v umístění
@id app.reinstall Znovu nainstalovat
@id app.uninstall Odinstalovat…
@id app.notes Poznámky k vydání
@id app.website Web
@id app.github Repozitář na GitHubu
@id app.releases Všechna vydání
@id launcher.update Aktualizovat a restartovat
@id launcher.download Stáhnout
@id launcher.notes Co je nového
@id launcher.dismiss Zavřít
@id settings.open Nastavení
@id settings.quit Ukončit ArtCraft Launcher
@id view.grid Mřížka
@id view.table Tabulka
@id filter.all Vše
@id filter.installed Nainstalované
@id filter.updates Aktualizace
@id filter.notinstalled Nenainstalované
@id sort.suite Pořadí v sadě
@id sort.name Název
@id sort.inspired Inspirace
@id sort.status Stav
@id sort.installed Nainstalovaná verze
@id sort.latest Nejnovější verze
@id sort.released Nejnovější vydání
@id help.discord Discord
@id help.github GitHub
@id help.website Web
@id update.check Zkontrolovat aktualizace
@id update.check_auto Automaticky kontrolovat aktualizace
@id update.notify Upozornit, když má nainstalovaná aplikace aktualizaci
@id update.prereleases Zahrnout předběžná vydání (beta verze a kandidáty na vydání)
@id linux.preferred_kind Instalovat nové aplikace jako
@id tray.close_to_tray Po zavření okna ponechat spuštěné v oznamovací oblasti
@id tray.show_on_current_desktop Zobrazit okno na aktuální ploše
@id tray.login_item Spouštět ArtCraft Launcher po přihlášení
@id appearance.theme Vzhled
Show ArtCraft Launcher Zobrazit ArtCraft Launcher
Check for Updates Zkontrolovat aktualizace
Visit getartcraft.com Navštívit getartcraft.com
ArtCraft on GitHub ArtCraft na GitHubu
Join the ArtCraft Discord Připojit se na ArtCraft Discord
@plural ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available ArtCraft Launcher · {n} dostupná aktualizace|ArtCraft Launcher · {n} dostupné aktualizace|ArtCraft Launcher · {n} dostupných aktualizací
1 # Czech (cs) catalog for ArtCraft Launcher.
2 # Format: context<TAB>source<TAB>translation
3 # Empty context = plain UI string (tl!, t). @id = command-id keyed. @plural = one|few|other.
4 # @msg = status/error message template. Other context = disambiguation for tr_ctx.
5 # AI-assisted Czech translation, translated from the meaning of the English source strings.
6 # Product and app names, shortcuts, units, file formats, placeholders and trailing ellipses are kept.
7 Launcher Launcher
8 Craft App Suite Craft App Suite
9 Open Source Tools for Creatives, Makers, and Builders Open-source nástroje pro kreativce, tvůrce a vývojáře
10 Settings Nastavení
11 Check for updates Zkontrolovat aktualizace
12 Grid Mřížka
13 Table Tabulka
14 Show apps as cards Zobrazit aplikace jako karty
15 Show apps as a table Zobrazit aplikace v tabulce
16 All Vše
17 Updates Aktualizace
18 Not installed Nenainstalované
19 Search apps Hledat aplikace
20 Sort Seřadit
21 Suite order Pořadí v sadě
22 Name Název
23 Inspired by Inspirace
24 Status Stav
25 Installed version Nainstalovaná verze
26 Latest version Nejnovější verze
27 Newest release Nejnovější vydání
28 Discord Discord
29 GitHub GitHub
30 Website Web
31 Classic apps Klasické aplikace
32 Classic Klasické
33 New apps Nové aplikace
34 NEW NOVÉ
35 Install Nainstalovat
36 Update to {v} Aktualizovat na {v}
37 Open Otevřít
38 Download for {os} Stáhnout pro {os}
39 Download Stáhnout
40 Coming soon Již brzy
41 Checking… Kontrola…
42 Not available Není k dispozici
43 Offline Bez připojení
44 Downloading {done} of {total} Stahování {done} z {total}
45 Downloading… Stahování…
46 Reconnecting… Obnovování připojení…
47 Verifying… Ověřování…
48 Installing, then restarting… Instalace a restart…
49 Installing… Instalace…
50 Removing… Odebírání…
51 Pause download Pozastavit stahování
52 Cancel download Zrušit stahování
53 Resume download Pokračovat ve stahování
54 Discard download Zahodit stahování
55 Release notes Poznámky k vydání
56 Show in Finder Zobrazit ve Finderu
57 Show in Explorer Zobrazit v Průzkumníku
58 Show in Files Zobrazit v Souborech
59 Reinstall Znovu nainstalovat
60 Uninstall… Odinstalovat…
61 All releases Všechna vydání
62 ArtCraft Launcher {version} is available. Je k dispozici ArtCraft Launcher {version}.
63 Dismiss Skrýt
64 Update and restart Aktualizovat a restartovat
65 A new version is available. Would you like to relaunch with the updated version? Je k dispozici nová verze. Chcete aplikaci znovu spustit s aktualizovanou verzí?
66 Later Později
67 What's new Co je nového
68 No notes were published with this release. K tomuto vydání nebyly zveřejněny žádné poznámky.
69 Released {date} Vydáno {date}
70 Open on GitHub Otevřít na GitHubu
71 Close Zavřít
72 ArtCraft Launcher {version} ArtCraft Launcher {version}
73 Only when I ask Pouze na vyžádání
74 Automatically download and install updates Automaticky stahovat a instalovat aktualizace
75 Notify me when an installed app has an update Upozornit, když má nainstalovaná aplikace aktualizaci
76 Include pre-releases (betas and release candidates) Zahrnout předběžná vydání (beta verze a kandidáty na vydání)
77 Installing on Linux Instalace v Linuxu
78 Install new apps as Instalovat nové aplikace jako
79 System package (recommended) Systémový balíček (doporučeno)
80 Updates always use the format an app was installed with. Aktualizace vždy použijí formát, ve kterém byla aplikace nainstalována.
81 Running in the background Běh na pozadí
82 Keep running in the tray when the window is closed Po zavření okna ponechat spuštěné v oznamovací oblasti
83 Show the window on the current desktop Zobrazit okno na aktuální ploše
84 Start ArtCraft Launcher when I log in Spouštět ArtCraft Launcher po přihlášení
85 Community Komunita
86 Questions, ideas and news from the ArtCraft team Dotazy, nápady a novinky od týmu ArtCraft
87 Appearance Vzhled
88 Quit ArtCraft Launcher Ukončit ArtCraft Launcher
89 Done Hotovo
90 Uninstall {name}? Odinstalovat {name}?
91 The app will be moved to the Trash. Your documents and settings stay. Aplikace bude přesunuta do Koše. Vaše dokumenty a nastavení zůstanou zachovány.
92 Windows will remove it like any installed program. Your documents and settings stay. Windows ji odebere jako každý nainstalovaný program. Vaše dokumenty a nastavení zůstanou zachovány.
93 Your system's package manager will remove it, and may ask for your password. Your documents and settings stay. Odebere ji správce balíčků systému a může požádat o heslo. Vaše dokumenty a nastavení zůstanou zachovány.
94 The app will be removed. Your documents and settings stay. Aplikace bude odebrána. Vaše dokumenty a nastavení zůstanou zachovány.
95 Uninstall Odinstalovat
96 Cancel Zrušit
97 Checked {ago} Zkontrolováno {ago}
98 just now právě teď
99 yesterday včera
100 {month} {day}, {year} {day}. {month} {year}
101 Automatically Automaticky
102 Every Craft app Všechny aplikace Craft
103 Apps on this computer Aplikace v tomto počítači
104 Installed apps with a newer version Nainstalované aplikace s novější verzí
105 Apps you can install Aplikace, které lze nainstalovat
106 Hide App Skrýt aplikaci
107 Unhide App Zobrazit aplikaci
108 Hidden Apps Skryté aplikace
109 Apps you hid Aplikace, které jste skryli
110 Checked {ago} · using {sources} Zkontrolováno {ago} · zdroje: {sources}
111 {a} and {b} {a} a {b}
112 Join the Discord Připojit se na Discord
113 Learn more Další informace
114 Minimize Minimalizovat
115 Maximize Maximalizovat
116 Restore Obnovit
117 New Nové
118 OK OK
119 Open link Otevřít odkaz
120 Update Aktualizovat
121 {name} icon Ikona aplikace {name}
122 Loading ArtCraft Launcher… Načítání aplikace ArtCraft Launcher…
123 Cancel update Zrušit aktualizaci
124 Stop waiting; the update can be started again later Přestat čekat; aktualizaci lze spustit znovu později
125 Download again Stáhnout znovu
126 Install cancelled Instalace zrušena
127 Install cancelled. The download is kept for the next try. Instalace zrušena. Stažený soubor zůstane připravený pro další pokus.
128 Update cancelled Aktualizace zrušena
129 Uninstall cancelled Odinstalace zrušena
130 Retry install Zkusit instalaci znovu
131 Retry update to {version} Zkusit aktualizaci na {version} znovu
132 The download is still here. It's checked again, then installed. Stažený soubor je stále k dispozici. Znovu se ověří a poté nainstaluje.
133 Waiting for another app… Čekání na jinou aplikaci…
134 Waiting for other installs… Čekání na dokončení ostatních instalací…
135 Installer (.msi) Instalační program (.msi)
136 Portable (.zip) Přenosná verze (.zip)
137 Disk image (.dmg) Obraz disku (.dmg)
138 Debian package (.deb) Balíček Debian (.deb)
139 RPM package (.rpm) Balíček RPM (.rpm)
140 Flatpak bundle Balíček Flatpak
141 Tarball (.tar.gz) Archiv tar (.tar.gz)
142 photocraft Image editing Úpravy obrázků
143 vectorcraft Vector illustration Vektorová ilustrace
144 filmcraft Video editing, color and sound Střih videa, barvy a zvuk
145 lightcraft Photo library and raw development Katalog fotografií a vyvolávání RAW
146 pdfcraft Reading, organizing and protecting PDFs Čtení, organizace a ochrana souborů PDF
147 effectcraft Motion graphics and visual effects Pohyblivá grafika a vizuální efekty
148 designcraft Page layout and publishing Sazba stránek a publikování
149 wordcraft Writing and document design Psaní a návrh dokumentů
150 deckcraft Presentations and slide shows Prezentace a promítání snímků
151 gridcraft Spreadsheets and data Tabulky a data
152 cadcraft Computer-aided design and drafting Počítačem podporované navrhování a kreslení
153 soundcraft Recording, editing and mixing audio Nahrávání, úpravy a mixování zvuku
154 figcraft Interface design and prototyping Návrh rozhraní a prototypování
155 month Jan 1.
156 month Feb 2.
157 month Mar 3.
158 month Apr 4.
159 month May 5.
160 month Jun 6.
161 month Jul 7.
162 month Aug 8.
163 month Sep 9.
164 month Oct 10.
165 month Nov 11.
166 month Dec 12.
167 Not checked yet Zatím nezkontrolováno
168 GitHub's hourly limit was reached; full details return in {min} min Byl dosažen hodinový limit GitHubu; úplné informace budou znovu dostupné za {min} min
169 GitHub limit hit; back in {min} min Dosažen limit GitHubu; obnovení za {min} min
170 Installing for {platform} Instalace pro {platform}
171 Downloads for {platform} Stažení pro {platform}
172 No downloads for this system Pro tento systém nejsou k dispozici žádné soubory ke stažení
173 Join the ArtCraft community on Discord Připojit se ke komunitě ArtCraft na Discordu
174 ArtCraft on {name} ArtCraft na {name}
175 Couldn't open {name}: {e} {name} nelze otevřít: {e}
176 Download cancelled Stahování zrušeno
177 Available to install K dispozici k instalaci
178 Available K dispozici
179 Installed · up to date Nainstalováno · aktuální
180 Update available Je k dispozici aktualizace
181 Installed Nainstalováno
182 installed nainstalováno
183 Not available here Zde není k dispozici
184 Updating… Aktualizuje se…
185 Update paused Aktualizace pozastavena
186 Download paused Stahování pozastaveno
187 {version} installed → {latest} released Nainstalována verze {version} → vydána {latest}
188 Version {version} Verze {version}
189 {installed} installed · latest {latest} Nainstalována verze {installed} · nejnovější {latest}
190 Installed · latest {latest} Nainstalováno · nejnovější {latest}
191 Version {version} installed Nainstalována verze {version}
192 Version {latest} Verze {latest}
193 No release published yet Zatím nebylo zveřejněno žádné vydání
194 ArtCraft Launcher ArtCraft Launcher
195 App Aplikace
196 Latest Nejnovější
197 Released Vydáno
198 Auto Automaticky
199 Language Jazyk
200 Ascending Vzestupně
201 Descending Sestupně
202 Click to reverse the order Kliknutím obrátíte pořadí
203 Click to sort by this column Kliknutím seřadíte podle tohoto sloupce
204 Like {name} Obdoba {name}
205 {done} of {total} {done} z {total}
206 Downloading Stahuje se
207 Verifying checksum… Ověřování kontrolního součtu…
208 Resume Pokračovat
209 {done} of {total} downloaded Staženo {done} z {total}
210 {done} downloaded Staženo {done}
211 Checking for updates… Kontrola aktualizací…
212 Downloads the new version, replaces this one and opens it again Stáhne novou verzi, nahradí tuto verzi a znovu ji otevře
213 Couldn't check for updates: {f} Aktualizace nelze zkontrolovat: {f}
214 Website (coming soon) Web (již brzy)
215 GitHub repository Repozitář na GitHubu
216 ArtCraft ArtCraft
217 Update 1 app Aktualizovat 1 aplikaci
218 Update all ({count}) Aktualizovat vše ({count})
219 ⊞ Grid ⊞ Mřížka
220 ☰ Table ☰ Tabulka
221 🔍 Search apps 🔍 Hledat aplikace
222 {name} {version} {name} {version}
223 Match system Podle systému
224 Dark Tmavý
225 Light Světlý
226 Full changelog Úplný přehled změn
227 link odkaz
228 No apps match Žádné aplikace neodpovídají hledání
229 Open ArtCraft Launcher to update. Aktualizujte otevřením aplikace ArtCraft Launcher.
230 {name} {v} is available Je k dispozici {name} {v}
231 {count} updates are available Počet dostupných aktualizací: {count}
232 These notes weren't fetched (GitHub's request limit was reached). They're on the release page. Tyto poznámky se nepodařilo načíst (byl dosažen limit požadavků GitHubu). Najdete je na stránce vydání.
233 When on, opening the launcher from the menu bar brings its window to the desktop you're on. When off, macOS switches you to the desktop the window was on. Je-li tato volba zapnutá, otevření launcheru z řádku nabídek zobrazí jeho okno na aktuální ploše. Je-li vypnutá, macOS přepne na plochu, na které se okno nacházelo.
234 Couldn't change the login item: {e} Položku po přihlášení nelze změnit: {e}
235 column App Aplikace
236 column Installed Nainstalováno
237 column Latest Nejnovější
238 column Released Vydáno
239 filter Installed Nainstalováno
240 App details Podrobnosti o aplikaci
241 Close (Esc) Zavřít (Esc)
242 Features Funkce
243 Latest release Nejnovější verze
244 Version Verze
245 Platforms Platformy
246 Releases Vydání
247 Enlarge picture Zvětšit obrázek
248 Click to enlarge Kliknutím zvětšíte
249 Previous picture (←) Předchozí obrázek (←)
250 Next picture (→) Další obrázek (→)
251 Loading pictures… Načítání obrázků…
252 Pictures couldn't be loaded. Reopen this page to try again. Obrázky se nepodařilo načíst. Po opětovném otevření stránky se načtení zkusí znovu.
253 {name} details Podrobnosti: {name}
254 @plural {n} app|{n} apps {n} aplikace|{n} aplikace|{n} aplikací
255 @plural {n} min ago|{n} min ago před {n} min|před {n} min|před {n} min
256 @plural {n} h ago|{n} h ago před {n} h|před {n} h|před {n} h
257 @plural {n} day ago|{n} days ago před {n} dnem|před {n} dny|před {n} dny
258 @msg Couldn't open {name}: {e} {name} nelze otevřít: {e}
259 @msg Download cancelled Stahování zrušeno
260 @msg {name} updated to {version} Aplikace {name} byla aktualizována na verzi {version}
261 @msg {name} installed: {version} Aplikace {name} byla nainstalována ve verzi {version}
262 @msg {name} updated to {version} (this release publishes no checksum, so only its size was checked) Aplikace {name} byla aktualizována na verzi {version} (toto vydání neobsahuje kontrolní součet, ověřena byla pouze velikost)
263 @msg {name} installed: {version} (this release publishes no checksum, so only its size was checked) Aplikace {name} byla nainstalována ve verzi {version} (toto vydání neobsahuje kontrolní součet, ověřena byla pouze velikost)
264 @msg {name} was removed Aplikace {name} byla odebrána
265 @msg {name} is running. Quit it, then try again. Aplikace {name} je spuštěná. Ukončete ji a zkuste to znovu.
266 @id app.install Nainstalovat
267 @id app.update Aktualizovat na {v}
268 @id app.open Otevřít
269 @id app.download Stáhnout pro {os}
270 @id app.reveal Zobrazit v umístění
271 @id app.reinstall Znovu nainstalovat
272 @id app.uninstall Odinstalovat…
273 @id app.notes Poznámky k vydání
274 @id app.website Web
275 @id app.github Repozitář na GitHubu
276 @id app.releases Všechna vydání
277 @id launcher.update Aktualizovat a restartovat
278 @id launcher.download Stáhnout
279 @id launcher.notes Co je nového
280 @id launcher.dismiss Zavřít
281 @id settings.open Nastavení
282 @id settings.quit Ukončit ArtCraft Launcher
283 @id view.grid Mřížka
284 @id view.table Tabulka
285 @id filter.all Vše
286 @id filter.installed Nainstalované
287 @id filter.updates Aktualizace
288 @id filter.notinstalled Nenainstalované
289 @id sort.suite Pořadí v sadě
290 @id sort.name Název
291 @id sort.inspired Inspirace
292 @id sort.status Stav
293 @id sort.installed Nainstalovaná verze
294 @id sort.latest Nejnovější verze
295 @id sort.released Nejnovější vydání
296 @id help.discord Discord
297 @id help.github GitHub
298 @id help.website Web
299 @id update.check Zkontrolovat aktualizace
300 @id update.check_auto Automaticky kontrolovat aktualizace
301 @id update.notify Upozornit, když má nainstalovaná aplikace aktualizaci
302 @id update.prereleases Zahrnout předběžná vydání (beta verze a kandidáty na vydání)
303 @id linux.preferred_kind Instalovat nové aplikace jako
304 @id tray.close_to_tray Po zavření okna ponechat spuštěné v oznamovací oblasti
305 @id tray.show_on_current_desktop Zobrazit okno na aktuální ploše
306 @id tray.login_item Spouštět ArtCraft Launcher po přihlášení
307 @id appearance.theme Vzhled
308 Show ArtCraft Launcher Zobrazit ArtCraft Launcher
309 Check for Updates Zkontrolovat aktualizace
310 Visit getartcraft.com Navštívit getartcraft.com
311 ArtCraft on GitHub ArtCraft na GitHubu
312 Join the ArtCraft Discord Připojit se na ArtCraft Discord
313 @plural ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available ArtCraft Launcher · {n} dostupná aktualizace|ArtCraft Launcher · {n} dostupné aktualizace|ArtCraft Launcher · {n} dostupných aktualizací
+21 -2
View File
@@ -14,7 +14,7 @@
# Left in English on purpose: product name "ArtCraft Launcher" (and its "Launcher" tag), suite
# name "Craft App Suite",
# app names (PhotoCraft, LightCraft, FilmCraft, DesignCraft, VectorCraft, PdfCraft, EffectCraft,
# WordCraft, DeckCraft, GridCraft, CADCraft, SoundCraft), "GitHub", "Discord".
# WordCraft, DeckCraft, GridCraft, CADCraft, SoundCraft, FigCraft), "GitHub", "Discord".
# Shortcut keys keep their key-cap names (Shift, Alt, Ctrl, Cmd, Esc).
# Trailing "…" (U+2026) must be preserved. Placeholders {name}, {n}, {version}, {count}, {v},
# {what}, {unit}, {real}, {e} must appear in translation.
@@ -118,6 +118,10 @@
Apps on this computer Apps auf diesem Computer
Installed apps with a newer version Installierte Apps mit neuerer Version
Apps you can install Apps, die du installieren kannst
Hide App App ausblenden
Unhide App App einblenden
Hidden Apps Ausgeblendete Apps
Apps you hid Apps, die du ausgeblendet hast
Checked {ago} · using {sources} Zuletzt geprüft: {ago} · über {sources}
{a} and {b} {a} und {b}
Join the Discord Dem Discord beitreten
@@ -166,6 +170,7 @@ deckcraft Presentations and slide shows Präsentationen und Diashows
gridcraft Spreadsheets and data Tabellenkalkulation und Daten
cadcraft Computer-aided design and drafting Computergestütztes Konstruieren und Zeichnen
soundcraft Recording, editing and mixing audio Audio aufnehmen, bearbeiten und mischen
figcraft Interface design and prototyping Interface-Design und Prototyping
# Contextual: month abbreviations for date rendering
month Jan Jan.
@@ -203,6 +208,7 @@ month Dec Dez.
{version} installed → {latest} released {version} installiert → {latest} veröffentlicht
Version {version} Version {version}
{installed} installed · latest {latest} {installed} installiert · neueste {latest}
Installed · latest {latest} Installiert · neueste {latest}
Version {version} installed Version {version} installiert
Version {latest} Version {latest}
No release published yet Noch keine Version veröffentlicht
@@ -226,7 +232,6 @@ month Dec Dez.
Checking for updates… Suche nach Updates…
Downloads the new version, replaces this one and opens it again Lädt die neue Version herunter, ersetzt diese und öffnet sie erneut
Couldn't check for updates: {f} Updates konnten nicht geprüft werden: {f}
Website (coming soon) Website (demnächst)
GitHub repository GitHub-Repository
ArtCraft ArtCraft
Update 1 app 1 App aktualisieren
@@ -256,6 +261,7 @@ column Released Datum
# Contextual: the filter tab names a group (the status pill says "Installed")
filter Installed Installiert
{name} details Details zu {name}
# Plural forms for German (one|other)
@plural {n} app|{n} apps {n} App|{n} Apps
@@ -324,4 +330,17 @@ filter Installed Installiert
Visit getartcraft.com getartcraft.com besuchen
ArtCraft on GitHub ArtCraft auf GitHub
Join the ArtCraft Discord Dem ArtCraft-Discord beitreten
App details App-Details
Close (Esc) Schließen (Esc)
Features Funktionen
Latest release Neueste Version
Version Version
Platforms Plattformen
Releases Versionen
Enlarge picture Bild vergrößern
Click to enlarge Zum Vergrößern klicken
Previous picture (←) Vorheriges Bild (←)
Next picture (→) Nächstes Bild (→)
Loading pictures… Bilder werden geladen…
Pictures couldn't be loaded. Reopen this page to try again. Die Bilder konnten nicht geladen werden. Beim erneuten Öffnen der Seite wird es noch einmal versucht.
@plural ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available ArtCraft Launcher · {n} Update verfügbar|ArtCraft Launcher · {n} Updates verfügbar
Can't render this file because it contains an unexpected character in line 11 and column 16.
+327
View File
@@ -0,0 +1,327 @@
# Spanish (es) catalog for ArtCraft Launcher.
# Format: context<TAB>source<TAB>translation
# Empty context = plain UI string (tl!, t).
# @id = command-id keyed (tr_id).
# @plural = plural forms: one|other (only 1 is "one" in Spanish).
# @msg = status/error message template (message, msg).
# Other context = disambiguation for tr_ctx.
#
# Clean-room: written from the meaning of ArtCraft Launcher's English labels, in neutral
# international Spanish for Spain and Latin America (the interface addresses the user as "tú";
# no regional words such as "ordenador").
#
# Coverage: the whole interface: every menu string and tl! literal (complete_menus = true).
# Left in English on purpose: product name "ArtCraft Launcher" (and its "Launcher" tag), suite
# name "Craft App Suite", app names (PhotoCraft, LightCraft, FilmCraft,
# DesignCraft, VectorCraft, PdfCraft, EffectCraft, WordCraft, DeckCraft, GridCraft, CADCraft,
# SoundCraft, FigCraft), "GitHub", "Discord", "Finder", "makers".
# Trailing "…" (U+2026) must be preserved. Placeholders must appear in the translation:
# {ago}, {a}, {b}, {count}, {date}, {day}, {done}, {e}, {f}, {installed}, {latest}, {min},
# {month}, {name}, {n}, {os}, {platform}, {sources}, {total}, {version}, {v}, {year}.
Launcher Launcher
Craft App Suite Craft App Suite
Open Source Tools for Creatives, Makers, and Builders Herramientas de código abierto para creativos, makers y constructores
Settings Ajustes
Check for updates Buscar actualizaciones
Grid Cuadrícula
Table Tabla
Show apps as cards Mostrar las aplicaciones como tarjetas
Show apps as a table Mostrar las aplicaciones como tabla
All Todas
Updates Actualizaciones
Not installed No instaladas
Search apps Buscar aplicaciones
Sort Ordenar
Suite order Orden de la suite
Name Nombre
Inspired by Inspirada en
Status Estado
Installed version Versión instalada
Latest version Última versión
Newest release Publicación más reciente
Discord Discord
GitHub GitHub
Website Sitio web
Classic apps Aplicaciones clásicas
New apps Aplicaciones nuevas
NEW NUEVA
Install Instalar
Update to {v} Actualizar a {v}
Open Abrir
Download for {os} Descargar para {os}
Download Descargar
Coming soon Próximamente
Checking… Comprobando…
Not available No disponible
Offline Sin conexión
Downloading {done} of {total} Descargando {done} de {total}
Downloading… Descargando…
Reconnecting… Reconectando…
Verifying… Verificando…
Installing, then restarting… Instalando y reiniciando…
Installing… Instalando…
Removing… Quitando…
Pause download Pausar la descarga
Cancel download Cancelar la descarga
Resume download Reanudar la descarga
Discard download Descartar la descarga
Release notes Notas de la versión
Show in Finder Mostrar en el Finder
Show in Explorer Mostrar en el Explorador
Show in Files Mostrar en Archivos
Reinstall Reinstalar
Uninstall… Desinstalar…
All releases Todas las versiones
ArtCraft Launcher {version} is available. ArtCraft Launcher {version} está disponible.
Dismiss Descartar
Update and restart Actualizar y reiniciar
A new version is available. Would you like to relaunch with the updated version? Hay una versión nueva. ¿Quieres reiniciar con la versión actualizada?
Later Más tarde
What's new Novedades
No notes were published with this release. Esta versión no se publicó con notas.
Released {date} Publicada el {date}
Open on GitHub Abrir en GitHub
Close Cerrar
ArtCraft Launcher {version} ArtCraft Launcher {version}
Only when I ask Solo cuando lo pida
Automatically download and install updates Descargar e instalar actualizaciones automáticamente
Notify me when an installed app has an update Avisarme cuando una aplicación instalada tenga una actualización
Include pre-releases (betas and release candidates) Incluir versiones preliminares (betas y versiones candidatas)
Installing on Linux Instalación en Linux
Install new apps as Instalar las aplicaciones nuevas como
System package (recommended) Paquete del sistema (recomendado)
Updates always use the format an app was installed with. Las actualizaciones siempre usan el formato con el que se instaló cada aplicación.
Running in the background En segundo plano
Keep running in the tray when the window is closed Seguir abierto en la bandeja del sistema al cerrar la ventana
Show the window on the current desktop Mostrar la ventana en el escritorio actual
Start ArtCraft Launcher when I log in Abrir ArtCraft Launcher al iniciar sesión
Community Comunidad
Questions, ideas and news from the ArtCraft team Preguntas, ideas y noticias del equipo de ArtCraft
Appearance Apariencia
Quit ArtCraft Launcher Salir de ArtCraft Launcher
Done Listo
Uninstall {name}? ¿Desinstalar {name}?
The app will be moved to the Trash. Your documents and settings stay. La aplicación se moverá a la Papelera. Tus documentos y ajustes se conservan.
Windows will remove it like any installed program. Your documents and settings stay. Windows la quitará como cualquier programa instalado. Tus documentos y ajustes se conservan.
Your system's package manager will remove it, and may ask for your password. Your documents and settings stay. El gestor de paquetes del sistema la quitará y puede pedirte la contraseña. Tus documentos y ajustes se conservan.
The app will be removed. Your documents and settings stay. La aplicación se quitará. Tus documentos y ajustes se conservan.
Uninstall Desinstalar
Cancel Cancelar
Checked {ago} Comprobado {ago}
just now ahora mismo
yesterday ayer
{month} {day}, {year} {day} {month} {year}
Automatically Automáticamente
Every Craft app Todas las aplicaciones Craft
Apps on this computer Aplicaciones en este equipo
Installed apps with a newer version Aplicaciones instaladas con una versión más reciente
Apps you can install Aplicaciones que puedes instalar
Hide App Ocultar aplicación
Unhide App Mostrar aplicación
Hidden Apps Aplicaciones ocultas
Apps you hid Aplicaciones que ocultaste
Checked {ago} · using {sources} Comprobado {ago} · con {sources}
{a} and {b} {a} y {b}
Join the Discord Únete al Discord
Learn more Más información
Minimize Minimizar
Maximize Maximizar
Restore Restaurar
New Nueva
OK Aceptar
Open link Abrir enlace
Update Actualizar
{name} icon Icono de {name}
Loading ArtCraft Launcher… Cargando ArtCraft Launcher…
Cancel update Cancelar la actualización
Stop waiting; the update can be started again later Dejar de esperar; la actualización se puede volver a iniciar más tarde
Download again Volver a descargar
Install cancelled Instalación cancelada
Install cancelled. The download is kept for the next try. Instalación cancelada. La descarga se guarda para el próximo intento.
Update cancelled Actualización cancelada
Uninstall cancelled Desinstalación cancelada
Retry install Reintentar la instalación
Retry update to {version} Reintentar la actualización a {version}
The download is still here. It's checked again, then installed. La descarga sigue aquí. Se vuelve a comprobar y luego se instala.
Waiting for another app… Esperando a otra aplicación…
Waiting for other installs… Esperando a otras instalaciones…
Installer (.msi) Instalador (.msi)
Portable (.zip) Portátil (.zip)
Disk image (.dmg) Imagen de disco (.dmg)
Debian package (.deb) Paquete Debian (.deb)
RPM package (.rpm) Paquete RPM (.rpm)
Flatpak bundle Paquete Flatpak
Tarball (.tar.gz) Archivo tar (.tar.gz)
photocraft Image editing Edición de imágenes
vectorcraft Vector illustration Ilustración vectorial
filmcraft Video editing, color and sound Edición de video, color y sonido
lightcraft Photo library and raw development Fototeca y revelado RAW
pdfcraft Reading, organizing and protecting PDFs Leer, organizar y proteger PDF
effectcraft Motion graphics and visual effects Gráficos en movimiento y efectos visuales
designcraft Page layout and publishing Maquetación y publicación
wordcraft Writing and document design Escritura y diseño de documentos
deckcraft Presentations and slide shows Presentaciones y diapositivas
gridcraft Spreadsheets and data Hojas de cálculo y datos
cadcraft Computer-aided design and drafting CAD: diseño y dibujo técnico
soundcraft Recording, editing and mixing audio Grabación, edición y mezcla de audio
figcraft Interface design and prototyping Diseño de interfaces y prototipos
month Jan ene.
month Feb feb.
month Mar mar.
month Apr abr.
month May may.
month Jun jun.
month Jul jul.
month Aug ago.
month Sep sept.
month Oct oct.
month Nov nov.
month Dec dic.
Not checked yet Aún sin comprobar
GitHub's hourly limit was reached; full details return in {min} min Se alcanzó el límite por hora de GitHub; todos los detalles estarán disponibles de nuevo en {min} min
GitHub limit hit; back in {min} min Límite de GitHub alcanzado; disponible de nuevo en {min} min
Installing for {platform} Instalación para {platform}
Downloads for {platform} Descargas para {platform}
No downloads for this system No hay descargas para este sistema
Join the ArtCraft community on Discord Únete a la comunidad de ArtCraft en Discord
ArtCraft on {name} ArtCraft en {name}
Couldn't open {name}: {e} No se pudo abrir {name}: {e}
Download cancelled Descarga cancelada
Available to install Disponible para instalar
Available Disponible
Installed · up to date Instalada · al día
Update available Actualización disponible
Installed Instalada
installed instalada
Not available here No disponible aquí
Updating… Actualizando…
Update paused Actualización en pausa
Download paused Descarga en pausa
{version} installed → {latest} released {version} instalada → {latest} publicada
Version {version} Versión {version}
{installed} installed · latest {latest} {installed} instalada · última {latest}
Installed · latest {latest} Instalada · última {latest}
Version {version} installed Versión {version} instalada
Version {latest} Versión {latest}
No release published yet Aún no hay ninguna versión publicada
ArtCraft Launcher ArtCraft Launcher
App Aplicación
Latest Última
Released Publicada
Auto Automático
Language Idioma
Ascending Ascendente
Descending Descendente
Click to reverse the order Haz clic para invertir el orden
Click to sort by this column Haz clic para ordenar por esta columna
Like {name} Al estilo de {name}
{done} of {total} {done} de {total}
Downloading Descargando
Verifying checksum… Verificando la suma de comprobación…
Resume Reanudar
{done} of {total} downloaded {done} de {total} descargados
{done} downloaded {done} descargados
Checking for updates… Buscando actualizaciones…
Downloads the new version, replaces this one and opens it again Descarga la versión nueva, reemplaza esta y vuelve a abrirla
Couldn't check for updates: {f} No se pudieron buscar actualizaciones: {f}
Website (coming soon) Sitio web (próximamente)
GitHub repository Repositorio en GitHub
ArtCraft ArtCraft
Update 1 app Actualizar 1 aplicación
Update all ({count}) Actualizar todas ({count})
⊞ Grid ⊞ Cuadrícula
☰ Table ☰ Tabla
🔍 Search apps 🔍 Buscar aplicaciones
{name} {version} {name} {version}
Match system Igual que el sistema
Dark Oscuro
Light Claro
Full changelog Registro de cambios completo
link enlace
No apps match Ninguna aplicación coincide
Open ArtCraft Launcher to update. Abre ArtCraft Launcher para actualizar.
{name} {v} is available {name} {v} está disponible
{count} updates are available Hay {count} actualizaciones disponibles
These notes weren't fetched (GitHub's request limit was reached). They're on the release page. Estas notas no se descargaron (se alcanzó el límite de solicitudes de GitHub). Están en la página de la versión.
When on, opening the launcher from the menu bar brings its window to the desktop you're on. When off, macOS switches you to the desktop the window was on. Si está activado, abrir ArtCraft Launcher desde la barra de menús lleva su ventana al escritorio en el que estás. Si está desactivado, macOS te cambia al escritorio en el que estaba la ventana.
Couldn't change the login item: {e} No se pudo cambiar el elemento de inicio de sesión: {e}
column App Aplicación
column Installed Instalada
column Latest Última
column Released Publicada
filter Installed Instaladas
App details Detalles de la app
Close (Esc) Cerrar (Esc)
Features Funciones
Latest release Última versión
Version Versión
Platforms Plataformas
Releases Versiones
Enlarge picture Ampliar imagen
Click to enlarge Haz clic para ampliar
Previous picture (←) Imagen anterior (←)
Next picture (→) Imagen siguiente (→)
Loading pictures… Cargando imágenes…
Pictures couldn't be loaded. Reopen this page to try again. No se pudieron cargar las imágenes. Vuelve a abrir esta página para intentarlo de nuevo.
{name} details Detalles de {name}
@plural {n} app|{n} apps {n} aplicación|{n} aplicaciones
@plural {n} min ago|{n} min ago hace {n} min|hace {n} min
@plural {n} h ago|{n} h ago hace {n} h|hace {n} h
@plural {n} day ago|{n} days ago hace {n} día|hace {n} días
@msg Couldn't open {name}: {e} No se pudo abrir {name}: {e}
@msg Download cancelled Descarga cancelada
@msg {name} updated to {version} {name} actualizada a {version}
@msg {name} installed: {version} {name} instalada: {version}
@msg {name} updated to {version} (this release publishes no checksum, so only its size was checked) {name} actualizada a {version} (esta versión no publica la suma de comprobación, así que solo se comprobó su tamaño)
@msg {name} installed: {version} (this release publishes no checksum, so only its size was checked) {name} instalada: {version} (esta versión no publica la suma de comprobación, así que solo se comprobó su tamaño)
@msg {name} was removed Se quitó {name}
@msg {name} is running. Quit it, then try again. {name} está abierta. Ciérrala y vuelve a intentarlo.
@id app.install Instalar
@id app.update Actualizar a {v}
@id app.open Abrir
@id app.download Descargar para {os}
@id app.reveal Mostrar en el Finder
@id app.reinstall Reinstalar
@id app.uninstall Desinstalar…
@id app.notes Notas de la versión
@id app.website Sitio web
@id app.github Repositorio en GitHub
@id app.releases Todas las versiones
@id launcher.update Actualizar y reiniciar
@id launcher.download Descargar
@id launcher.notes Novedades
@id launcher.dismiss Descartar
@id settings.open Ajustes
@id settings.quit Salir de ArtCraft Launcher
@id view.grid Cuadrícula
@id view.table Tabla
@id filter.all Todas
@id filter.installed Instaladas
@id filter.updates Actualizaciones
@id filter.notinstalled No instaladas
@id sort.suite Orden de la suite
@id sort.name Nombre
@id sort.inspired Inspirada en
@id sort.status Estado
@id sort.installed Versión instalada
@id sort.latest Última versión
@id sort.released Publicación más reciente
@id help.discord Discord
@id help.github GitHub
@id help.website Sitio web
@id update.check Buscar actualizaciones
@id update.check_auto Buscar actualizaciones automáticamente
@id update.notify Avisarme cuando una aplicación instalada tenga una actualización
@id update.prereleases Incluir versiones preliminares (betas y versiones candidatas)
@id linux.preferred_kind Instalar las aplicaciones nuevas como
@id tray.close_to_tray Seguir abierto en la bandeja del sistema al cerrar la ventana
@id tray.show_on_current_desktop Mostrar la ventana en el escritorio actual
@id tray.login_item Abrir ArtCraft Launcher al iniciar sesión
@id appearance.theme Apariencia
Show ArtCraft Launcher Mostrar ArtCraft Launcher
Check for Updates Buscar actualizaciones
Visit getartcraft.com Visitar getartcraft.com
ArtCraft on GitHub ArtCraft en GitHub
Join the ArtCraft Discord Únete al Discord de ArtCraft
@plural ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available ArtCraft Launcher · {n} actualización disponible|ArtCraft Launcher · {n} actualizaciones disponibles
Can't render this file because it contains an unexpected character in line 5 and column 48.
+21 -2
View File
@@ -12,7 +12,7 @@
# Coverage: the whole interface: every menu string and tl! literal (complete_menus = true).
# Left in English on purpose: product name "ArtCraft Launcher" (and its "Launcher" tag), suite
# name "Craft App Suite", app names (PhotoCraft, LightCraft, FilmCraft, DesignCraft, VectorCraft,
# PdfCraft, EffectCraft, WordCraft, DeckCraft, GridCraft, CADCraft, SoundCraft), "GitHub",
# PdfCraft, EffectCraft, WordCraft, DeckCraft, GridCraft, CADCraft, SoundCraft, FigCraft), "GitHub",
# "Discord".
# Trailing "…" (U+2026) must be preserved. Placeholders {name}, {n}, {version}, {count}, {v},
# {what}, {unit}, {real}, {e} must appear in translation.
@@ -116,6 +116,10 @@
Apps on this computer Applications sur cet ordinateur
Installed apps with a newer version Applications installées avec une version plus récente
Apps you can install Applications que vous pouvez installer
Hide App Masquer l'application
Unhide App Afficher l'application
Hidden Apps Applications masquées
Apps you hid Applications que vous avez masquées
Checked {ago} · using {sources} Vérifié {ago} · via {sources}
{a} and {b} {a} et {b}
Join the Discord Rejoindre le Discord
@@ -164,6 +168,7 @@ deckcraft Presentations and slide shows Présentations et diaporamas
gridcraft Spreadsheets and data Tableurs et données
cadcraft Computer-aided design and drafting Conception et dessin assistés par ordinateur
soundcraft Recording, editing and mixing audio Enregistrement, édition et mixage audio
figcraft Interface design and prototyping Design d'interfaces et prototypage
# Contextual: month abbreviations for date rendering
month Jan janv.
@@ -201,6 +206,7 @@ month Dec déc.
{version} installed → {latest} released {version} installée → {latest} publiée
Version {version} Version {version}
{installed} installed · latest {latest} {installed} installée · dernière {latest}
Installed · latest {latest} Installée · dernière {latest}
Version {version} installed Version {version} installée
Version {latest} Version {latest}
No release published yet Aucune version publiée pour l'instant
@@ -224,7 +230,6 @@ month Dec déc.
Checking for updates… Recherche de mises à jour…
Downloads the new version, replaces this one and opens it again Télécharge la nouvelle version, remplace celle-ci et la rouvre
Couldn't check for updates: {f} Impossible de rechercher les mises à jour : {f}
Website (coming soon) Site web (bientôt disponible)
GitHub repository Dépôt GitHub
ArtCraft ArtCraft
Update 1 app Mettre à jour 1 application
@@ -254,6 +259,7 @@ column Released Date de publication
# Contextual: the filter tab names a group (the status pill says "Installed")
filter Installed Installées
{name} details Détails de {name}
# Plural forms for French (one|other)
@plural {n} app|{n} apps {n} application|{n} applications
@@ -322,4 +328,17 @@ filter Installed Installées
Visit getartcraft.com Visiter getartcraft.com
ArtCraft on GitHub ArtCraft sur GitHub
Join the ArtCraft Discord Rejoindre le Discord d'ArtCraft
App details Détails de l'application
Close (Esc) Fermer (Esc)
Features Fonctionnalités
Latest release Dernière version
Version Version
Platforms Plateformes
Releases Versions
Enlarge picture Agrandir l'image
Click to enlarge Cliquer pour agrandir
Previous picture (←) Image précédente (←)
Next picture (→) Image suivante (→)
Loading pictures… Chargement des images…
Pictures couldn't be loaded. Reopen this page to try again. Impossible de charger les images. Rouvrir la page permet de réessayer.
@plural ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available ArtCraft Launcher · {n} mise à jour disponible|ArtCraft Launcher · {n} mises à jour disponibles
Can't render this file because it contains an unexpected character in line 5 and column 50.
+347
View File
@@ -0,0 +1,347 @@
# Hungarian (hu) catalog for ArtCraft Launcher.
# Format: context<TAB>source<TAB>translation
# Empty context = plain UI string (tl!, t).
# @id = command-id keyed (tr_id).
# @plural = plural forms: one|other (English and Hungarian).
# @msg = status/error message template (message, msg).
# Other context = disambiguation for tr_ctx.
#
# Clean-room: written from the meaning of ArtCraft Launcher's English labels in neutral Hungarian,
# using the terms Hungarian-speaking users of creative software know. The user is addressed with
# the informal "te", as in most current creative and community software.
#
# Coverage: the whole interface — every menu string and tl! literal (complete_menus = true).
# Left in English on purpose: product name "ArtCraft Launcher" (and its "Launcher" tag), suite
# name "Craft App Suite",
# app names (PhotoCraft, LightCraft, FilmCraft, DesignCraft, VectorCraft, PdfCraft, EffectCraft,
# WordCraft, DeckCraft, GridCraft, CADCraft, SoundCraft, FigCraft), "GitHub", "Discord".
# Shortcut keys keep their key-cap names (Shift, Alt, Ctrl, Cmd, Esc).
# Trailing "…" (U+2026) must be preserved. Placeholders {name}, {n}, {version}, {count}, {v},
# {what}, {unit}, {real}, {e} must appear in translation.
Launcher Launcher
Craft App Suite Craft App Suite
Open Source Tools for Creatives, Makers, and Builders Nyílt forráskódú eszközök alkotóknak, barkácsolóknak és fejlesztőknek
Settings Beállítások
Check for updates Frissítések keresése
Grid Rács
Table Táblázat
Show apps as cards Alkalmazások megjelenítése kártyákként
Show apps as a table Alkalmazások megjelenítése táblázatként
All Összes
Updates Frissítések
Not installed Nincs telepítve
Search apps Alkalmazások keresése
Sort Rendezés
Suite order Alapértelmezett sorrend
Name Név
Inspired by Mintául szolgált
Status Állapot
Installed version Telepített verzió
Latest version Legújabb verzió
Newest release Legújabb kiadás
Discord Discord
GitHub GitHub
Website Webhely
Classic apps Eredeti alkalmazások
New apps Új alkalmazások
NEW ÚJ
Install Telepítés
Update to {v} Frissítés: {v}
Open Megnyitás
Download for {os} Letöltés {os} rendszerre
Download Letöltés
Coming soon Hamarosan
Checking… Ellenőrzés…
Not available Nem elérhető
Offline Nincs kapcsolat
Downloading {done} of {total} Letöltés: {done} / {total}
Downloading… Letöltés…
Reconnecting… Újracsatlakozás…
Verifying… Fájl ellenőrzése…
Installing, then restarting… Telepítés, majd újraindítás…
Installing… Telepítés…
Removing… Eltávolítás…
Pause download Letöltés szüneteltetése
Cancel download Letöltés megszakítása
Resume download Letöltés folytatása
Discard download Letöltött fájl törlése
Release notes Kiadási információk
Show in Finder Megjelenítés a Finderben
Show in Explorer Megjelenítés a Fájlkezelőben
Show in Files Megjelenítés a fájlkezelőben
Reinstall Újratelepítés
Uninstall… Eltávolítás…
All releases Minden kiadás
ArtCraft Launcher {version} is available. Elérhető az ArtCraft Launcher új verziója: {version}.
Dismiss Elrejtés
Update and restart Frissítés és újraindítás
A new version is available. Would you like to relaunch with the updated version? Új verzió érhető el. Újraindítod az alkalmazást a frissítéshez?
Later Később
What's new Újdonságok
No notes were published with this release. Ehhez a kiadáshoz nem tettek közzé kiadási információkat.
Released {date} Megjelent: {date}
Open on GitHub Megnyitás a GitHubon
Close Bezárás
ArtCraft Launcher {version} ArtCraft Launcher {version}
Only when I ask Csak kézi kereséskor
Automatically download and install updates Frissítések automatikus letöltése és telepítése
Notify me when an installed app has an update Értesítés a telepített alkalmazásokhoz elérhető frissítésekről
Include pre-releases (betas and release candidates) Előzetes kiadások engedélyezése (béta- és kiadásra jelölt verziók)
Installing on Linux Telepítés Linuxon
Install new apps as Új alkalmazások csomagformátuma
System package (recommended) Rendszercsomag (ajánlott)
Updates always use the format an app was installed with. A frissítések mindig az eredeti telepítés csomagformátumát használják.
Running in the background Futtatás a háttérben
Keep running in the tray when the window is closed Az ablak bezárásakor kerüljön a tálcára
Show the window on the current desktop Ablak megjelenítése az aktuális asztalon
Start ArtCraft Launcher when I log in ArtCraft Launcher indítása bejelentkezéskor
Community Közösség
Questions, ideas and news from the ArtCraft team Kérdések, ötletek és hírek az ArtCraft csapatától
Appearance Megjelenés
Quit ArtCraft Launcher Kilépés az ArtCraft Launcherből
Done Kész
Uninstall {name}? {name} eltávolítása?
The app will be moved to the Trash. Your documents and settings stay. Az alkalmazás a Kukába kerül. A dokumentumaid és beállításaid megmaradnak.
Windows will remove it like any installed program. Your documents and settings stay. A Windows úgy távolítja el az alkalmazást, mint bármely más telepített programot. A dokumentumaid és beállításaid megmaradnak.
Your system's package manager will remove it, and may ask for your password. Your documents and settings stay. Az alkalmazást a rendszer csomagkezelője távolítja el. Ehhez szükség lehet a jelszavadra. A dokumentumaid és beállításaid megmaradnak.
The app will be removed. Your documents and settings stay. Eltávolítjuk az alkalmazást. A dokumentumaid és beállításaid megmaradnak.
Uninstall Eltávolítás
Cancel Mégse
Checked {ago} Ellenőrizve: {ago}
just now épp most
yesterday tegnap
{month} {day}, {year} {year}. {month} {day}.
Automatically Automatikusan
Every Craft app Minden Craft-alkalmazás
Apps on this computer Alkalmazások ezen a számítógépen
Installed apps with a newer version Telepített alkalmazások, amelyekhez frissítés érhető el
Apps you can install Telepíthető alkalmazások
Hide App Alkalmazás elrejtése
Unhide App Alkalmazás megjelenítése
Hidden Apps Rejtett alkalmazások
Apps you hid Az általad elrejtett alkalmazások
Checked {ago} · using {sources} Ellenőrizve: {ago} · {sources}
{a} and {b} {a} és {b}
Join the Discord Csatlakozás a Discordhoz
Learn more További információ
Minimize Kis méret
Maximize Teljes méret
Restore Előző méret
New Új
OK OK
Open link Hivatkozás megnyitása
Update Frissítés
{name} icon {name} ikonja
Loading ArtCraft Launcher… ArtCraft Launcher betöltése…
Cancel update Frissítés megszakítása
Stop waiting; the update can be started again later Megszakítja a várakozást. A frissítést később újra elindíthatod.
Download again Újbóli letöltés
Install cancelled Telepítés megszakítva
Install cancelled. The download is kept for the next try. Telepítés megszakítva. A letöltött fájl megmarad a következő próbálkozáshoz.
Update cancelled Frissítés megszakítva
Uninstall cancelled Eltávolítás megszakítva
Retry install Telepítés újrapróbálása
Retry update to {version} Frissítés újrapróbálása: {version}
The download is still here. It's checked again, then installed. A letöltött fájl megmaradt. Újra ellenőrizzük, majd telepítjük.
Waiting for another app… Várakozás egy másik alkalmazásra…
Waiting for other installs… Várakozás a többi telepítésre…
# Package formats offered for download (PackageKind::label)
Installer (.msi) Telepítő (.msi)
Portable (.zip) Hordozható (.zip)
Disk image (.dmg) Lemezkép (.dmg)
Debian package (.deb) Debian-csomag (.deb)
RPM package (.rpm) RPM-csomag (.rpm)
Flatpak bundle Flatpak-csomag
Tarball (.tar.gz) Tömörített archívum (.tar.gz)
# Contextual: each app's tagline, keyed by app id
photocraft Image editing Képszerkesztés
vectorcraft Vector illustration Vektoros illusztráció
filmcraft Video editing, color and sound Videószerkesztés, színkorrekció és hang
lightcraft Photo library and raw development Fotók rendszerezése és RAW-előhívás
pdfcraft Reading, organizing and protecting PDFs PDF-ek olvasása, rendezése és védelme
effectcraft Motion graphics and visual effects Mozgógrafika és vizuális effektek
designcraft Page layout and publishing Oldaltervezés és kiadványszerkesztés
wordcraft Writing and document design Írás és dokumentumtervezés
deckcraft Presentations and slide shows Prezentációk és diavetítések
gridcraft Spreadsheets and data Táblázatok és adatok
cadcraft Computer-aided design and drafting Számítógépes tervezés és műszaki rajz
soundcraft Recording, editing and mixing audio Hangrögzítés, hangszerkesztés és keverés
figcraft Interface design and prototyping Felülettervezés és prototípus-készítés
# Contextual: month abbreviations for date rendering
month Jan jan.
month Feb febr.
month Mar márc.
month Apr ápr.
month May máj.
month Jun jún.
month Jul júl.
month Aug aug.
month Sep szept.
month Oct okt.
month Nov nov.
month Dec dec.
Not checked yet Még nem történt ellenőrzés
GitHub's hourly limit was reached; full details return in {min} min Elértük a GitHub óránkénti lekérdezési korlátját; a részletes adatok {min} perc múlva lesznek újra elérhetők
GitHub limit hit; back in {min} min GitHub-korlát elérve; újrapróbálás {min} perc múlva
Installing for {platform} Telepítés {platform} rendszerre
Downloads for {platform} Letöltések {platform} rendszerre
No downloads for this system Nincsenek letöltések ehhez a rendszerhez
Join the ArtCraft community on Discord Csatlakozz az ArtCraft közösségéhez a Discordon
ArtCraft on {name} ArtCraft – {name}
Couldn't open {name}: {e} {name} nem nyitható meg: {e}
Download cancelled Letöltés megszakítva
Available to install Telepíthető
Available Elérhető
Installed · up to date Telepítve · naprakész
Update available Frissítés elérhető
Installed Telepítve
installed telepítve
Not available here Ezen a rendszeren nem érhető el
Updating… Frissítés…
Update paused Frissítés szüneteltetve
Download paused Letöltés szüneteltetve
{version} installed → {latest} released Telepítve: {version} → megjelent: {latest}
Version {version} Verzió: {version}
{installed} installed · latest {latest} Telepítve: {installed} · legújabb: {latest}
Installed · latest {latest} Telepítve · legújabb: {latest}
Version {version} installed Telepített verzió: {version}
Version {latest} Verzió: {latest}
No release published yet Még nem jelent meg kiadás
ArtCraft Launcher ArtCraft Launcher
App Alkalmazás
Latest Legújabb
Released Megjelent
Auto Automatikus
Language Nyelv
Ascending Növekvő
Descending Csökkenő
Click to reverse the order Kattints a sorrend megfordításához
Click to sort by this column Kattints az oszlop szerinti rendezéshez
Like {name} Mintául szolgált: {name}
{done} of {total} {done} / {total}
Downloading Letöltés folyamatban
Verifying checksum… Ellenőrzőösszeg ellenőrzése…
Resume Folytatás
{done} of {total} downloaded {done} / {total} letöltve
{done} downloaded {done} letöltve
Checking for updates… Frissítések keresése…
Downloads the new version, replaces this one and opens it again Letölti és telepíti az új verziót, majd újraindítja az alkalmazást
Couldn't check for updates: {f} Nem sikerült frissítéseket keresni: {f}
Website (coming soon) Webhely (hamarosan)
GitHub repository GitHub-tároló
ArtCraft ArtCraft
Update 1 app 1 alkalmazás frissítése
Update all ({count}) Összes frissítése ({count})
⊞ Grid ⊞ Rács
☰ Table ☰ Táblázat
🔍 Search apps 🔍 Alkalmazások keresése
{name} {version} {name} {version}
Match system Rendszerbeállítás szerint
Dark Sötét
Light Világos
Full changelog Teljes változásnapló
link hivatkozás
No apps match Nincs találat
Open ArtCraft Launcher to update. Nyisd meg az ArtCraft Launchert a frissítéshez.
{name} {v} is available Elérhető: {name} {v}
{count} updates are available {count} frissítés elérhető
These notes weren't fetched (GitHub's request limit was reached). They're on the release page. A kiadási információkat nem sikerült betölteni, mert elértük a GitHub lekérdezési korlátját. A kiadás oldalán megtalálod őket.
When on, opening the launcher from the menu bar brings its window to the desktop you're on. When off, macOS switches you to the desktop the window was on. Ha be van kapcsolva, a menüsorból megnyitott ArtCraft Launcher ablaka az aktuális asztalon jelenik meg. Ha ki van kapcsolva, a macOS arra az asztalra vált, ahol az ablak található.
Couldn't change the login item: {e} Az automatikus indítás beállítását nem sikerült módosítani: {e}
# Contextual: table column headers
column App Alkalmazás
column Installed Telepített
column Latest Legújabb
column Released Dátum
# Contextual: the filter tab names a group (the status pill says "Installed")
filter Installed Telepítve
App details Alkalmazás részletei
Close (Esc) Bezárás (Esc)
Features Funkciók
Latest release Legújabb kiadás
Version Verzió
Platforms Platformok
Releases Kiadások
Enlarge picture Kép nagyítása
Click to enlarge Kattints a nagyításhoz
Previous picture (←) Előző kép (←)
Next picture (→) Következő kép (→)
Loading pictures… Képek betöltése…
Pictures couldn't be loaded. Reopen this page to try again. A képeket nem sikerült betölteni. Nyisd meg újra az oldalt, és újra megpróbáljuk.
{name} details {name} – részletek
# Plural forms for Hungarian (one|other). After a number the noun stays singular.
@plural {n} app|{n} apps {n} alkalmazás|{n} alkalmazás
@plural {n} min ago|{n} min ago {n} perce|{n} perce
@plural {n} h ago|{n} h ago {n} órája|{n} órája
@plural {n} day ago|{n} days ago {n} napja|{n} napja
# Message templates (@msg): toasts from the backend. {placeholders} match any text.
@msg Couldn't open {name}: {e} {name} nem nyitható meg: {e}
@msg Download cancelled Letöltés megszakítva
@msg {name} updated to {version} {name} frissítve. Verzió: {version}
@msg {name} installed: {version} {name} telepítve: {version}
@msg {name} updated to {version} (this release publishes no checksum, so only its size was checked) {name} frissítve. Verzió: {version} (ehhez a kiadáshoz nem adtak meg ellenőrzőösszeget, ezért csak a fájl méretét ellenőriztük)
@msg {name} installed: {version} (this release publishes no checksum, so only its size was checked) {name} telepítve: {version} (ehhez a kiadáshoz nem adtak meg ellenőrzőösszeget, ezért csak a fájl méretét ellenőriztük)
@msg {name} was removed {name} eltávolítva
@msg {name} is running. Quit it, then try again. {name} fut. Zárd be, majd próbáld újra.
# Command IDs for menu items (@id)
# Format: @id<TAB>command_id<TAB>translation
@id app.install Telepítés
@id app.update Frissítés: {v}
@id app.open Megnyitás
@id app.download Letöltés {os} rendszerre
@id app.reveal Megjelenítés a Finderben
@id app.reinstall Újratelepítés
@id app.uninstall Eltávolítás…
@id app.notes Kiadási információk
@id app.website Webhely
@id app.github GitHub-tároló
@id app.releases Minden kiadás
@id launcher.update Frissítés és újraindítás
@id launcher.download Letöltés
@id launcher.notes Újdonságok
@id launcher.dismiss Elrejtés
@id settings.open Beállítások
@id settings.quit Kilépés az ArtCraft Launcherből
@id view.grid Rács
@id view.table Táblázat
@id filter.all Összes
@id filter.installed Telepítve
@id filter.updates Frissítések
@id filter.notinstalled Nincs telepítve
@id sort.suite Alapértelmezett sorrend
@id sort.name Név
@id sort.inspired Mintául szolgált
@id sort.status Állapot
@id sort.installed Telepített verzió
@id sort.latest Legújabb verzió
@id sort.released Legújabb kiadás
@id help.discord Discord
@id help.github GitHub
@id help.website Webhely
@id update.check Frissítések keresése
@id update.check_auto Frissítések automatikus keresése
@id update.notify Értesítés a telepített alkalmazásokhoz elérhető frissítésekről
@id update.prereleases Előzetes kiadások engedélyezése (béta- és kiadásra jelölt verziók)
@id linux.preferred_kind Új alkalmazások csomagformátuma
@id tray.close_to_tray Az ablak bezárásakor kerüljön a tálcára
@id tray.show_on_current_desktop Ablak megjelenítése az aktuális asztalon
@id tray.login_item ArtCraft Launcher indítása bejelentkezéskor
@id appearance.theme Megjelenés
# Tray (menu bar) menu and tooltip
Show ArtCraft Launcher ArtCraft Launcher megjelenítése
Check for Updates Frissítések keresése
Visit getartcraft.com getartcraft.com megnyitása
ArtCraft on GitHub ArtCraft a GitHubon
Join the ArtCraft Discord Csatlakozás az ArtCraft Discord-közösségéhez
@plural ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available ArtCraft Launcher · {n} frissítés elérhető|ArtCraft Launcher · {n} frissítés elérhető
Can't render this file because it contains an unexpected character in line 11 and column 16.
+349
View File
@@ -0,0 +1,349 @@
# Japanese (ja) catalog for ArtCraft Launcher.
# Format: context<TAB>source<TAB>translation
# Empty context = plain UI string (tl!, t).
# @id = command-id keyed (tr_id).
# @plural = plural forms: one|other in English, one form for Japanese.
# @msg = status/error message template (message, msg).
# Other context = disambiguation for tr_ctx.
#
# Clean-room: written from the meaning of ArtCraft Launcher's English labels in neutral Japanese,
# using the terms Japanese-speaking users of creative software know. The user is addressed with
# the neutral polite form (です・ます), as in most current Japanese software.
#
# Coverage: the whole interface — every menu string and tl! literal (complete_menus = true).
# Left in English on purpose: product name "ArtCraft Launcher" (and its "Launcher" tag), suite
# name "Craft App Suite",
# app names (PhotoCraft, LightCraft, FilmCraft, DesignCraft, VectorCraft, PdfCraft, EffectCraft,
# WordCraft, DeckCraft, GridCraft, CADCraft, SoundCraft, FigCraft), "GitHub", "Discord".
# Shortcut keys keep their key-cap names (Shift, Alt, Ctrl, Cmd, Esc).
# Trailing "…" (U+2026) must be preserved. Placeholders {name}, {n}, {version}, {count}, {v},
# {what}, {unit}, {real}, {e} must appear in translation.
# Japanese has one plural form. Shown with the system's Japanese font (see cjk_font.rs); where the
# system has none, the interface stays in English.
Launcher Launcher
Craft App Suite Craft App Suite
Open Source Tools for Creatives, Makers, and Builders クリエイター、メイカー、ビルダーのためのオープンソースツール
Settings 設定
Check for updates 更新を確認
Grid グリッド
Table テーブル
Show apps as cards アプリをカードで表示
Show apps as a table アプリをテーブルで表示
All すべて
Updates 更新
Not installed 未インストール
Search apps アプリを検索
Sort 並べ替え
Suite order スイート順
Name 名前
Inspired by 参考アプリ
Status ステータス
Installed version インストール済みバージョン
Latest version 最新バージョン
Newest release 最新リリース
Discord Discord
GitHub GitHub
Website ウェブサイト
Classic apps クラシックアプリ
New apps 新着アプリ
NEW 新着
Install インストール
Update to {v} {v} に更新
Open 開く
Download for {os} {os} 版をダウンロード
Download ダウンロード
Coming soon 近日公開
Checking… 確認中…
Not available 利用不可
Offline オフライン
Downloading {done} of {total} ダウンロード中 {done} / {total}
Downloading… ダウンロード中…
Reconnecting… 再接続中…
Verifying… 検証中…
Installing, then restarting… インストール完了後再起動します…
Installing… インストール中…
Removing… アンインストール中…
Pause download ダウンロードを一時停止
Cancel download ダウンロードをキャンセル
Resume download ダウンロードを再開
Discard download ダウンロードを破棄
Release notes リリースノート
Show in Finder Finder で表示
Show in Explorer エクスプローラーで表示
Show in Files ファイルマネージャーで表示
Reinstall 再インストール
Uninstall… アンインストール…
All releases すべてのリリース
ArtCraft Launcher {version} is available. ArtCraft Launcher {version} が利用可能です。
Dismiss 閉じる
Update and restart 更新して再起動
A new version is available. Would you like to relaunch with the updated version? 新しいバージョンがあります。更新後のバージョンで再起動しますか?
Later 後で
What's new 新着情報
No notes were published with this release. このリリースにはリリースノートが公開されていません。
Released {date} {date} 公開
Open on GitHub GitHub で開く
Close 閉じる
ArtCraft Launcher {version} ArtCraft Launcher {version}
Only when I ask 手動で確認したときのみ
Automatically download and install updates 更新を自動でダウンロードしてインストール
Notify me when an installed app has an update インストール済みアプリに更新があるときに通知する
Include pre-releases (betas and release candidates) プレリリース(ベータ版とリリース候補)を含める
Installing on Linux Linux へのインストール
Install new apps as 新しいアプリのインストール形式
System package (recommended) システムパッケージ(推奨)
Updates always use the format an app was installed with. 更新は常に、アプリをインストールしたときの形式で行われます。
Running in the background バックグラウンドで実行
Keep running in the tray when the window is closed ウィンドウを閉じてもトレイで実行し続ける
Show the window on the current desktop 現在のデスクトップにウィンドウを表示
Start ArtCraft Launcher when I log in ログイン時に ArtCraft Launcher を起動
Community コミュニティ
Questions, ideas and news from the ArtCraft team ArtCraft チームからの質問、アイデア、ニュース
Appearance 外観
Quit ArtCraft Launcher ArtCraft Launcher を終了
Done 完了
Uninstall {name}? {name} をアンインストールしますか?
The app will be moved to the Trash. Your documents and settings stay. アプリはゴミ箱に移動されます。ドキュメントと設定はそのまま残ります。
Windows will remove it like any installed program. Your documents and settings stay. Windows は他のインストール済みプログラムと同様にアンインストールします。ドキュメントと設定はそのまま残ります。
Your system's package manager will remove it, and may ask for your password. Your documents and settings stay. システムのパッケージマネージャーがアンインストールします。パスワードの入力を求められる場合があります。ドキュメントと設定はそのまま残ります。
The app will be removed. Your documents and settings stay. アプリはアンインストールされます。ドキュメントと設定はそのまま残ります。
Uninstall アンインストール
Cancel キャンセル
Checked {ago} 最終確認: {ago}
just now たった今
yesterday 昨日
{month} {day}, {year} {year}年{month}{day}日
Automatically 自動
Every Craft app すべての Craft アプリ
Apps on this computer このコンピューターのアプリ
Installed apps with a newer version 新しいバージョンがあるインストール済みアプリ
Apps you can install インストールできるアプリ
Hide App アプリを非表示
Unhide App アプリを再表示
Hidden Apps 非表示のアプリ
Apps you hid 非表示にしたアプリ
Checked {ago} · using {sources} 最終確認: {ago} · 取得元: {sources}
{a} and {b} {a} と {b}
Join the Discord Discord に参加
Learn more 詳細を見る
Minimize 最小化
Maximize 最大化
Restore 元に戻す
New 新着
OK OK
Open link リンクを開く
Update 更新
{name} icon {name} のアイコン
Loading ArtCraft Launcher… ArtCraft Launcher を読み込み中…
Cancel update 更新をキャンセル
Stop waiting; the update can be started again later 待機を中止します。更新は後で再開できます
Download again 再ダウンロード
Install cancelled インストールをキャンセルしました
Install cancelled. The download is kept for the next try. インストールをキャンセルしました。ダウンロードしたファイルは次回のために保持されます。
Update cancelled 更新をキャンセルしました
Uninstall cancelled アンインストールをキャンセルしました
Retry install インストールを再試行
Retry update to {version} {version} への更新を再試行
The download is still here. It's checked again, then installed. ダウンロードしたファイルはそのまま残っています。もう一度検証してからインストールします。
Waiting for another app… 他のアプリを待機中…
Waiting for other installs… 他のインストールを待機中…
# Package formats offered for download (PackageKind::label)
Installer (.msi) インストーラー (.msi)
Portable (.zip) ポータブル版 (.zip)
Disk image (.dmg) ディスクイメージ (.dmg)
Debian package (.deb) Debian パッケージ (.deb)
RPM package (.rpm) RPM パッケージ (.rpm)
Flatpak bundle Flatpak バンドル
Tarball (.tar.gz) tarball (.tar.gz)
# Contextual: each app's tagline, keyed by app id
photocraft Image editing 画像編集
vectorcraft Vector illustration ベクターイラスト
filmcraft Video editing, color and sound 動画編集、カラー、サウンド
lightcraft Photo library and raw development フォトライブラリと RAW 現像
pdfcraft Reading, organizing and protecting PDFs PDF の閲覧、整理、保護
effectcraft Motion graphics and visual effects モーショングラフィックスと視覚効果
designcraft Page layout and publishing ページレイアウトと出版
wordcraft Writing and document design 文書作成とデザイン
deckcraft Presentations and slide shows プレゼンテーションとスライドショー
gridcraft Spreadsheets and data 表計算とデータ
cadcraft Computer-aided design and drafting コンピューター支援設計と製図
soundcraft Recording, editing and mixing audio オーディオの録音、編集、ミックス
figcraft Interface design and prototyping インターフェースデザインとプロトタイピング
# Contextual: month abbreviations for date rendering
month Jan 1月
month Feb 2月
month Mar 3月
month Apr 4月
month May 5月
month Jun 6月
month Jul 7月
month Aug 8月
month Sep 9月
month Oct 10月
month Nov 11月
month Dec 12月
Not checked yet まだ確認していません
GitHub's hourly limit was reached; full details return in {min} min GitHub の時間あたりの上限に達しました。{min} 分後に詳細が表示されます
GitHub limit hit; back in {min} min GitHub の上限に達しました。{min} 分後に復旧します
Installing for {platform} {platform} にインストール
Downloads for {platform} {platform} 用のダウンロード
No downloads for this system このシステム用のダウンロードはありません
Join the ArtCraft community on Discord Discord で ArtCraft コミュニティに参加
ArtCraft on {name} {name} の ArtCraft
Couldn't open {name}: {e} {name} を開けませんでした: {e}
Download cancelled ダウンロードをキャンセルしました
Available to install インストール可能
Available 利用可能
Installed · up to date インストール済み · 最新
Update available 更新あり
Installed インストール済み
installed インストール済み
Not available here このプラットフォームでは利用不可
Updating… 更新中…
Update paused 更新を一時停止
Download paused ダウンロードを一時停止
{version} installed → {latest} released {version} インストール済み → {latest} 公開
Version {version} バージョン {version}
{installed} installed · latest {latest} {installed} インストール済み · 最新 {latest}
Installed · latest {latest} インストール済み · 最新 {latest}
Version {version} installed バージョン {version} インストール済み
Version {latest} バージョン {latest}
No release published yet まだリリースがありません
ArtCraft Launcher ArtCraft Launcher
App アプリ
Latest 最新
Released 公開日
Auto 自動
Language 言語
Ascending 昇順
Descending 降順
Click to reverse the order クリックして順序を反転
Click to sort by this column クリックしてこの列で並べ替え
Like {name} {name} と同様
{done} of {total} {done} / {total}
Downloading ダウンロード中
Verifying checksum… チェックサムを検証中…
Resume 再開
{done} of {total} downloaded {done} / {total} ダウンロード済み
{done} downloaded {done} ダウンロード済み
Checking for updates… 更新を確認中…
Downloads the new version, replaces this one and opens it again 新しいバージョンをダウンロードし、現在のバージョンと置き換えて再び開きます
Couldn't check for updates: {f} 更新を確認できませんでした: {f}
Website (coming soon) ウェブサイト(近日公開)
GitHub repository GitHub リポジトリ
ArtCraft ArtCraft
Update 1 app 1 個のアプリを更新
Update all ({count}) すべて更新({count})
⊞ Grid ⊞ グリッド
☰ Table ☰ テーブル
🔍 Search apps 🔍 アプリを検索
{name} {version} {name} {version}
Match system システムに合わせる
Dark ダーク
Light ライト
Full changelog 完全な変更履歴
link リンク
No apps match 一致するアプリがありません
Open ArtCraft Launcher to update. 更新するには ArtCraft Launcher を開いてください。
{name} {v} is available {name} {v} が利用可能です
{count} updates are available {count} 件の更新があります
These notes weren't fetched (GitHub's request limit was reached). They're on the release page. このリリースノートは取得できませんでした(GitHub のリクエスト上限に達しました)。リリースページにあります。
When on, opening the launcher from the menu bar brings its window to the desktop you're on. When off, macOS switches you to the desktop the window was on. オンにすると、メニューバーからランチャーを開いたときにウィンドウが現在のデスクトップに表示されます。オフにすると、macOS はウィンドウがあったデスクトップに切り替えます。
Couldn't change the login item: {e} ログイン項目を変更できませんでした: {e}
# Contextual: table column headers
column App アプリ
column Installed インストール済みバージョン
column Latest 最新バージョン
column Released 公開日
# Contextual: the filter tab names a group (the status pill says "Installed")
filter Installed インストール済み
App details アプリの詳細
Close (Esc) 閉じる (Esc)
Features 機能
Latest release 最新リリース
Version バージョン
Platforms 対応プラットフォーム
Releases リリース
Enlarge picture 画像を拡大
Click to enlarge クリックして拡大
Previous picture (←) 前の画像 (←)
Next picture (→) 次の画像 (→)
Loading pictures… 画像を読み込み中…
Pictures couldn't be loaded. Reopen this page to try again. 画像を読み込めませんでした。このページを開き直すと再試行します。
{name} details {name} の詳細
# Plural forms for Japanese (one form: Japanese nouns don't inflect for number)
@plural {n} app|{n} apps {n} 個のアプリ
@plural {n} min ago|{n} min ago {n} 分前
@plural {n} h ago|{n} h ago {n} 時間前
@plural {n} day ago|{n} days ago {n} 日前
# Message templates (@msg): toasts from the backend. {placeholders} match any text.
@msg Couldn't open {name}: {e} {name} を開けませんでした: {e}
@msg Download cancelled ダウンロードをキャンセルしました
@msg {name} updated to {version} {name} を {version} に更新しました
@msg {name} installed: {version} {name} をインストールしました: {version}
@msg {name} updated to {version} (this release publishes no checksum, so only its size was checked) {name} を {version} に更新しました(このリリースはチェックサムを公開していないため、サイズのみ確認しました)
@msg {name} installed: {version} (this release publishes no checksum, so only its size was checked) {name} をインストールしました: {version}(このリリースはチェックサムを公開していないため、サイズのみ確認しました)
@msg {name} was removed {name} をアンインストールしました
@msg {name} is running. Quit it, then try again. {name} が実行中です。終了してから再試行してください。
# Command IDs for menu items (@id)
# Format: @id<TAB>command_id<TAB>translation
@id app.install インストール
@id app.update {v} に更新
@id app.open 開く
@id app.download {os} 版をダウンロード
@id app.reveal Finder で表示
@id app.reinstall 再インストール
@id app.uninstall アンインストール…
@id app.notes リリースノート
@id app.website ウェブサイト
@id app.github GitHub リポジトリ
@id app.releases すべてのリリース
@id launcher.update 更新して再起動
@id launcher.download ダウンロード
@id launcher.notes 新着情報
@id launcher.dismiss 閉じる
@id settings.open 設定
@id settings.quit ArtCraft Launcher を終了
@id view.grid グリッド
@id view.table テーブル
@id filter.all すべて
@id filter.installed インストール済み
@id filter.updates 更新
@id filter.notinstalled 未インストール
@id sort.suite スイート順
@id sort.name 名前
@id sort.inspired 参考アプリ
@id sort.status ステータス
@id sort.installed インストール済みバージョン
@id sort.latest 最新バージョン
@id sort.released 最新リリース
@id help.discord Discord
@id help.github GitHub
@id help.website ウェブサイト
@id update.check 更新を確認
@id update.check_auto 自動で更新を確認
@id update.notify インストール済みアプリに更新があるときに通知する
@id update.prereleases プレリリース(ベータ版とリリース候補)を含める
@id linux.preferred_kind 新しいアプリのインストール形式
@id tray.close_to_tray ウィンドウを閉じてもトレイで実行し続ける
@id tray.show_on_current_desktop 現在のデスクトップにウィンドウを表示
@id tray.login_item ログイン時に ArtCraft Launcher を起動
@id appearance.theme 外観
# Tray (menu bar) menu and tooltip
Show ArtCraft Launcher ArtCraft Launcher を表示
Check for Updates 更新を確認
Visit getartcraft.com getartcraft.com を開く
ArtCraft on GitHub GitHub の ArtCraft
Join the ArtCraft Discord ArtCraft Discord に参加
@plural ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available ArtCraft Launcher · {n} 件の更新があります
Can't render this file because it contains an unexpected character in line 14 and column 44.
+118 -15
View File
@@ -5,21 +5,38 @@ use std::sync::OnceLock;
use super::catalog::Catalog;
use super::detect::system_lang;
/// The writing system a language is drawn in, and so the system font it needs. Languages that
/// share one can share a face; Simplified and Traditional Chinese do not (they want different
/// faces), so each script is its own. The bundled faces have no CJK glyphs, so a language with a
/// script borrows a system font for it (`crate::theme::cjk_font`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Script {
Chinese,
Japanese,
}
impl Script {
/// Every script a language can be written in.
pub const ALL: [Script; 2] = [Script::Chinese, Script::Japanese];
}
/// One supported UI language.
pub struct LangInfo {
/// Canonical BCP 47 code (`en`, `ru`, `zh-CN`, `de`, `fr`). Also the `interfaceLanguage` value.
/// Canonical BCP 47 code (`en`, `ru`, `zh-CN`, `de`, `fr`, `pt-BR`, `es`, `cs`, `pl`, `hu`, `ja`).
/// Also the `interfaceLanguage` value.
pub code: &'static str,
/// The language's name in itself, shown in the Preferences dropdown.
pub name: &'static str,
/// The language's name in English, shown instead where its own script can't be drawn.
pub english_name: &'static str,
/// Does its script need a CJK font? The bundled faces have none, so it comes from the
/// system (`theme::cjk_font`).
pub cjk: bool,
/// The writing system this language is drawn in, or `None` if it needs no system CJK font.
pub script: Option<Script>,
/// Catalog file contents (empty for the built-in English).
pub source: &'static str,
/// Plural form index for a count (English and German: 0 = one, 1 = other; Russian: 0 = one,
/// 1 = few, 2 = many; French: 0 = one for 0 and 1; Chinese: always 0).
/// Plural form index for a count (English, German, Spanish and Hungarian: 0 = one, 1 = other;
/// Russian: 0 = one, 1 = few, 2 = many; Czech: 0 = one, 1 = few, 2 = other; Polish: 0 = one only
/// for 1, 1 = few, 2 = many; French: 0 = one for 0 and 1; a language whose nouns don't inflect
/// for number: always 0).
pub plural: fn(u64) -> usize,
/// Number of plural forms the catalog's `@plural` rows carry (checked by tests).
pub plural_forms: usize,
@@ -37,7 +54,7 @@ fn plural_zero_one(n: u64) -> usize {
usize::from(n > 1)
}
/// Languages whose nouns don't inflect for number (Chinese): one form for every count.
/// A language whose nouns don't inflect for number: one form for every count.
fn plural_none(_: u64) -> usize {
0
}
@@ -55,13 +72,34 @@ pub(super) fn plural_east_slavic(n: u64) -> usize {
}
}
/// Czech integer counts: 1 is `one`, 2–4 are `few`, everything else is `other`.
pub(super) fn plural_czech(n: u64) -> usize {
match n {
1 => 0,
2..=4 => 1,
_ => 2,
}
}
/// Polish integer counts: only 1 itself is `one` (21 is "21 aplikacji"), 2–4 in the last digit
/// (but not 12–14) `few`, everything else `many`.
pub(super) fn plural_polish(n: u64) -> usize {
if n == 1 {
0
} else if matches!(n % 10, 2..=4) && !matches!(n % 100, 12..=14) {
1
} else {
2
}
}
/// The registry. English first: it is the fallback and the source language.
pub static LANGUAGES: [LangInfo; 6] = [
pub static LANGUAGES: [LangInfo; 11] = [
LangInfo {
code: "en",
name: "English",
english_name: "English",
cjk: false,
script: None,
source: "",
plural: plural_one_other,
plural_forms: 2,
@@ -72,7 +110,7 @@ pub static LANGUAGES: [LangInfo; 6] = [
code: "ru",
name: "Русский",
english_name: "Russian",
cjk: false,
script: None,
source: include_str!("ru.tsv"),
plural: plural_east_slavic,
plural_forms: 3,
@@ -83,7 +121,7 @@ pub static LANGUAGES: [LangInfo; 6] = [
code: "zh-CN",
name: "简体中文",
english_name: "Simplified Chinese",
cjk: true,
script: Some(Script::Chinese),
source: include_str!("zh-CN.tsv"),
plural: plural_none,
plural_forms: 1,
@@ -94,7 +132,7 @@ pub static LANGUAGES: [LangInfo; 6] = [
code: "de",
name: "Deutsch",
english_name: "German",
cjk: false,
script: None,
source: include_str!("de.tsv"),
plural: plural_one_other,
plural_forms: 2,
@@ -105,7 +143,7 @@ pub static LANGUAGES: [LangInfo; 6] = [
code: "fr",
name: "Français",
english_name: "French",
cjk: false,
script: None,
source: include_str!("fr.tsv"),
plural: plural_zero_one,
plural_forms: 2,
@@ -116,13 +154,68 @@ pub static LANGUAGES: [LangInfo; 6] = [
code: "pt-BR",
name: "Português (Brasil)",
english_name: "Portuguese (Brazil)",
cjk: false,
script: None,
source: include_str!("pt-BR.tsv"),
plural: plural_one_other,
plural_forms: 2,
complete_menus: true,
catalog: OnceLock::new(),
},
LangInfo {
code: "es",
name: "Español",
english_name: "Spanish",
script: None,
source: include_str!("es.tsv"),
plural: plural_one_other,
plural_forms: 2,
complete_menus: true,
catalog: OnceLock::new(),
},
LangInfo {
code: "cs",
name: "Čeština",
english_name: "Czech",
script: None,
source: include_str!("cs.tsv"),
plural: plural_czech,
plural_forms: 3,
complete_menus: true,
catalog: OnceLock::new(),
},
LangInfo {
code: "pl",
name: "Polski",
english_name: "Polish",
script: None,
source: include_str!("pl.tsv"),
plural: plural_polish,
plural_forms: 3,
complete_menus: true,
catalog: OnceLock::new(),
},
LangInfo {
code: "hu",
name: "Magyar",
english_name: "Hungarian",
script: None,
source: include_str!("hu.tsv"),
plural: plural_one_other,
plural_forms: 2,
complete_menus: true,
catalog: OnceLock::new(),
},
LangInfo {
code: "ja",
name: "日本語",
english_name: "Japanese",
script: Some(Script::Japanese),
source: include_str!("ja.tsv"),
plural: plural_none,
plural_forms: 1,
complete_menus: true,
catalog: OnceLock::new(),
},
];
impl LangInfo {
@@ -156,6 +249,11 @@ impl Lang {
pub const DE: Lang = Lang(&LANGUAGES[3]);
pub const FR: Lang = Lang(&LANGUAGES[4]);
pub const PT_BR: Lang = Lang(&LANGUAGES[5]);
pub const ES: Lang = Lang(&LANGUAGES[6]);
pub const CS: Lang = Lang(&LANGUAGES[7]);
pub const PL: Lang = Lang(&LANGUAGES[8]);
pub const HU: Lang = Lang(&LANGUAGES[9]);
pub const JA: Lang = Lang(&LANGUAGES[10]);
pub fn code(self) -> &'static str {
self.0.code
@@ -188,7 +286,12 @@ impl Lang {
/// Does drawing this language need a CJK font from the system?
pub fn needs_cjk_font(self) -> bool {
self.0.cjk
self.0.script.is_some()
}
/// The writing system this language is drawn in, if it needs a system CJK font.
pub fn script(self) -> Option<Script> {
self.0.script
}
/// Does this language's catalog claim to cover every `tl!` literal?
+7 -2
View File
@@ -4,7 +4,12 @@
//! # Adding a language
//! 1. Add `xx.tsv` next to `ru.tsv` (copy its header; translate from the *meaning* of the
//! English text, clean-room).
//! 2. Add one row to [`LANGUAGES`] (code, native name, catalog, plural rule).
//! 2. Add one row to [`LANGUAGES`] (code, native name, catalog, plural rule). For a complete
//! catalog, cover every UI literal and plural key; the tests enforce that claim. A translation
//! that isn't finished yet starts with `complete_menus: false`: it still shows in
//! Preferences, strings without a row fall back to English, and the completeness tests
//! skip it until every row is done. A CJK language also sets `script` and takes its system
//! font from `crate::cjk_font`.
//!
//! That is all: the Preferences dropdown, the system-locale match and the catalog tests
//! (parse, placeholders, plural forms, coverage of every `tl!` literal) pick it up from the
@@ -30,5 +35,5 @@ mod translate;
mod tests;
pub use detect::{detect_system_lang_in_background, set_system_locales, set_test_lang, system_lang};
pub use lang::{LANGUAGES, Lang, LangInfo, lang_from_tag};
pub use lang::{LANGUAGES, Lang, LangInfo, Script, lang_from_tag};
pub use translate::{current, fmt, has, message, msg, set_current, t, t_id, tn, tr, tr_ctx, tr_id, trn};
+348
View File
@@ -0,0 +1,348 @@
# Polish (pl) catalog for ArtCraft Launcher.
# Format: context<TAB>source<TAB>translation
# Empty context = plain UI string (tl!, t).
# @id = command-id keyed (tr_id).
# @plural = plural forms: one|other (English) or one|few|many (Polish).
# @msg = status/error message template (message, msg).
# Other context = disambiguation for tr_ctx.
#
# Clean-room: written from the meaning of ArtCraft Launcher's English labels in neutral Polish,
# using the terms Polish users of creative and system software know. The user is addressed
# informally ("twoje", "cię"), as in Polish system interfaces.
#
# Coverage: the whole interface — every menu string and tl! literal (complete_menus = true).
# Left in English on purpose: product name "ArtCraft Launcher" (and its "Launcher" tag), suite
# name "Craft App Suite", app names (PhotoCraft, LightCraft, FilmCraft, DesignCraft, VectorCraft,
# PdfCraft, EffectCraft, WordCraft, DeckCraft, GridCraft, CADCraft, SoundCraft, FigCraft), "GitHub",
# "Discord".
# Shortcut keys keep their key-cap names (Shift, Alt, Ctrl, Cmd, Esc).
# Trailing "…" (U+2026) must be preserved. Placeholders {name}, {n}, {version}, {count}, {v},
# {what}, {unit}, {real}, {e} must appear in translation.
Launcher Launcher
Craft App Suite Craft App Suite
Open Source Tools for Creatives, Makers, and Builders Narzędzia open source dla artystów, twórców i konstruktorów
Settings Ustawienia
Check for updates Sprawdź dostępność aktualizacji
Grid Siatka
Table Tabela
Show apps as cards Wyświetlaj listę jako karty
Show apps as a table Wyświetlaj listę jako tabelę
All Wszystkie
Updates Aktualizacje
Not installed Niezainstalowane
Search apps Wyszukaj aplikacje
Sort Sortowanie
Suite order Domyślna kolejność
Name Nazwa
Inspired by Wzorowane na
Status Status
Installed version Zainstalowana wersja
Latest version Najnowsza wersja
Newest release Najnowsze wydanie
Discord Discord
GitHub GitHub
Website Strona
Classic apps Klasyczne aplikacje
New apps Nowe aplikacje
Classic Klasyk
NEW NOWE
Install Instaluj
Update to {v} Zaktualizuj do {v}
Open Otwórz
Download for {os} Pobierz dla {os}
Download Pobierz
Coming soon Wkrótce
Checking… Sprawdzanie…
Not available Niedostępna
Offline Offline
Downloading {done} of {total} Pobrano {done} z {total}
Downloading… Pobieranie…
Reconnecting… Wznawianie połączenia…
Verifying… Weryfikowanie…
Installing, then restarting… Instalowanie a następnie ponowne uruchomienie…
Installing… Instalowanie…
Removing… Usuwanie…
Pause download Wstrzymaj pobieranie
Cancel download Anuluj pobieranie
Resume download Wznów pobieranie
Discard download Usuń pobrane
Release notes Informacje o wydaniu
Show in Finder Pokaż w Finderze
Show in Explorer Pokaż w Eksploratorze plików
Show in Files Pokaż w menedżerze plików
Reinstall Przeinstaluj
Uninstall… Odinstaluj…
All releases Wszystkie wydania
ArtCraft Launcher {version} is available. Dostępna jest nowa wersja ArtCraft Launcher ({version}).
Dismiss Zamknij
Update and restart Zaktualizuj i zrestartuj
A new version is available. Would you like to relaunch with the updated version? Nowa wersja jest dostępna. Czy chcesz uruchomić ponownie ze zaktualizowaną wersją
Later Później
What's new Co nowego
No notes were published with this release. Dla tego wydania nie opublikowano żadnych notatek.
Released {date} Wydano {date}
Open on GitHub Otwórz na GitHubie
Close Zamknij
ArtCraft Launcher {version} ArtCraft Launcher {version}
Only when I ask Tylko kiedy zapytam
Automatically download and install updates Automatycznie pobieraj i instaluj aktualizacje
Notify me when an installed app has an update Powiadom mnie o aktualizacjach dla zainstalowanych aplikacji
Include pre-releases (betas and release candidates) Uwzględnij wersje przedpremierowe (beta i wersje kandydujące)
Installing on Linux Instalowanie w systemie Linux
Install new apps as Instaluj nowe aplikacje jako
System package (recommended) Pakiet systemowy (zalecany)
Updates always use the format an app was installed with. Aktualizacje zawsze używają formatu, w jakim pierwotnie zainstalowano aplikację.
Running in the background Działanie w tle
Keep running in the tray when the window is closed Po zamknięciu okna działaj w zasobniku systemowym
Show the window on the current desktop Pokaż okno na bieżącym pulpicie
Start ArtCraft Launcher when I log in Uruchom ArtCraft Launcher po zalogowaniu
Community Społeczność
Questions, ideas and news from the ArtCraft team Pytania, pomysły i nowości od zespołu ArtCraft
Appearance Motyw
Quit ArtCraft Launcher Wyjdź z ArtCraft Launcher
Done Gotowe
Uninstall {name}? Odinstalować {name}?
The app will be moved to the Trash. Your documents and settings stay. Aplikacja zostanie przeniesiona do kosza. Twoje dokumenty i ustawienia pozostaną.
Windows will remove it like any installed program. Your documents and settings stay. Windows usunie to jak normalny zainstalowany program. Twoje dokumenty i ustawienia pozostaną.
Your system's package manager will remove it, and may ask for your password. Your documents and settings stay. Twój systemowy menedżer pakietów usunie aplikację, może też zapytać cię o hasło. Twoje dokumenty i ustawienia pozostaną.
The app will be removed. Your documents and settings stay. Ta aplikacja zostanie usunięta. Twoje dokumenty i ustawienia pozostaną.
Uninstall Odinstaluj
Cancel Anuluj
Checked {ago} Ostatnio sprawdzono {ago}
just now przed chwilą
yesterday wczoraj
{month} {day}, {year} {day} {month} {year}
Automatically Automatycznie
Every Craft app Wszystkie aplikacje Craft
Apps on this computer Aplikacje na tym komputerze
Installed apps with a newer version Zainstalowane aplikacje z nowszą wersją
Apps you can install Aplikacje, które możesz zainstalować
Hide App Ukryj aplikację
Unhide App Pokaż aplikację
Hidden Apps Ukryte aplikacje
Apps you hid Aplikacje ukryte przez ciebie
Checked {ago} · using {sources} Ostatnio sprawdzono {ago} · przy użyciu {sources}
{a} and {b} {a} i {b}
Join the Discord Dołącz na Discorda
Learn more Więcej
Minimize Minimalizuj
Maximize Maksymalizuj
Restore Przywróć
New Nowe
OK OK
Open link Otwórz link
Update Aktualizacja
{name} icon Ikona {name}
Loading ArtCraft Launcher… Ładowanie ArtCraft Launcher…
Cancel update Anuluj aktualizację
Stop waiting; the update can be started again later Przerwij oczekiwanie; aktualizację można uruchomić ponownie później
Download again Pobierz ponownie
Install cancelled Anulowano instalację
Install cancelled. The download is kept for the next try. Anulowano instalację. Pobrany plik zostanie zachowany na następną próbę.
Update cancelled Anulowano aktualizację
Uninstall cancelled Anulowano odinstalowanie
Retry install Ponów instalację
Retry update to {version} Ponów aktualizację do {version}
The download is still here. It's checked again, then installed. Pobrany plik wciąż tu jest. Zostanie sprawdzony ponownie, a następnie zainstalowany.
Waiting for another app… Oczekiwanie na inną aplikację…
Waiting for other installs… Oczekiwanie na inne instalacje…
# Package formats offered for download (PackageKind::label)
Installer (.msi) Instalator (.msi)
Portable (.zip) Wersja przenośna (.zip)
Disk image (.dmg) Obraz dysku (.dmg)
Debian package (.deb) Pakiet Debiana (.deb)
RPM package (.rpm) Pakiet RPM (.rpm)
Flatpak bundle Pakiet Flatpak
Tarball (.tar.gz) Archiwum (.tar.gz)
# Contextual: each app's tagline, keyed by app id
photocraft Image editing Edycja zdjęć
vectorcraft Vector illustration Ilustracja wektorowa
filmcraft Video editing, color and sound Montaż wideo, kolor i dźwięk
lightcraft Photo library and raw development Biblioteka zdjęć i obróbka RAW
pdfcraft Reading, organizing and protecting PDFs Czytanie, porządkowanie i zabezpieczanie plików PDF
effectcraft Motion graphics and visual effects Grafika ruchoma i efekty wizualne
designcraft Page layout and publishing Skład stron i publikowanie
wordcraft Writing and document design Pisanie i projektowanie dokumentów
deckcraft Presentations and slide shows Prezentacje i pokazy slajdów
gridcraft Spreadsheets and data Arkusze kalkulacyjne i dane
cadcraft Computer-aided design and drafting Projektowanie i kreślenie wspomagane komputerowo
soundcraft Recording, editing and mixing audio Nagrywanie, edycja i miksowanie dźwięku
figcraft Interface design and prototyping Projektowanie interfejsów i prototypowanie
# Contextual: month abbreviations for date rendering
month Jan sty
month Feb lut
month Mar mar
month Apr kwi
month May maj
month Jun cze
month Jul lip
month Aug sie
month Sep wrz
month Oct paź
month Nov lis
month Dec gru
Not checked yet Jeszcze nie sprawdzono
GitHub's hourly limit was reached; full details return in {min} min Osiągnięto godzinowy limit GitHuba; pełne informacje wrócą za {min} min
GitHub limit hit; back in {min} min Limit GitHuba osiągnięty; powrót za {min} min
Installing for {platform} Instalowanie dla {platform}
Downloads for {platform} Do pobrania dla {platform}
No downloads for this system Brak pobrań dla tego systemu
Join the ArtCraft community on Discord Dołącz do społeczności ArtCraft na Discordzie
ArtCraft on {name} ArtCraft w serwisie {name}
Couldn't open {name}: {e} Nie udało się otworzyć {name}: {e}
Download cancelled Anulowano pobieranie
Available to install Dostępna do instalacji
Available Dostępna
Installed · up to date Zainstalowana · aktualna
Update available Dostępna aktualizacja
Installed Zainstalowana
installed zainstalowana
Not available here Niedostępna w tym systemie
Updating… Aktualizowanie…
Update paused Aktualizacja wstrzymana
Download paused Pobieranie wstrzymane
{version} installed → {latest} released {version} zainstalowana → {latest} wydana
Version {version} Wersja {version}
{installed} installed · latest {latest} {installed} zainstalowana · najnowsza {latest}
Installed · latest {latest} Zainstalowana · najnowsza {latest}
Version {version} installed Wersja {version} zainstalowana
Version {latest} Wersja {latest}
No release published yet Nie opublikowano jeszcze wydania
ArtCraft Launcher ArtCraft Launcher
App Aplikacja
Latest Najnowsza
Released Wydano
Auto Automatycznie
Language Język
Ascending Rosnąco
Descending Malejąco
Click to reverse the order Kliknij, aby odwrócić kolejność
Click to sort by this column Kliknij, aby posortować według tej kolumny
Like {name} Wzorowane na {name}
{done} of {total} {done} z {total}
Downloading Pobieranie
Verifying checksum… Weryfikowanie sumy kontrolnej…
Resume Wznów
{done} of {total} downloaded Pobrano {done} z {total}
{done} downloaded Pobrano {done}
Checking for updates… Sprawdzanie aktualizacji…
Downloads the new version, replaces this one and opens it again Pobiera nową wersję, zastępuje obecną i uruchamia ją ponownie.
Couldn't check for updates: {f} Nie udało się sprawdzić aktualizacji: {f}
Website (coming soon) Strona (wkrótce)
GitHub repository Repozytorium na GitHubie
ArtCraft ArtCraft
Update 1 app Zaktualizuj 1 aplikację
Update all ({count}) Zaktualizuj wszystkie ({count})
⊞ Grid ⊞ Siatka
☰ Table ☰ Tabela
🔍 Search apps 🔍 Wyszukaj aplikacje
{name} {version} {name} {version}
Match system Zgodnie z systemem
Dark Ciemny
Light Jasny
Full changelog Pełna lista zmian
link link
No apps match Brak pasujących aplikacji
Open ArtCraft Launcher to update. Otwórz ArtCraft Launcher, aby zaktualizować aplikacje.
{name} {v} is available Dostępna jest nowa wersja {name} {v}
{count} updates are available Dostępne aktualizacje: {count}
These notes weren't fetched (GitHub's request limit was reached). They're on the release page. Nie udało się pobrać tych notatek (osiągnięto limit zapytań GitHuba). Znajdziesz je na stronie wydania.
When on, opening the launcher from the menu bar brings its window to the desktop you're on. When off, macOS switches you to the desktop the window was on. Gdy włączone, otwarcie launchera z paska menu przenosi jego okno na bieżący pulpit. Gdy wyłączone, macOS przełącza cię na pulpit, na którym było okno.
Couldn't change the login item: {e} Nie udało się zmienić elementu logowania: {e}
# Contextual: table column headers
column App Aplikacja
column Installed Zainstalowana
column Latest Najnowsza
column Released Data wydania
# Contextual: the filter tab names a group (the status pill says "Installed")
filter Installed Zainstalowane
App details Szczegóły aplikacji
Close (Esc) Zamknij (Esc)
Features Funkcje
Latest release Najnowsze wydanie
Version Wersja
Platforms Platformy
Releases Wydania
Enlarge picture Powiększ obraz
Click to enlarge Kliknij, aby powiększyć
Previous picture (←) Poprzedni obraz (←)
Next picture (→) Następny obraz (→)
Loading pictures… Wczytywanie obrazów…
Pictures couldn't be loaded. Reopen this page to try again. Nie udało się wczytać obrazów. Otwórz tę stronę ponownie, aby spróbować jeszcze raz.
{name} details {name} – szczegóły
# Plural forms for Polish (one|few|many)
@plural {n} app|{n} apps {n} aplikacja|{n} aplikacje|{n} aplikacji
@plural {n} min ago|{n} min ago {n} min temu|{n} min temu|{n} min temu
@plural {n} h ago|{n} h ago {n} godz. temu|{n} godz. temu|{n} godz. temu
@plural {n} day ago|{n} days ago {n} dzień temu|{n} dni temu|{n} dni temu
# Message templates (@msg): toasts from the backend. {placeholders} match any text.
@msg Couldn't open {name}: {e} Nie udało się otworzyć {name}: {e}
@msg Download cancelled Anulowano pobieranie
@msg {name} updated to {version} Zaktualizowano {name} do wersji {version}
@msg {name} installed: {version} Zainstalowano {name} w wersji {version}
@msg {name} updated to {version} (this release publishes no checksum, so only its size was checked) Zaktualizowano {name} do wersji {version} (to wydanie nie zawiera sumy kontrolnej, więc sprawdzono tylko rozmiar)
@msg {name} installed: {version} (this release publishes no checksum, so only its size was checked) Zainstalowano {name} w wersji {version} (to wydanie nie zawiera sumy kontrolnej, więc sprawdzono tylko rozmiar)
@msg {name} was removed Usunięto {name}
@msg {name} is running. Quit it, then try again. {name} jest uruchomiona. Zamknij ją i spróbuj ponownie.
# Command IDs for menu items (@id)
# Format: @id<TAB>command_id<TAB>translation
@id app.install Instaluj
@id app.update Zaktualizuj do {v}
@id app.open Otwórz
@id app.download Pobierz dla {os}
@id app.reveal Pokaż w Finderze
@id app.reinstall Przeinstaluj
@id app.uninstall Odinstaluj…
@id app.notes Informacje o wydaniu
@id app.website Strona
@id app.github Repozytorium na GitHubie
@id app.releases Wszystkie wydania
@id launcher.update Zaktualizuj i zrestartuj
@id launcher.download Pobierz
@id launcher.notes Co nowego
@id launcher.dismiss Zamknij
@id settings.open Ustawienia
@id settings.quit Wyjdź z ArtCraft Launcher
@id view.grid Siatka
@id view.table Tabela
@id filter.all Wszystkie
@id filter.installed Zainstalowane
@id filter.updates Aktualizacje
@id filter.notinstalled Niezainstalowane
@id sort.suite Domyślna kolejność
@id sort.name Nazwa
@id sort.inspired Wzorowane na
@id sort.status Status
@id sort.installed Zainstalowana wersja
@id sort.latest Najnowsza wersja
@id sort.released Najnowsze wydanie
@id help.discord Discord
@id help.github GitHub
@id help.website Strona
@id update.check Sprawdź dostępność aktualizacji
@id update.check_auto Automatycznie sprawdzaj dostępność aktualizacji
@id update.notify Powiadom mnie o aktualizacjach dla zainstalowanych aplikacji
@id update.prereleases Uwzględnij wersje przedpremierowe (beta i wersje kandydujące)
@id linux.preferred_kind Instaluj nowe aplikacje jako
@id tray.close_to_tray Po zamknięciu okna działaj w zasobniku systemowym
@id tray.show_on_current_desktop Pokaż okno na bieżącym pulpicie
@id tray.login_item Uruchom ArtCraft Launcher po zalogowaniu
@id appearance.theme Wygląd
# Tray (menu bar) menu and tooltip
Show ArtCraft Launcher Pokaż ArtCraft Launcher
Check for Updates Sprawdź dostępność aktualizacji
Visit getartcraft.com Odwiedź getartcraft.com
ArtCraft on GitHub ArtCraft na GitHubie
Join the ArtCraft Discord Dołącz do Discorda ArtCraft
@plural ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available ArtCraft Launcher · {n} dostępna aktualizacja|ArtCraft Launcher · {n} dostępne aktualizacje|ArtCraft Launcher · {n} dostępnych aktualizacji
Can't render this file because it contains an unexpected character in line 11 and column 15.
+20 -1
View File
@@ -106,6 +106,10 @@
Apps on this computer Aplicativos neste computador
Installed apps with a newer version Aplicativos instalados com nova versão
Apps you can install Aplicativos que você pode instalar
Hide App Ocultar aplicativo
Unhide App Mostrar aplicativo
Hidden Apps Aplicativos ocultos
Apps you hid Aplicativos que você ocultou
Checked {ago} · using {sources} Verificado {ago} · via {sources}
{a} and {b} {a} e {b}
Join the Discord Entrar no Discord
@@ -154,6 +158,7 @@ deckcraft Presentations and slide shows Apresentações e slides
gridcraft Spreadsheets and data Planilhas e dados
cadcraft Computer-aided design and drafting Desenho técnico e projeto assistido por computador (CAD)
soundcraft Recording, editing and mixing audio Gravação, edição e mixagem de áudio
figcraft Interface design and prototyping Design de interfaces e prototipagem
# Contextual: month abbreviations for date rendering
month Jan jan.
@@ -191,6 +196,7 @@ month Dec dez.
{version} installed → {latest} released {version} instalado → {latest} lançado
Version {version} Versão {version}
{installed} installed · latest {latest} {installed} instalado · última {latest}
Installed · latest {latest} Instalado · última {latest}
Version {version} installed Versão {version} instalada
Version {latest} Versão {latest}
No release published yet Nenhum lançamento publicado ainda
@@ -214,7 +220,6 @@ month Dec dez.
Checking for updates… Verificando atualizações…
Downloads the new version, replaces this one and opens it again Baixa a nova versão, substitui a atual e abre novamente
Couldn't check for updates: {f} Não foi possível verificar atualizações: {f}
Website (coming soon) Site (em breve)
GitHub repository Repositório no GitHub
ArtCraft ArtCraft
Update 1 app Atualizar 1 aplicativo
@@ -244,6 +249,7 @@ column Released Lançamento
# Contextual: the filter tab names a group (the status pill says "Installed")
filter Installed Instalados
{name} details Detalhes de {name}
# Plural forms for Brazilian Portuguese (one|other)
@plural {n} app|{n} apps {n} aplicativo|{n} aplicativos
@@ -312,4 +318,17 @@ filter Installed Instalados
Visit getartcraft.com Visitar getartcraft.com
ArtCraft on GitHub ArtCraft no GitHub
Join the ArtCraft Discord Entrar no Discord da ArtCraft
App details Detalhes do aplicativo
Close (Esc) Fechar (Esc)
Features Recursos
Latest release Último lançamento
Version Versão
Platforms Plataformas
Releases Lançamentos
Enlarge picture Ampliar imagem
Click to enlarge Clique para ampliar
Previous picture (←) Imagem anterior (←)
Next picture (→) Próxima imagem (→)
Loading pictures… Carregando imagens…
Pictures couldn't be loaded. Reopen this page to try again. Não foi possível carregar as imagens. Abra a página novamente para tentar de novo.
@plural ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available ArtCraft Launcher · {n} atualização disponível|ArtCraft Launcher · {n} atualizações disponíveis
Can't render this file because it contains an unexpected character in line 250 and column 66.
+21 -5
View File
@@ -13,7 +13,7 @@
# Left in English on purpose: product name "ArtCraft Launcher" (and its "Launcher" tag), suite
# name "Craft App Suite",
# app names (PhotoCraft, LightCraft, FilmCraft, DesignCraft, VectorCraft, PdfCraft, EffectCraft,
# WordCraft, DeckCraft, GridCraft, CADCraft, SoundCraft), "GitHub", "Discord", "Website".
# WordCraft, DeckCraft, GridCraft, CADCraft, SoundCraft, FigCraft), "GitHub", "Discord", "Website".
# Shortcut keys keep their key-cap names (Shift, Alt, Ctrl, Cmd, Esc).
# Trailing "…" (U+2026) must be preserved. Placeholders {name}, {n}, {version}, {count}, {v},
# {what}, {unit}, {real}, {e} must appear in translation.
@@ -34,7 +34,6 @@
Sort Сортировка
Suite order По классу приложений
Name Имя
Inspired by Вдохновлено
Status Статус
Installed version Установленная версия
Latest version Последняя версия
@@ -117,6 +116,10 @@
Apps on this computer Приложения на этом компьютере
Installed apps with a newer version Установленные приложения, у которых есть новая версия
Apps you can install Приложения, которые можно установить
Hide App Скрыть приложение
Unhide App Показать приложение
Hidden Apps Скрытые приложения
Apps you hid Приложения, которые вы скрыли
Checked {ago} · using {sources} Проверено {ago} · через {sources}
{a} and {b} {a} и {b}
Join the Discord Присоединиться к Discord
@@ -165,6 +168,7 @@ deckcraft Presentations and slide shows Презентации и слайд-ш
gridcraft Spreadsheets and data Электронные таблицы и данные
cadcraft Computer-aided design and drafting Автоматизированное проектирование и черчение
soundcraft Recording, editing and mixing audio Запись, редактирование и сведение звука
figcraft Interface design and prototyping Дизайн интерфейсов и прототипирование
# Contextual: month abbreviations for date rendering
month Jan янв.
@@ -202,6 +206,7 @@ month Dec дек.
{version} installed → {latest} released {version} установлено → {latest} выпущено
Version {version} Версия {version}
{installed} installed · latest {latest} {installed} установлено · последняя {latest}
Installed · latest {latest} Установлено · последняя {latest}
Version {version} installed Версия {version} установлена
Version {latest} Версия {latest}
No release published yet Релиз ещё не опубликован
@@ -215,7 +220,6 @@ month Dec дек.
Descending По убыванию
Click to reverse the order Нажмите, чтобы изменить порядок
Click to sort by this column Нажмите, чтобы сортировать по этому столбцу
Like {name} Аналог {name}
{done} of {total} {done} из {total}
Downloading Скачивание
Verifying checksum… Проверка контрольной суммы…
@@ -225,7 +229,6 @@ month Dec дек.
Checking for updates… Проверка обновлений…
Downloads the new version, replaces this one and opens it again Скачивает новую версию, заменяет текущую и открывает её заново
Couldn't check for updates: {f} Не удалось проверить обновления: {f}
Website (coming soon) Веб-сайт (скоро)
GitHub repository Репозиторий GitHub
ArtCraft ArtCraft
Update 1 app Обновить 1 приложение
@@ -255,6 +258,7 @@ column Released Дата релиза
# Contextual: the filter tab names a group (the status pill says "Installed")
filter Installed Установлены
{name} details {name}: подробности
# Plural forms for Russian (one|few|many)
@plural {n} app|{n} apps {n} приложение|{n} приложения|{n} приложений
@@ -299,7 +303,6 @@ filter Installed Установлены
@id filter.notinstalled Не установленные
@id sort.suite По классу приложений
@id sort.name Имя
@id sort.inspired Вдохновлено
@id sort.status Статус
@id sort.installed Установленная версия
@id sort.latest Последняя версия
@@ -323,4 +326,17 @@ filter Installed Установлены
Visit getartcraft.com Открыть getartcraft.com
ArtCraft on GitHub ArtCraft на GitHub
Join the ArtCraft Discord Присоединиться к Discord ArtCraft
App details О приложении
Close (Esc) Закрыть (Esc)
Features Возможности
Latest release Последний релиз
Version Версия
Platforms Платформы
Releases Релизы
Enlarge picture Увеличить изображение
Click to enlarge Нажмите, чтобы увеличить
Previous picture (←) Предыдущее изображение (←)
Next picture (→) Следующее изображение (→)
Loading pictures… Загрузка изображений…
Pictures couldn't be loaded. Reopen this page to try again. Не удалось загрузить изображения. Откройте страницу снова, чтобы повторить попытку.
@plural ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available ArtCraft Launcher · {n} обновление|ArtCraft Launcher · {n} обновления|ArtCraft Launcher · {n} обновлений
Can't render this file because it contains an unexpected character in line 13 and column 44.
+142 -14
View File
@@ -4,7 +4,7 @@
use std::collections::{BTreeSet, HashSet};
use super::catalog::{Catalog, unescape};
use super::lang::plural_east_slavic;
use super::lang::{plural_czech, plural_east_slavic, plural_polish};
use super::{LANGUAGES, Lang, LangInfo, lang_from_tag, message, tr, trn};
/// Each catalog's rows as (line number, context, source, translation), split exactly as the
@@ -124,6 +124,7 @@ fn ellipsis_preserved() {
fn complete_catalogs_cover_every_ui_literal() {
let sources = [
include_str!("../app.rs"),
include_str!("../details.rs"),
include_str!("../dialogs.rs"),
include_str!("../model.rs"),
include_str!("../notes.rs"),
@@ -208,6 +209,39 @@ fn russian_plural_rule_works() {
assert_eq!(trn(Lang::RU, 3, "{n} day ago", "{n} days ago"), "3 дня назад");
}
#[test]
fn czech_plural_rule_works() {
for n in [0, 5, 11, 21, 22, 25] {
assert_eq!(plural_czech(n), 2, "{n}");
}
assert_eq!(plural_czech(1), 0);
for n in [2, 4] {
assert_eq!(plural_czech(n), 1, "{n}");
}
assert_eq!(trn(Lang::CS, 1, "{n} app", "{n} apps"), "1 aplikace");
assert_eq!(trn(Lang::CS, 2, "{n} app", "{n} apps"), "2 aplikace");
assert_eq!(trn(Lang::CS, 5, "{n} app", "{n} apps"), "5 aplikací");
}
#[test]
fn polish_plural_rule_works() {
// Polish says "one" only for 1 itself: 21, 31 and 101 take "many", unlike Russian.
assert_eq!(plural_polish(1), 0);
for n in [2, 3, 4, 22, 23, 24, 102, 122] {
assert_eq!(plural_polish(n), 1, "{n}");
}
for n in [0, 5, 11, 12, 13, 14, 15, 21, 25, 31, 100, 101, 111, 112] {
assert_eq!(plural_polish(n), 2, "{n}");
}
assert_eq!(trn(Lang::PL, 1, "{n} app", "{n} apps"), "1 aplikacja");
assert_eq!(trn(Lang::PL, 3, "{n} app", "{n} apps"), "3 aplikacje");
assert_eq!(trn(Lang::PL, 22, "{n} app", "{n} apps"), "22 aplikacje");
assert_eq!(trn(Lang::PL, 5, "{n} app", "{n} apps"), "5 aplikacji");
assert_eq!(trn(Lang::PL, 21, "{n} app", "{n} apps"), "21 aplikacji");
// A key the Polish catalog doesn't carry falls back to the English forms.
assert_eq!(trn(Lang::PL, 3, "{n} unknown item", "{n} unknown items"), "3 unknown items");
}
#[test]
fn untranslated_plurals_fall_back_to_english_by_count() {
// Russian's rule says "one" for 21, but English says "other".
@@ -218,6 +252,10 @@ fn untranslated_plurals_fall_back_to_english_by_count() {
#[test]
fn locale_tags_resolve() {
for tag in ["cs", "cs-CZ", "cs_CZ.UTF-8", "CS_cz"] {
assert_eq!(lang_from_tag(tag), Some(Lang::CS), "{tag}");
}
assert_eq!(super::detect::first_supported(["xx-XX", "cs-CZ", "en-US"]), Some(Lang::CS));
assert_eq!(lang_from_tag("pt_BR.UTF-8"), Some(Lang::PT_BR));
assert_eq!(lang_from_tag("pt-BR"), Some(Lang::PT_BR));
assert_eq!(lang_from_tag("pt"), Some(Lang::PT_BR));
@@ -252,6 +290,24 @@ fn german_counts_dates_and_messages_work() {
assert_eq!(tr(Lang::DE, "Untranslated diagnostic"), "Untranslated diagnostic");
}
#[test]
fn hungarian_locales_and_plurals_work() {
for tag in ["hu", "hu-HU", "hu_HU.UTF-8", "HU_hu"] {
assert_eq!(lang_from_tag(tag), Some(Lang::HU), "{tag}");
}
assert_eq!(super::detect::first_supported(["xx-XX", "hu-HU", "en-US"]), Some(Lang::HU));
// After a number the noun stays singular; 1 is still the "one" form.
assert_eq!(trn(Lang::HU, 0, "{n} app", "{n} apps"), "0 alkalmazás");
assert_eq!(trn(Lang::HU, 1, "{n} app", "{n} apps"), "1 alkalmazás");
assert_eq!(trn(Lang::HU, 2, "{n} app", "{n} apps"), "2 alkalmazás");
assert_eq!(trn(Lang::HU, 21, "{n} day ago", "{n} days ago"), "21 napja");
assert_eq!(trn(Lang::HU, 1, "{n} day ago", "{n} days ago"), "1 napja");
assert_eq!(super::tr_ctx(Lang::HU, "month", "Mar"), "márc.");
assert_eq!(tr(Lang::HU, "{month} {day}, {year}"), "{year}. {month} {day}.");
assert_eq!(message(Lang::HU, "PhotoCraft updated to 0.5.0"), "PhotoCraft frissítve. Verzió: 0.5.0");
assert_eq!(tr(Lang::HU, "Untranslated diagnostic"), "Untranslated diagnostic");
}
#[test]
fn french_locales_and_plurals_work() {
for tag in ["fr", "fr-FR", "fr_CA.UTF-8", "fr-BE", "FR_ch"] {
@@ -265,6 +321,28 @@ fn french_locales_and_plurals_work() {
assert_eq!(trn(Lang::FR, 3, "{n} day ago", "{n} days ago"), "il y a 3 jours");
}
#[test]
fn spanish_locales_and_plurals_work() {
for tag in ["es", "es-ES", "es_MX.UTF-8", "es-419", "es-PA", "ES_ar"] {
assert_eq!(lang_from_tag(tag), Some(Lang::ES), "{tag}");
}
assert_eq!(super::detect::first_supported(["xx-XX", "es-PA", "en-US"]), Some(Lang::ES));
// Only 1 is singular in Spanish, as in English.
assert_eq!(trn(Lang::ES, 0, "{n} app", "{n} apps"), "0 aplicaciones");
assert_eq!(trn(Lang::ES, 1, "{n} app", "{n} apps"), "1 aplicación");
assert_eq!(trn(Lang::ES, 3, "{n} day ago", "{n} days ago"), "hace 3 días");
assert_eq!(tr(Lang::ES, "{month} {day}, {year}"), "{day} {month} {year}");
assert_eq!(message(Lang::ES, "PhotoCraft updated to 0.5.0"), "PhotoCraft actualizada a 0.5.0");
}
#[test]
fn polish_locales_resolve() {
for tag in ["pl", "pl-PL", "pl_PL.UTF-8", "PL_pl"] {
assert_eq!(lang_from_tag(tag), Some(Lang::PL), "{tag}");
}
assert_eq!(super::detect::first_supported(["xx-XX", "pl-PL", "en-US"]), Some(Lang::PL));
}
#[test]
fn simplified_chinese_locales_respect_the_script() {
for tag in ["zh", "zh-CN", "zh_CN.UTF-8", "zh-SG", "zh-Hans", "zh-Hans-CN", "zh-Hans-HK", "ZH_cn"] {
@@ -288,16 +366,34 @@ fn chinese_counts_and_english_fallback_work() {
assert_eq!(message(Lang::ZH_CN, "PhotoCraft updated to 0.5.0"), "PhotoCraft 已更新至 0.5.0");
}
#[test]
fn japanese_locales_and_counts_work() {
for tag in ["ja", "ja-JP", "ja_JP.UTF-8", "JA_jp"] {
assert_eq!(lang_from_tag(tag), Some(Lang::JA), "{tag}");
}
assert_eq!(super::detect::first_supported(["xx-XX", "ja-JP", "en-US"]), Some(Lang::JA));
// Japanese has one plural form.
for n in [0, 1, 2, 11, 100] {
assert_eq!(trn(Lang::JA, n, "{n} app", "{n} apps"), format!("{n} 個のアプリ"));
assert_eq!(trn(Lang::JA, n, "{n} day ago", "{n} days ago"), format!("{n} 日前"));
}
assert_eq!(trn(Lang::JA, 2, "{n} unknown item", "{n} unknown items"), "2 unknown items");
assert_eq!(tr(Lang::JA, "Untranslated diagnostic"), "Untranslated diagnostic");
assert_eq!(super::tr_ctx(Lang::JA, "month", "Mar"), "3月");
assert_eq!(tr(Lang::JA, "{month} {day}, {year}"), "{year}年{month}{day}日");
assert_eq!(message(Lang::JA, "PhotoCraft updated to 0.5.0"), "PhotoCraft を 0.5.0 に更新しました");
}
/// With the system's Chinese font installed, every UI family can draw the whole Chinese catalog.
/// Machines without one (CI, the web) skip this; the UI then stays in English.
#[test]
fn system_font_covers_chinese_in_every_ui_family() {
use egui::{FontFamily, FontId};
if !crate::theme::cjk_font_available() {
if !crate::theme::cjk_font_available(Lang::ZH_CN) {
return;
}
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx, true);
crate::theme::install_fonts(&ctx, Some(Lang::ZH_CN));
let mut output = ctx.run_ui(egui::RawInput::default(), |_| {});
output.textures_delta.clear();
let text: String = Lang::ZH_CN
@@ -322,22 +418,54 @@ fn system_font_covers_chinese_in_every_ui_family() {
}
}
/// With a system CJK font installed, every UI family can draw the whole Japanese catalog.
/// Machines without one (CI, the web) skip this.
#[test]
fn system_font_covers_japanese_in_every_ui_family() {
use egui::{FontFamily, FontId};
if !crate::theme::cjk_font_available(Lang::JA) {
return;
}
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx, Some(Lang::JA));
let mut output = ctx.run_ui(egui::RawInput::default(), |_| {});
output.textures_delta.clear();
let text: String = Lang::JA
.0
.source
.lines()
.filter_map(|line| line.split('\t').nth(2))
.flat_map(str::chars)
.filter(|c| ('\u{3040}'..='\u{30ff}').contains(c) || ('\u{3400}'..='\u{9fff}').contains(c))
.collect();
assert!(text.chars().count() > 200);
for family in [
FontFamily::Proportional,
FontFamily::Monospace,
crate::theme::medium(),
crate::theme::semibold(),
crate::theme::display(),
crate::theme::mono(),
crate::theme::mono_bold(),
] {
ctx.fonts_mut(|fonts| assert!(fonts.has_glyphs(&FontId::new(14.0, family.clone()), &text), "missing glyph in {family:?}"));
}
}
#[test]
fn language_labels_come_from_the_registry() {
use crate::settings::Settings;
for lang in Lang::all().filter(|l| !l.needs_cjk_font()) {
assert_eq!(Settings::language_label(lang.code()), lang.name());
assert_eq!(Settings::language_label(lang.code(), None), lang.name());
}
// Chinese: in English until its font is loaded (which a label never does), then both.
// (Other tests may load the font meanwhile.)
let before = crate::theme::cjk_font_loaded();
let label = Settings::language_label(Lang::ZH_CN.code());
let after = crate::theme::cjk_font_loaded();
assert!(label == "Simplified Chinese (简体中文)" || label == "Simplified Chinese", "{label}");
assert!(!before || label.contains("简体中文"), "{label}");
assert!(after || !label.contains("简体中文"), "{label}");
assert_eq!(Settings::language_label(Settings::LANGUAGE_AUTO), "Auto");
assert_eq!(Settings::language_label("xx"), "Auto");
// A CJK language is named in English, and its own name is added only while its face is the one
// in the font stack: no other face draws it.
assert_eq!(Settings::language_label(Lang::ZH_CN.code(), None), "Simplified Chinese");
assert_eq!(Settings::language_label(Lang::ZH_CN.code(), Some(Lang::ZH_CN)), "Simplified Chinese (简体中文)");
assert_eq!(Settings::language_label(Lang::JA.code(), Some(Lang::ZH_CN)), "Japanese");
assert_eq!(Settings::language_label(Lang::JA.code(), Some(Lang::JA)), "Japanese (日本語)");
assert_eq!(Settings::language_label(Settings::LANGUAGE_AUTO, None), "Auto");
assert_eq!(Settings::language_label("xx", None), "Auto");
}
// The parser.
+20 -4
View File
@@ -21,7 +21,6 @@
Sort 排序
Suite order 默认顺序
Name 名称
Inspired by 参考软件
Status 状态
Installed version 已安装版本
Latest version 最新版本
@@ -127,6 +126,7 @@ month Dec 12月
{version} installed → {latest} released 已安装 {version} → 最新 {latest}
Version {version} 版本 {version}
{installed} installed · latest {latest} 已安装 {installed} · 最新 {latest}
Installed · latest {latest} 已安装 · 最新 {latest}
Version {version} installed 已安装版本 {version}
Version {latest} 版本 {latest}
No release published yet 尚未发布版本
@@ -140,7 +140,6 @@ month Dec 12月
Descending 降序
Click to reverse the order 点击切换排序方向
Click to sort by this column 点击按此列排序
Like {name} 类似 {name}
{done} of {total} {done} / {total}
Downloading 正在下载
Verifying checksum… 正在验证校验和…
@@ -150,7 +149,6 @@ month Dec 12月
Checking for updates… 正在检查更新…
Downloads the new version, replaces this one and opens it again 下载新版本,替换当前版本并重新打开
Couldn't check for updates: {f} 无法检查更新:{f}
Website (coming soon) 网站(即将上线)
GitHub repository GitHub 仓库
ArtCraft ArtCraft
Update 1 app 更新 1 个应用
@@ -176,6 +174,7 @@ column Installed 已安装版本
column Latest 最新版本
column Released 发布日期
filter Installed 已安装
{name} details {name} 详情
@plural {n} min ago|{n} min ago {n} 分钟前
@plural {n} h ago|{n} h ago {n} 小时前
@plural {n} day ago|{n} days ago {n} 天前
@@ -204,7 +203,6 @@ filter Installed 已安装
@id filter.notinstalled 未安装
@id sort.suite 默认顺序
@id sort.name 名称
@id sort.inspired 参考软件
@id sort.status 状态
@id sort.installed 已安装版本
@id sort.latest 最新版本
@@ -249,6 +247,7 @@ deckcraft Presentations and slide shows 演示文稿与幻灯片
gridcraft Spreadsheets and data 电子表格与数据处理
cadcraft Computer-aided design and drafting 计算机辅助设计与制图
soundcraft Recording, editing and mixing audio 音频录制、编辑与混音
figcraft Interface design and prototyping 界面设计与原型制作
Show in Files 在文件管理器中显示
Loading ArtCraft Launcher… 正在加载 ArtCraft Launcher…
@@ -265,6 +264,10 @@ soundcraft Recording, editing and mixing audio 音频录制、编辑与混音
Apps on this computer 此电脑上的应用
Installed apps with a newer version 有新版本的已安装应用
Apps you can install 可安装的应用
Hide App 隐藏应用
Unhide App 取消隐藏应用
Hidden Apps 已隐藏的应用
Apps you hid 你隐藏的应用
Cancel update 取消更新
Stop waiting; the update can be started again later 停止等待;稍后可重新开始更新
Download again 重新下载
@@ -286,6 +289,19 @@ soundcraft Recording, editing and mixing audio 音频录制、编辑与混音
RPM package (.rpm) RPM 软件包 (.rpm)
Flatpak bundle Flatpak 包
Tarball (.tar.gz) 压缩包 (.tar.gz)
App details 应用详情
Close (Esc) 关闭(Esc)
Features 功能特性
Latest release 最新发布
Version 版本
Platforms 平台
Releases 版本发布
Enlarge picture 放大图片
Click to enlarge 点击放大
Previous picture (←) 上一张(←)
Next picture (→) 下一张(→)
Loading pictures… 正在加载图片…
Pictures couldn't be loaded. Reopen this page to try again. 无法加载图片。重新打开此页面可重试。
# Message templates (@msg): toasts from the backend. {placeholders} match any text.
@msg Couldn't open {name}: {e} 无法打开 {name}:{e}
Can't render this file because it contains an unexpected character in line 4 and column 3.
+1
View File
@@ -17,6 +17,7 @@ pub fn app(id: &str) -> Option<ImageSource<'static>> {
"gridcraft" => egui::include_image!("../../../assets/app-icons/gridcraft.png"),
"cadcraft" => egui::include_image!("../../../assets/app-icons/cadcraft.png"),
"soundcraft" => egui::include_image!("../../../assets/app-icons/soundcraft.png"),
"figcraft" => egui::include_image!("../../../assets/app-icons/figcraft.png"),
_ => return None,
})
}
+2
View File
@@ -19,10 +19,12 @@ macro_rules! tl {
mod app;
#[cfg(not(target_arch = "wasm32"))]
mod cjk_font;
mod details;
mod dialogs;
pub mod i18n;
pub mod icons;
pub mod model;
mod motion;
mod notes;
pub mod settings;
pub mod theme;
+1 -1
View File
@@ -57,7 +57,7 @@ impl AppEntry {
}
pub fn installed_label(&self) -> Option<String> {
self.installed.as_ref().map(|i| i.version.as_ref().map_or_else(|| t("installed").to_string(), ToString::to_string))
self.installed.as_ref().map(|i| i.version.as_ref().map(ToString::to_string).unwrap_or_else(|| t("Installed").to_string()))
}
pub fn latest_label(&self) -> Option<String> {
+155
View File
@@ -0,0 +1,155 @@
//! Fewer animations when the OS asks for that: Windows' "Animation effects" switched off, macOS'
//! "Reduce motion", a Linux desktop's animations setting, a browser's `prefers-reduced-motion`.
//! The shell says what the OS wants ([`crate::Host::reduce_motion`]); this makes egui follow it.
//! Progress bars and spinners keep moving: they say that work is going on.
use egui::{Event, MouseWheelUnit, RawInput, TouchPhase};
/// Seconds between asking the shell again: the setting can change while the launcher runs.
pub(crate) const RECHECK_EVERY: f64 = 1.0;
/// With `reduce`, popups, tooltips and dialogs appear at once instead of fading in, and scrolling
/// to a place jumps there. Without it, egui's own timings.
pub(crate) fn apply(ctx: &egui::Context, reduce: bool) {
let stock = egui::Style::default();
ctx.all_styles_mut(|style| {
style.animation_time = if reduce { 0.0 } else { stock.animation_time };
style.scroll_animation = if reduce { egui::style::ScrollAnimation::none() } else { stock.scroll_animation };
});
}
/// The most one step of a rewritten wheel event moves, in points: under what egui takes for a
/// trackpad's step (8), also once Shift has folded both axes into one.
const WHEEL_STEP: f32 = 4.0;
/// A wheel event that would take more steps than this is left as it came.
const WHEEL_MAX_STEPS: f32 = 4096.0;
/// Makes a mouse wheel scroll at once. egui spreads each notch of a wheel over several frames and
/// has no switch for that, but it applies a trackpad's small steps as they come: so every wheel
/// event is rewritten as a run of such steps, adding up to the same distance.
pub(crate) fn unsmooth_wheel(ctx: &egui::Context, raw: &mut RawInput) {
let wheel = |event: &Event| matches!(event, Event::MouseWheel { phase: TouchPhase::Move, .. });
if !raw.events.iter().any(wheel) {
return;
}
let line = ctx.options(|o| o.input_options.line_scroll_speed);
let page = raw.screen_rect.unwrap_or_else(|| ctx.viewport_rect()).height();
let mut events = Vec::with_capacity(raw.events.len());
for event in std::mem::take(&mut raw.events) {
let Event::MouseWheel { unit, delta, phase: TouchPhase::Move, modifiers } = &event else {
events.push(event);
continue;
};
let points = match unit {
MouseWheelUnit::Point => *delta,
MouseWheelUnit::Line => line * *delta,
MouseWheelUnit::Page => page * *delta,
};
let steps = ((points.x.abs() + points.y.abs()) / WHEEL_STEP).ceil();
// Nothing to move, or nonsense (NaN included): egui's to deal with.
if !(1.0..=WHEEL_MAX_STEPS).contains(&steps) {
events.push(event);
continue;
}
let step = Event::MouseWheel { unit: MouseWheelUnit::Point, delta: points / steps, phase: TouchPhase::Move, modifiers: *modifiers };
events.extend(std::iter::repeat_n(step, steps as usize));
}
raw.events = events;
}
#[cfg(test)]
mod tests {
use egui::{Modifiers, Vec2, vec2};
use super::*;
const FRAME: f64 = 1.0 / 60.0;
fn wheel(unit: MouseWheelUnit, delta: Vec2, modifiers: Modifiers) -> Event {
Event::MouseWheel { unit, delta, phase: TouchPhase::Move, modifiers }
}
/// One frame: how far egui scrolls in it.
fn scrolled(ctx: &egui::Context, frame: u32, events: Vec<Event>, reduce: bool) -> Vec2 {
let mut raw = RawInput { time: Some(f64::from(frame) * FRAME), events, ..Default::default() };
if reduce {
unsmooth_wheel(ctx, &mut raw);
}
let mut seen = Vec2::ZERO;
ctx.run_ui(raw, |ui| seen = ui.input(|i| i.smooth_scroll_delta)).drop_without_applying_deltas();
seen
}
fn close(a: Vec2, b: Vec2) -> bool {
(a - b).length() < 0.01
}
#[test]
fn egui_spreads_a_wheel_notch_over_frames() {
let ctx = egui::Context::default();
let line = ctx.options(|o| o.input_options.line_scroll_speed);
let first = scrolled(&ctx, 0, vec![wheel(MouseWheelUnit::Line, vec2(0.0, -3.0), Modifiers::NONE)], false);
assert!(first.y < 0.0 && first.y > -1.5 * line, "{first:?}");
assert!(scrolled(&ctx, 1, Vec::new(), false).y < 0.0);
}
#[test]
fn a_wheel_notch_scrolls_at_once() {
let ctx = egui::Context::default();
let line = ctx.options(|o| o.input_options.line_scroll_speed);
let first = scrolled(&ctx, 0, vec![wheel(MouseWheelUnit::Line, vec2(0.0, -3.0), Modifiers::NONE)], true);
assert!(close(first, vec2(0.0, -3.0 * line)), "{first:?}");
assert_eq!(scrolled(&ctx, 1, Vec::new(), true), Vec2::ZERO);
}
#[test]
fn a_big_step_in_points_scrolls_at_once_too() {
let ctx = egui::Context::default();
let first = scrolled(&ctx, 0, vec![wheel(MouseWheelUnit::Point, vec2(30.0, -90.0), Modifiers::NONE)], true);
assert!(close(first, vec2(30.0, -90.0)), "{first:?}");
assert_eq!(scrolled(&ctx, 1, Vec::new(), true), Vec2::ZERO);
}
#[test]
fn shift_scrolls_sideways_at_once() {
let ctx = egui::Context::default();
let line = ctx.options(|o| o.input_options.line_scroll_speed);
// Both axes at once: Shift adds them up, which must still pass for a trackpad's step.
let first = scrolled(&ctx, 0, vec![wheel(MouseWheelUnit::Line, vec2(1.0, 2.0), Modifiers::SHIFT)], true);
assert!(close(first, vec2(3.0 * line, 0.0)), "{first:?}");
assert_eq!(scrolled(&ctx, 1, Vec::new(), true), Vec2::ZERO);
}
#[test]
fn other_input_is_left_alone() {
let ctx = egui::Context::default();
let events = vec![
Event::PointerMoved(egui::pos2(10.0, 10.0)),
wheel(MouseWheelUnit::Line, Vec2::ZERO, Modifiers::NONE),
wheel(MouseWheelUnit::Line, vec2(0.0, f32::NAN), Modifiers::NONE),
wheel(MouseWheelUnit::Page, vec2(f32::INFINITY, 0.0), Modifiers::NONE),
wheel(MouseWheelUnit::Point, vec2(0.0, 1.0e9), Modifiers::NONE),
Event::MouseWheel { unit: MouseWheelUnit::Point, delta: vec2(0.0, 40.0), phase: TouchPhase::Start, modifiers: Modifiers::NONE },
];
let mut raw = RawInput { events: events.clone(), ..Default::default() };
unsmooth_wheel(&ctx, &mut raw);
// NaN is not equal to itself: compare what the events look like.
assert_eq!(format!("{:?}", raw.events), format!("{events:?}"));
}
#[test]
fn the_styles_follow_the_setting() {
let ctx = egui::Context::default();
let stock = egui::Style::default();
apply(&ctx, true);
for theme in [egui::Theme::Dark, egui::Theme::Light] {
assert_eq!(ctx.style_of(theme).animation_time, 0.0);
assert_eq!(ctx.style_of(theme).scroll_animation, egui::style::ScrollAnimation::none());
}
apply(&ctx, false);
for theme in [egui::Theme::Dark, egui::Theme::Light] {
assert_eq!(ctx.style_of(theme).animation_time, stock.animation_time);
assert_eq!(ctx.style_of(theme).scroll_animation, stock.scroll_animation);
}
}
}
+13 -9
View File
@@ -22,16 +22,17 @@ pub enum Filter {
Installed,
Updates,
NotInstalled,
/// Apps the user chose to hide from the other lists.
Hidden,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum SortKey {
/// Suite order: classic apps, then new.
/// Suite order: classic apps, then new. Settings saved with the removed "Inspired by" sort load as this.
#[default]
#[serde(alias = "InspiredBy")]
Suite,
Name,
/// The product an app is inspired by.
InspiredBy,
Status,
/// Installed version.
Installed,
@@ -89,7 +90,7 @@ pub struct Settings {
pub sort_descending: bool,
pub theme: ThemeChoice,
/// Interface language: `auto` (follow the system) or a language code from the registry
/// (`en`, `ru`, `zh-CN`, `de`).
/// (`en`, `ru`, `zh-CN`, `de`, `fr`, `pt-BR`, `pl`).
pub language: String,
/// Closing the window keeps the launcher in the tray (menu bar) instead of quitting.
pub close_to_tray: bool,
@@ -111,6 +112,8 @@ pub struct Settings {
pub notified: BTreeMap<String, String>,
/// Unix seconds of the last finished check.
pub last_check: Option<i64>,
/// Catalog ids of apps hidden from the main lists (`photocraft`, …).
pub hidden: std::collections::BTreeSet<String>,
}
impl Settings {
@@ -118,13 +121,13 @@ impl Settings {
pub const LANGUAGE_AUTO: &'static str = "auto";
/// Display name for a language `code`: a language's name in itself, from the registry
/// ([`LANGUAGES`](crate::i18n::LANGUAGES)). A language needing the system's Chinese font is
/// named in English, with its own name added only once that font is loaded (labels never load
/// it: choosing the language does); "Auto" for `auto` and unknown codes, like
/// ([`LANGUAGES`](crate::i18n::LANGUAGES)). A language needing a CJK font is named in English,
/// with its own name added only while `installed` — the CJK face in the current font stack — is
/// that language (no other face draws it); "Auto" for `auto` and unknown codes, like
/// [`Lang::from_pref`](crate::i18n::Lang::from_pref).
pub fn language_label(code: &str) -> String {
pub fn language_label(code: &str, installed: Option<crate::i18n::Lang>) -> String {
match crate::i18n::Lang::from_code(code) {
Some(lang) if lang.needs_cjk_font() && crate::theme::cjk_font_loaded() => format!("{} ({})", lang.english_name(), lang.name()),
Some(lang) if lang.needs_cjk_font() && installed == Some(lang) => format!("{} ({})", lang.english_name(), lang.name()),
Some(lang) if lang.needs_cjk_font() => lang.english_name().to_string(),
Some(lang) => lang.name().to_string(),
None => t("Auto").to_string(),
@@ -182,6 +185,7 @@ impl Default for Settings {
dismissed_notices: std::collections::BTreeSet::new(),
notified: BTreeMap::new(),
last_check: None,
hidden: std::collections::BTreeSet::new(),
}
}
}
+57 -16
View File
@@ -8,6 +8,8 @@ use egui::epaint::text::VariationCoords;
use egui::{Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, FontTweak, Stroke, TextStyle, Visuals};
use serde::{Deserialize, Serialize};
use crate::i18n::Lang;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum ThemeChoice {
/// Follow the system's light or dark setting.
@@ -143,6 +145,27 @@ pub fn shadow(t: &Tokens) -> egui::epaint::Shadow {
egui::epaint::Shadow { offset: [0, 8], blur: 24, spread: 0, color: Color32::from_black_alpha(if t.dark { 140 } else { 36 }) }
}
/// The dim over the page behind a drawer; `amount` runs from 0 (none) to 1.
pub fn scrim(t: &Tokens, amount: f32) -> Color32 {
let alpha = if t.dark { 170.0 } else { 105.0 } * amount.clamp(0.0, 1.0);
Color32::from_black_alpha(alpha.round() as u8)
}
/// Behind an enlarged picture: near-black in both appearances, as pictures are judged on black.
pub fn backdrop() -> Color32 {
Color32::from_black_alpha(232)
}
/// A frame standing in for a picture, over [`backdrop`].
pub fn backdrop_panel() -> Color32 {
Color32::from_white_alpha(14)
}
/// Ink on [`backdrop`], at `alpha` out of 255.
pub fn backdrop_ink(alpha: u8) -> Color32 {
Color32::from_white_alpha(alpha)
}
fn visuals(t: &Tokens) -> Visuals {
let mut v = if t.dark { Visuals::dark() } else { Visuals::light() };
v.panel_fill = t.bg;
@@ -245,7 +268,9 @@ pub fn mono_bold() -> FontFamily {
type Face<'a> = (&'a str, &'static [u8], &'a [([u8; 4], f32)], FontFamily);
/// Registers the site's three variable fonts, each at the weights (and width) it uses there.
pub fn install_fonts(ctx: &egui::Context, cjk: bool) {
/// `cjk`: the CJK language to draw, whose system font is added behind everything else, or `None`
/// for a language that needs no CJK font.
pub fn install_fonts(ctx: &egui::Context, cjk: Option<Lang>) {
const INTER: &[u8] = include_bytes!("../../../assets/fonts/Inter-Variable.ttf");
const ARCHIVO: &[u8] = include_bytes!("../../../assets/fonts/Archivo-Variable.ttf");
const GEIST_MONO: &[u8] = include_bytes!("../../../assets/fonts/GeistMono-Variable.ttf");
@@ -262,14 +287,19 @@ pub fn install_fonts(ctx: &egui::Context, cjk: bool) {
("GeistMono-Bold", GEIST_MONO, &[(*b"wght", 700.0)], mono_bold()),
];
for (name, bytes, coords, family) in faces {
let tweak = FontTweak { coords: VariationCoords::new(coords.iter().copied()), ..Default::default() };
// No subpixel binning: these fonts carry no hinting instructions, so a glyph drawn at a
// fractional x smears each stem across two pixels, leaving small text blurry at 100 %.
let tweak = FontTweak { coords: VariationCoords::new(coords.iter().copied()), subpixel_binning: Some(false), ..Default::default() };
fonts.font_data.insert(name.to_owned(), Arc::new(FontData::from_static(bytes).tweak(tweak)));
let mut stack = vec![name.to_owned()];
stack.extend(fallback.iter().cloned());
fonts.families.insert(family, stack);
}
// Chinese, behind everything else, from the system: only when asked for, as the font is large.
if cjk && let Some(font) = cjk_font() {
// The CJK face, behind everything else, from the system: only when asked for, as the font is
// large.
if let Some(lang) = cjk
&& let Some(font) = cjk_font(lang)
{
fonts.font_data.insert("CJK".to_owned(), font);
for stack in fonts.families.values_mut() {
stack.push("CJK".to_owned());
@@ -278,36 +308,47 @@ pub fn install_fonts(ctx: &egui::Context, cjk: bool) {
ctx.set_fonts(fonts);
}
/// The system's Simplified Chinese font, read once on the first call (tens of MB). Desktop only:
/// the web build has none, so Chinese can't be drawn there.
pub fn cjk_font() -> Option<Arc<FontData>> {
/// The system's font for a CJK language, read once per language (tens of MB). Desktop only: the
/// web build has none, so CJK text can't be drawn there.
pub fn cjk_font(lang: Lang) -> Option<Arc<FontData>> {
#[cfg(not(target_arch = "wasm32"))]
{
CJK_FONT.get_or_init(|| crate::cjk_font::load().map(Arc::new)).clone()
CJK_FONTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner).entry(lang.code()).or_insert_with(|| load_cjk(lang)).clone()
}
#[cfg(target_arch = "wasm32")]
{
let _ = lang;
None
}
}
/// The system's Chinese font once [`cjk_font`] has looked for it.
/// Loads the system font for the CJK language `lang`: the face for its script, or `None` where the
/// system has none. Nothing falls back to another script's face — a face for a different script
/// leaves its characters as tofu — so a language without its font stays in English, like any other.
#[cfg(not(target_arch = "wasm32"))]
static CJK_FONT: std::sync::OnceLock<Option<Arc<FontData>>> = std::sync::OnceLock::new();
fn load_cjk(lang: Lang) -> Option<Arc<FontData>> {
crate::cjk_font::load(lang.script()?).map(Arc::new)
}
/// Has the system's Chinese font already been loaded? Never loads it.
pub fn cjk_font_loaded() -> bool {
/// The system fonts looked for so far, by language code.
#[cfg(not(target_arch = "wasm32"))]
static CJK_FONTS: std::sync::Mutex<std::collections::BTreeMap<&'static str, Option<Arc<FontData>>>> =
std::sync::Mutex::new(std::collections::BTreeMap::new());
/// Has the system font for `lang` already been loaded? Never loads it.
pub fn cjk_font_loaded(lang: Lang) -> bool {
#[cfg(not(target_arch = "wasm32"))]
{
CJK_FONT.get().is_some_and(Option::is_some)
CJK_FONTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(lang.code()).is_some_and(Option::is_some)
}
#[cfg(target_arch = "wasm32")]
{
let _ = lang;
false
}
}
/// Can Chinese be drawn? Loads the system font on the first call ([`cjk_font`]).
pub fn cjk_font_available() -> bool {
cjk_font().is_some()
/// Can the CJK language `lang` be drawn? Loads the system font on the first call ([`cjk_font`]).
pub fn cjk_font_available(lang: Lang) -> bool {
cjk_font(lang).is_some()
}
+117 -116
View File
@@ -1,6 +1,7 @@
//! The header, toolbar, footer, the grid of cards and the table, laid out like the apps page on
//! getartcraft.com: hairline cells, mono labels and square controls.
use artcraft_launcher_core::details::AppDetails;
use artcraft_launcher_core::platform::pick_asset;
use artcraft_launcher_core::protocol::{Command, JobPhase};
use artcraft_launcher_core::status::AppStatus;
@@ -63,9 +64,6 @@ const CARD_PAD: f32 = 20.0;
/// Room macOS's traffic lights take at the left of the header.
const TRAFFIC_LIGHTS_W: f32 = 72.0;
/// A run of cards, under an optional (number, title) eyebrow.
type Section = (Option<(&'static str, &'static str)>, Vec<&'static str>);
impl LauncherUi {
/// The header bar; with a custom or macOS title bar it is also the window's title bar.
pub(crate) fn header(&mut self, ui: &mut Ui, bar: TitleBar) {
@@ -83,11 +81,6 @@ impl LauncherUi {
let logo = vec2(14.0 * crate::icons::ARTCRAFT_LOGO_ASPECT, 14.0);
let (cell, _) = ui.allocate_exact_size(vec2(logo.x + 40.0, h), Sense::hover());
egui::Image::new(crate::icons::artcraft_logo(t.dark)).alt_text(tl!("ArtCraft")).paint_at(ui, Rect::from_center_size(cell.center(), logo));
widgets::rule(ui, h);
ui.add_space(16.0);
widgets::tag(ui, tl!("Launcher"), t.invert_bg, t.invert_fg);
ui.add_space(12.0);
widgets::hud(ui, tl!("Craft App Suite"), t.muted);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if bar == TitleBar::Custom {
widgets::window_buttons(ui, h);
@@ -115,11 +108,28 @@ impl LauncherUi {
}
ui.add_space(6.0);
widgets::rule(ui, h);
// The controls reserve their actual widths before the secondary text gets space.
ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
let tag_width =
widgets::caps(ui, tl!("Launcher"), theme::HUD_SIZE, theme::mono_bold(), theme::HUD_SPACING, t.ink).size().x + 16.0;
if ui.available_width() >= 17.0 + tag_width {
widgets::rule(ui, h);
ui.add_space(16.0);
widgets::tag(ui, tl!("Launcher"), t.invert_bg, t.invert_fg);
}
if ui.available_width() >= 12.0 + widgets::hud_galley(ui, tl!("Craft App Suite"), t.muted).size().x {
ui.add_space(12.0);
widgets::hud(ui, tl!("Craft App Suite"), t.muted);
}
});
});
});
}
pub(crate) fn toolbar(&mut self, ui: &mut Ui) {
if self.settings.filter == Filter::Hidden && self.settings.hidden.is_empty() {
self.settings.filter = Filter::All;
}
let t = Tokens::get(ui.ctx());
if self.dialog.is_none()
&& let Some(r) = self.launcher_update.clone()
@@ -189,17 +199,19 @@ impl LauncherUi {
self.announcements(ui);
ui.horizontal(|ui| {
let updates = self.count(Filter::Updates);
let filters: Vec<(Filter, Cell<'_>)> = [
let mut filters = vec![
(Filter::All, tl!("All"), tl!("Every Craft app"), None),
// The tab names a group ("installed ones"); the status pill says "Installed".
(Filter::Installed, tr_ctx(current(), "filter", "Installed"), tl!("Apps on this computer"), None),
(Filter::Updates, tl!("Updates"), tl!("Installed apps with a newer version"), Some(t.warn)),
(Filter::NotInstalled, tl!("Not installed"), tl!("Apps you can install"), None),
]
.into_iter()
.filter(|(f, ..)| self.caps.manage_apps || *f == Filter::All)
.map(|(f, label, tip, highlight)| (f, Cell { label, tip, count: Some((self.count(f), highlight)) }))
.collect();
];
filters.retain(|(f, ..)| self.caps.manage_apps || *f == Filter::All);
if !self.settings.hidden.is_empty() {
filters.push((Filter::Hidden, tl!("Hidden Apps"), tl!("Apps you hid"), None));
}
let filters: Vec<(Filter, Cell<'_>)> =
filters.into_iter().map(|(f, label, tip, highlight)| (f, Cell { label, tip, count: Some((self.count(f), highlight)) })).collect();
widgets::cells(ui, &mut self.settings.filter, &filters);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if self.caps.manage_apps && updates > 0 {
@@ -209,12 +221,8 @@ impl LauncherUi {
fmt(tl!("Update all ({count})"), &[("count", &updates.to_string())])
};
if widgets::button(ui, &label, Kind::Primary, 0.0).clicked() {
let ids: Vec<&str> = self
.entries
.iter()
.filter(|e| e.status(self.platform, true) == AppStatus::UpdateAvailable && e.job.is_none())
.map(|e| e.app.id)
.collect();
let ids: Vec<&str> =
self.entries.iter().filter(|e| self.in_filter(e, Filter::Updates) && e.job.is_none()).map(|e| e.app.id).collect();
for id in ids {
self.send(Command::Install { app: id.to_string() });
}
@@ -234,15 +242,7 @@ impl LauncherUi {
.selected_text(RichText::new(sort_label(self.settings.sort)).size(13.0))
.width(140.0)
.show_ui(ui, |ui| {
for key in [
SortKey::Suite,
SortKey::Name,
SortKey::InspiredBy,
SortKey::Status,
SortKey::Installed,
SortKey::Latest,
SortKey::Released,
] {
for key in [SortKey::Suite, SortKey::Name, SortKey::Status, SortKey::Installed, SortKey::Latest, SortKey::Released] {
if ui.selectable_label(self.settings.sort == key, sort_label(key)).clicked() {
self.settings.sort = key;
self.settings.sort_descending = key.starts_descending();
@@ -379,44 +379,15 @@ impl LauncherUi {
// Neighbouring cards share their 1 px hairline, like the site's `gap-px` grid.
let cols = ((width / CARD_MIN_W).floor() as usize).max(1);
let card_w = (width + (cols as f32 - 1.0)) / cols as f32;
// In suite order, split into the classic apps and the new ones.
let grouped = self.settings.sort == SortKey::Suite && self.settings.filter == Filter::All && self.search.trim().is_empty();
let sections: Vec<Section> = if grouped {
[(AppGroup::Classic, "01", tl!("Classic apps")), (AppGroup::New, "02", tl!("New apps"))]
.iter()
.map(|(g, index, title)| {
let list = ids.iter().copied().filter(|id| self.entry(id).is_some_and(|e| e.app.group == *g)).collect();
(Some((*index, *title)), list)
})
.collect()
} else {
vec![(None, ids.to_vec())]
};
let t = Tokens::get(ui.ctx());
for (title, list) in sections {
if list.is_empty() {
continue;
}
if let Some((index, title)) = title {
ui.add_space(4.0);
ui.horizontal(|ui| {
widgets::eyebrow(ui, index, title);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
widgets::hud(ui, &tn(list.len() as u64, "{n} app", "{n} apps"), t.faint);
});
});
ui.add_space(10.0);
}
let rows = list.len().div_ceil(cols);
let height = rows as f32 * (CARD_H - 1.0) + 1.0;
let (area, _) = ui.allocate_exact_size(vec2(width, height), Sense::hover());
for (i, id) in list.iter().enumerate() {
let (row, col) = (i / cols, i % cols);
let min = area.min + vec2(col as f32 * (card_w - 1.0), row as f32 * (CARD_H - 1.0));
self.card(ui, id, Rect::from_min_size(min, vec2(card_w, CARD_H)));
}
ui.add_space(24.0);
let rows = ids.len().div_ceil(cols);
let height = rows as f32 * (CARD_H - 1.0) + 1.0;
let (area, _) = ui.allocate_exact_size(vec2(width, height), Sense::hover());
for (i, id) in ids.iter().enumerate() {
let (row, col) = (i / cols, i % cols);
let min = area.min + vec2(col as f32 * (card_w - 1.0), row as f32 * (CARD_H - 1.0));
self.card(ui, id, Rect::from_min_size(min, vec2(card_w, CARD_H)));
}
ui.add_space(24.0);
}
fn card(&mut self, ui: &mut Ui, id: &str, rect: Rect) {
@@ -424,13 +395,27 @@ impl LauncherUi {
let t = Tokens::get(ui.ctx());
let app = entry.app;
let hovered = ui.rect_contains_pointer(rect);
let lift = ui.ctx().animate_bool_with_time(ui.id().with(("card-hover", id)), hovered, 0.3);
let wipe = if self.reduce_motion { 0.0 } else { 0.3 };
let lift = ui.ctx().animate_bool_with_time(ui.id().with(("card-hover", id)), hovered, wipe);
let app_color = rgb(app.color);
// The whole card opens the app's page. Added before the card's own links and buttons, so
// they sit on top of it and keep their clicks.
if AppDetails::by_id(id).is_some() {
let open = ui.interact(rect, ui.id().with(("card-open", id)), Sense::click());
open.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, fmt(tl!("{name} details"), &[("name", app.name)])));
if open.on_hover_cursor(egui::CursorIcon::PointingHand).clicked() {
self.detail = Some(id.to_string());
}
}
let painter = ui.painter_at(rect.expand(16.0));
painter.rect(rect, CornerRadius::ZERO, t.bg, t.hairline(), egui::StrokeKind::Inside);
// On hover, a 2 px rule in the app's colour wipes in from the left along the top.
// A faint wash in the app's colour fades in under the pointer.
let fill = t.bg.lerp_to_gamma(app_color, 0.05 * lift);
painter.rect(rect, CornerRadius::ZERO, fill, t.hairline(), egui::StrokeKind::Inside);
// On hover, a 2 px rule in the app's colour wipes in from the left along the top, inside the
// hairlines the card shares with its neighbours (the site's rule sits within its grid gaps).
if lift > 0.0 {
painter.rect_filled(Rect::from_min_size(rect.min, vec2(rect.width() * lift, 2.0)), CornerRadius::ZERO, app_color);
let inside = rect.shrink(1.0);
painter.rect_filled(Rect::from_min_size(inside.min, vec2(inside.width() * lift, 2.0)), CornerRadius::ZERO, app_color);
}
// The top strip: the app's number in its colour, and its pages.
@@ -445,9 +430,7 @@ impl LauncherUi {
strip_ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.spacing_mut().item_spacing.x = 16.0;
widgets::hud_link(ui, tl!("GitHub"), &app.repo_url());
if let Some(site) = app.website() {
widgets::hud_link(ui, tl!("Website"), &site);
}
widgets::hud_link(ui, tl!("Website"), &app.website());
});
}
@@ -459,10 +442,10 @@ impl LauncherUi {
let ui = &mut child;
ui.horizontal(|ui| {
const ICON: f32 = 56.0;
let [name, tagline, like] = [FontId::new(22.0, theme::display()), FontId::proportional(13.5), FontId::proportional(12.0)];
let [name, tagline] = [FontId::new(22.0, theme::display()), FontId::proportional(13.5)];
const GAP: f32 = 4.0;
// The slot is as tall as the text beside it when that is taller, so the two share a centre.
let block = lines_height(ui, &[&name, &tagline, &like], GAP);
let block = lines_height(ui, &[&name, &tagline], GAP);
let (slot, icon_response) = ui.allocate_exact_size(vec2(ICON, block.max(ICON)), Sense::hover());
icon_response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Image, true, fmt(tl!("{name} icon"), &[("name", app.name)])));
// The icon rises a little on hover, over a soft drop shadow (the site's only shadow).
@@ -475,7 +458,7 @@ impl LauncherUi {
egui::Image::new(icon).paint_at(ui, icon_rect);
}
ui.add_space(6.0);
centered_lines(ui, &[&name, &tagline, &like], GAP, |ui| {
centered_lines(ui, &[&name, &tagline], GAP, |ui| {
// The name's row is as tall as its text, not as a button.
ui.spacing_mut().interact_size.y = 0.0;
ui.horizontal(|ui| {
@@ -485,10 +468,6 @@ impl LauncherUi {
}
});
ui.add(egui::Label::new(RichText::new(tr_ctx(current(), app.id, app.tagline)).font(tagline.clone()).color(t.muted)).truncate());
ui.add(
egui::Label::new(RichText::new(fmt(tl!("Like {name}"), &[("name", &app.inspired_by())])).font(like.clone()).color(t.faint))
.truncate(),
);
});
});
ui.add_space(14.0);
@@ -533,7 +512,6 @@ impl LauncherUi {
.vscroll(false)
.cell_layout(egui::Layout::left_to_right(egui::Align::Center))
.column(Column::remainder().at_least(220.0).clip(true))
.column(Column::exact(172.0).clip(true))
.column(Column::exact(176.0))
.column(Column::exact(84.0))
.column(Column::exact(84.0))
@@ -542,7 +520,6 @@ impl LauncherUi {
.header(42.0, |mut header| {
for (key, label) in [
(Some(SortKey::Name), tl!("App")),
(Some(SortKey::InspiredBy), tl!("Inspired by")),
(Some(SortKey::Status), tl!("Status")),
(Some(SortKey::Installed), tr_ctx(current(), "column", "Installed")),
(Some(SortKey::Latest), tr_ctx(current(), "column", "Latest")),
@@ -597,6 +574,20 @@ impl LauncherUi {
if i < last {
rule(ui);
}
// Clicking the app's cell opens its page (the content is added after, so it wins any hit).
if AppDetails::by_id(id).is_some() {
let open = ui.interact(ui.max_rect(), ui.id().with(("row-open", id)), Sense::click());
open.widget_info(|| {
egui::WidgetInfo::labeled(
egui::WidgetType::Button,
true,
fmt(tl!("{name} details"), &[("name", entry.app.name)]),
)
});
if open.on_hover_cursor(egui::CursorIcon::PointingHand).clicked() {
self.detail = Some(id.to_string());
}
}
if let Some(icon) = crate::icons::app(entry.app.id) {
ui.add(egui::Image::new(icon).fit_to_exact_size(vec2(38.0, 38.0)));
}
@@ -616,9 +607,6 @@ impl LauncherUi {
);
});
});
row.col(|ui| {
ui.add(egui::Label::new(RichText::new(entry.app.inspired_by()).size(13.0).color(t.muted)).truncate());
});
row.col(|ui| {
let (text, tone) = model::entry_status_text(&entry, &status, self.caps.manage_apps);
let r = widgets::badge(ui, &text, widgets::tone_color(&t, tone));
@@ -659,13 +647,6 @@ impl LauncherUi {
}
}
/// The trademark notice under the grid and the table.
pub(crate) fn trademark_notice(&self, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
ui.add_space(8.0);
ui.add(egui::Label::new(RichText::new(artcraft_launcher_core::catalog::TRADEMARK_NOTICE).size(11.0).color(t.faint)).wrap());
}
// ---- actions -------------------------------------------------------------------------
fn primary_action(&self, entry: &AppEntry, status: &AppStatus) -> Primary {
@@ -697,7 +678,13 @@ impl LauncherUi {
}
/// The main button (or progress) and the … menu, within `width`.
fn actions(&mut self, ui: &mut Ui, entry: &AppEntry, status: &AppStatus, width: f32) {
pub(crate) fn actions(&mut self, ui: &mut Ui, entry: &AppEntry, status: &AppStatus, width: f32) {
self.actions_in(ui, entry, status, width, true);
}
/// The main button and the `⋯` menu beside it. `links_in_menu` is false where the page shows
/// the website, GitHub and releases links as buttons of its own.
pub(crate) fn actions_in(&mut self, ui: &mut Ui, entry: &AppEntry, status: &AppStatus, width: f32, links_in_menu: bool) {
let t = Tokens::get(ui.ctx());
let id = entry.app.id.to_string();
// One square button (the menu) beside the main one.
@@ -780,7 +767,7 @@ impl LauncherUi {
if widgets::glyph_button(ui, widgets::Glyph::Close, tl!("Discard download")).clicked() {
self.send(Command::Cancel { app: id.clone() });
}
self.menu(ui, entry, status);
self.menu(ui, entry, status, links_in_menu);
return;
}
match self.primary_action(entry, status) {
@@ -818,10 +805,10 @@ impl LauncherUi {
ui.add_enabled_ui(false, |ui| widgets::button(ui, label, Kind::Secondary, main_w));
}
}
self.menu(ui, entry, status);
self.menu(ui, entry, status, links_in_menu);
}
fn menu(&mut self, ui: &mut Ui, entry: &AppEntry, status: &AppStatus) {
fn menu(&mut self, ui: &mut Ui, entry: &AppEntry, status: &AppStatus, links: bool) {
let id = entry.app.id.to_string();
let manage = self.caps.manage_apps;
let reveal = self.caps.reveal_label;
@@ -877,21 +864,32 @@ impl LauncherUi {
}
}
}
// The drawer shows the links as buttons, so its menu leaves them out (but keeps Hide).
if links {
ui.separator();
let link = |ui: &mut Ui, label: &str, url: String| {
if ui.button(label).clicked() {
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
ui.close();
}
};
link(ui, tl!("Website"), entry.app.website());
link(ui, tl!("GitHub repository"), entry.app.repo_url());
link(ui, tl!("All releases"), entry.app.releases_url());
}
ui.separator();
let link = |ui: &mut Ui, label: &str, url: String| {
if ui.button(label).clicked() {
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
ui.close();
let hidden = self.settings.hidden.contains(entry.app.id);
if ui.button(if hidden { tl!("Unhide App") } else { tl!("Hide App") }).clicked() {
if hidden {
self.settings.hidden.remove(entry.app.id);
if self.settings.hidden.is_empty() && self.settings.filter == Filter::Hidden {
self.settings.filter = Filter::All;
}
} else {
self.settings.hidden.insert(id);
}
};
match entry.app.website() {
Some(site) => link(ui, tl!("Website"), site),
None => {
ui.add_enabled(false, egui::Button::new(tl!("Website (coming soon)")));
}
ui.close();
}
link(ui, tl!("GitHub repository"), entry.app.repo_url());
link(ui, tl!("All releases"), entry.app.releases_url());
});
}
}
@@ -900,7 +898,6 @@ fn sort_label(k: SortKey) -> &'static str {
match k {
SortKey::Suite => tl!("Suite order"),
SortKey::Name => tl!("Name"),
SortKey::InspiredBy => tl!("Inspired by"),
SortKey::Status => tl!("Status"),
SortKey::Installed => tl!("Installed version"),
SortKey::Latest => tl!("Latest version"),
@@ -909,16 +906,20 @@ fn sort_label(k: SortKey) -> &'static str {
}
/// "0.4.1 installed → 0.5.0 released · Oct 8, 2026" and friends.
fn version_line(e: &AppEntry, status: &AppStatus) -> String {
pub(crate) fn version_line(e: &AppEntry, status: &AppStatus) -> String {
let released = e.release().and_then(|r| r.published_at).map(|at| format!(" · {}", date_text(at))).unwrap_or_default();
match (status, e.installed_label(), e.latest_label()) {
let inst_ver = e.installed.as_ref().and_then(|i| i.version.as_ref().map(ToString::to_string));
let latest = e.latest_label();
match (status, inst_ver.as_deref(), latest.as_deref()) {
(AppStatus::UpdateAvailable, Some(i), Some(l)) => {
format!("{}{released}", fmt(tl!("{version} installed → {latest} released"), &[("version", i.as_str()), ("latest", l.as_str())]))
format!("{}{released}", fmt(tl!("{version} installed → {latest} released"), &[("version", i), ("latest", l)]))
}
(AppStatus::UpToDate, Some(i), _) => format!("{}{released}", fmt(tl!("Version {version}"), &[("version", i.as_str())])),
(_, Some(i), Some(l)) => fmt(tl!("{installed} installed · latest {latest}"), &[("installed", i.as_str()), ("latest", l.as_str())]),
(_, Some(i), None) => fmt(tl!("Version {version} installed"), &[("version", i.as_str())]),
(_, None, Some(l)) => format!("{}{released}", fmt(tl!("Version {latest}"), &[("latest", l.as_str())])),
(AppStatus::UpToDate, Some(i), _) => format!("{}{released}", fmt(tl!("Version {version}"), &[("version", i)])),
(_, Some(i), Some(l)) => fmt(tl!("{installed} installed · latest {latest}"), &[("installed", i), ("latest", l)]),
(_, None, Some(l)) if e.installed.is_some() => fmt(tl!("Installed · latest {latest}"), &[("latest", l)]),
(_, Some(i), None) => fmt(tl!("Version {version} installed"), &[("version", i)]),
(_, None, None) if e.installed.is_some() => tl!("Installed").to_string(),
(_, None, Some(l)) => format!("{}{released}", fmt(tl!("Version {latest}"), &[("latest", l)])),
(AppStatus::NoRelease, None, None) => tl!("No release published yet").to_string(),
_ => " ".to_string(),
}
+17 -9
View File
@@ -70,7 +70,7 @@ pub fn hud_elided(ui: &mut Ui, text: &str, color: Color32, max_width: f32) -> Re
if elided { resp.on_hover_text(text) } else { resp }
}
/// "01 / Classic apps": a section eyebrow, the index faint.
/// "01 / Features": a section eyebrow, the index faint.
pub fn eyebrow(ui: &mut Ui, index: &str, label: &str) {
let t = Tokens::get(ui.ctx());
ui.horizontal(|ui| {
@@ -311,6 +311,12 @@ pub enum Kind {
Danger,
}
/// The natural width of a button, including its horizontal padding.
pub fn button_width(ui: &Ui, text: &str, minimum: f32) -> f32 {
let galley = caps(ui, text, theme::HUD_SIZE, theme::mono_bold(), theme::BUTTON_SPACING, Tokens::get(ui.ctx()).ink);
(galley.size().x + 32.0).max(minimum)
}
/// A square button with a mono upper-case label, at least `width` wide.
pub fn button(ui: &mut Ui, text: &str, kind: Kind, width: f32) -> Response {
let t = Tokens::get(ui.ctx());
@@ -411,14 +417,16 @@ pub fn progress(ui: &mut Ui, fraction: Option<f32>, caption: &str, color: Color3
p.rect_filled(Rect::from_min_size(bar.min, vec2(bar.width() * f.clamp(0.0, 1.0), bar.height())), CornerRadius::ZERO, color);
}
None => {
// Indeterminate: a sweeping segment.
let x = (ui.input(|i| i.time) as f32 * 0.8).fract();
let seg = Rect::from_min_size(
egui::pos2(bar.left() + bar.width() * (x * 1.3 - 0.3).max(0.0), bar.top()),
vec2(bar.width() * 0.3, bar.height()),
)
.intersect(bar);
p.rect_filled(seg, CornerRadius::ZERO, color);
// Keep the segment moving through the track boundary without resetting visibly.
let width = bar.width();
let segment_width = width * 0.3;
let x = (ui.input(|i| i.time) * 0.8).fract() as f32 * width;
for left in [x, x - width] {
let seg = Rect::from_min_size(egui::pos2(bar.left() + left, bar.top()), vec2(segment_width, bar.height())).intersect(bar);
if seg.is_positive() {
p.rect_filled(seg, CornerRadius::ZERO, color);
}
}
ui.ctx().request_repaint();
}
}
+3 -3
View File
@@ -155,13 +155,13 @@ fn simplified_chinese_switches_persists_and_keeps_install_actions() {
h.get_by_value("English").click();
h.run_steps(2);
// Opening Settings doesn't load the (large) Chinese font: the picker names it in English.
assert!(!artcraft_launcher_ui_egui::theme::cjk_font_loaded());
assert!(!artcraft_launcher_ui_egui::theme::cjk_font_loaded(artcraft_launcher_ui_egui::i18n::Lang::ZH_CN));
h.get_by_label("Simplified Chinese").click();
h.run_steps(3);
if !artcraft_launcher_ui_egui::theme::cjk_font_available() {
if !artcraft_launcher_ui_egui::theme::cjk_font_available(artcraft_launcher_ui_egui::i18n::Lang::ZH_CN) {
// No Chinese font: choosing it keeps the UI in English, and Settings says why.
assert_eq!(h.state().as_ref().unwrap().settings.language, "zh-CN");
h.get_by_label_contains("No Chinese font was found");
h.get_by_label_contains("No font for this language was found");
h.get_by_label("Notify me when an installed app has an update");
snapshot(&mut h, "settings-zh-CN-no-font");
return;
+13
View File
@@ -184,4 +184,17 @@ fn german_switches_persists_and_keeps_install_actions() {
let (mut web, _) = harness_language(ViewMode::Grid, egui::Theme::Dark, false, &restored.language);
web.run_steps(6);
assert!(web.query_all_by_label("Für macOS herunterladen").count() >= 3);
for width in [720.0, 800.0, 1180.0] {
web.set_size(egui::vec2(width, 900.0));
web.run_steps(6);
for label in ["Grid", "Table", "Settings", "Check for updates"] {
let rect = web.get_by_label(artcraft_launcher_ui_egui::i18n::t(label)).rect();
assert!(rect.left() >= 0.0 && rect.right() <= width, "{width}: {label} {rect:?}");
for label in ["Launcher", "Craft App Suite"] {
if let Some(text) = web.query_by_label(label) {
assert!(!rect.intersects(text.rect()), "{width}: {label} overlaps {rect:?}");
}
}
}
}
}
+195
View File
@@ -0,0 +1,195 @@
//! The Hungarian UI, in its own test binary: the UI language is process-wide, so it can't share one
//! with the English UI tests. `ARTCRAFT_LAUNCHER_SNAPSHOTS=<dir>` also saves screenshots (run with
//! `-- --test-threads=1`, see `ui.rs`).
use std::cell::Cell;
use std::rc::Rc;
use artcraft_launcher_core::protocol::{Backend, Capabilities, Command, Event, JobPhase, LatestRelease, Origin, Source};
use artcraft_launcher_core::release::{Asset, Release};
use artcraft_launcher_core::status::InstallSource;
use artcraft_launcher_core::{Arch, Installed, Os, Platform, SUITE, version};
use artcraft_launcher_ui_egui::settings::ViewMode;
use artcraft_launcher_ui_egui::{Host, LauncherUi, Settings};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
const NOW: i64 = 1_791_500_000;
struct Fake {
events: Vec<Event>,
clock: Rc<Cell<i64>>,
os: Os,
}
impl Backend for Fake {
fn send(&mut self, _command: Command) {}
fn poll(&mut self) -> Vec<Event> {
std::mem::take(&mut self.events)
}
fn capabilities(&self) -> Capabilities {
Capabilities { manage_apps: true, reveal_label: "Show in Finder", self_update: true }
}
fn platform(&self) -> Option<Platform> {
Some(Platform { os: self.os, arch: Arch::Arm64 })
}
fn now(&self) -> i64 {
self.clock.get()
}
}
struct DesktopHost;
impl Host for DesktopHost {
fn has_tray(&self) -> bool {
true
}
fn login_item(&self) -> Option<bool> {
Some(false)
}
}
fn release(id: &str, v: &str, at: i64) -> Release {
let name = format!("{id}-{v}-macos-universal.dmg");
Release {
tag: format!("v{v}"),
version: version::parse(v),
name: format!("v{v}"),
published_at: Some(at),
html_url: format!("https://github.com/storytold/{id}/releases/tag/v{v}"),
notes: String::new(),
prerelease: false,
assets: vec![Asset {
name: name.clone(),
size: 150 << 20,
url: format!("https://github.com/storytold/{id}/releases/download/v{v}/{name}"),
sha256: None,
}],
}
}
fn events() -> Vec<Event> {
let mut ev = Vec::new();
for (i, app) in SUITE.iter().enumerate() {
let latest = LatestRelease::Found(release(app.id, "0.5.0", NOW - 40 * 86_400 - i as i64 * 600));
ev.push(Event::Latest { app: app.id.into(), latest, from: Origin::ArtCraftApi });
let installed = matches!(app.id, "photocraft" | "lightcraft").then(|| Installed {
version: version::parse(if app.id == "photocraft" { "0.4.1" } else { "0.5.0" }),
source: InstallSource::MacApp,
location: format!("/Applications/{}.app", app.name),
package: None,
});
ev.push(Event::Detected { app: app.id.into(), installed });
}
ev.push(Event::Progress { app: "filmcraft".into(), phase: JobPhase::Verifying });
ev.push(Event::CheckFinished { at: NOW - 180, source: Source::Feed, rate_limited_until: None });
// A backend message, translated through its `@msg` template where it's shown.
ev.push(Event::JobFinished { app: "lightcraft".into(), result: Ok("LightCraft updated to 0.5.0".into()) });
ev
}
fn harness(view: ViewMode) -> Harness<'static, Option<LauncherUi>> {
harness_on(view, Os::MacOs)
}
fn harness_on(view: ViewMode, os: Os) -> Harness<'static, Option<LauncherUi>> {
artcraft_launcher_ui_egui::i18n::set_test_lang(artcraft_launcher_ui_egui::i18n::Lang::EN);
let mut backend = Some(Fake { events: events(), clock: Rc::new(Cell::new(NOW)), os });
let settings =
Settings { view, notify: false, language: "hu".into(), theme: artcraft_launcher_ui_egui::theme::ThemeChoice::Dark, ..Settings::default() };
Harness::builder().with_size(egui::vec2(1180.0, 900.0)).with_theme(egui::Theme::Dark).build_ui_state(
move |ui, state: &mut Option<LauncherUi>| {
if state.is_none()
&& let Some(b) = backend.take()
{
*state = Some(LauncherUi::new(ui.ctx(), Box::new(b), settings.clone()));
}
if let Some(l) = state.as_mut() {
l.logic(&ui.ctx().clone(), &mut DesktopHost);
l.ui(ui, &mut DesktopHost);
}
},
None,
)
}
fn snapshot(h: &mut Harness<'_, Option<LauncherUi>>, name: &str) {
let Some(dir) = std::env::var_os("ARTCRAFT_LAUNCHER_SNAPSHOTS") else { return };
let img = h.render().unwrap();
img.save(std::path::Path::new(&dir).join(format!("{name}.png"))).unwrap();
}
// One test: the UI language is process-wide.
#[test]
fn hungarian_ui_translates_labels_dates_and_messages() {
let mut h = harness(ViewMode::Grid);
h.run_steps(8);
snapshot(&mut h, "grid-hu-dark");
// Product names stay in English.
h.get_by_label("Launcher");
h.get_by_label("Craft App Suite");
h.get_by_label("Képszerkesztés");
h.get_by_label("Frissítés: 0.5.0");
h.get_by_label_contains("Ellenőrzőösszeg ellenőrzése…");
h.get_by_label_contains("Ellenőrizve: 3 perce · ArtCraft API");
h.get_by_label("LightCraft frissítve. Verzió: 0.5.0");
let mut table = harness(ViewMode::Table);
table.run_steps(8);
snapshot(&mut table, "table-hu-dark");
table.get_by_label("Telepített");
assert!(table.query_all_by_label_contains("2026. aug.").count() >= 1);
check_settings_layout();
}
/// Checks the actual modal after resizing it while open, including Linux's extra controls.
fn check_settings_layout() {
let mut h = harness_on(ViewMode::Grid, Os::Linux);
h.run_steps(8);
h.get_by_label("Beállítások").click();
h.run_steps(8);
for (width, height) in [(1180.0, 900.0), (720.0, 500.0), (480.0, 500.0), (1180.0, 900.0)] {
h.set_size(egui::vec2(width, height));
h.run_steps(8);
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, height));
let done = h.get_by_label("Kész").rect();
let quit = h.get_by_label("Kilépés az ArtCraft Launcherből").rect();
assert!(screen.contains_rect(done), "{width}x{height}: Done is clipped: {done:?}");
assert!(screen.contains_rect(quit), "{width}x{height}: Quit is clipped: {quit:?}");
assert!(!done.intersects(quit));
let title = h.get_all_by_label("Beállítások").last().unwrap().rect();
assert!(screen.contains_rect(title), "{width}x{height}: title is clipped: {title:?}");
h.get_by_label("Rendszerbeállítás szerint").scroll_to_me();
h.run_steps(8);
// The footer has the same services, and the cards behind the modal have Webhely and GitHub
// links as one 14px line of text; keep only the settings body's buttons (34px) and icons (24px).
let discord = h.get_by_label("Csatlakozás a Discordhoz").rect();
let theme = h.get_by_label("Rendszerbeállítás szerint").rect();
assert!(screen.contains_rect(discord), "community links must be reachable by scrolling");
assert!(theme.bottom() < done.top(), "appearance controls must scroll above the fixed actions");
let mut links = vec![discord];
for label in ["Webhely", "GitHub", "X", "Instagram", "LinkedIn", "YouTube", "TikTok"] {
let candidates: Vec<_> = h
.query_all_by_label(label)
.map(|n| n.rect())
.filter(|r| r.top() >= discord.top() - 1.0 && r.bottom() < theme.top() && r.height() > 20.0)
.collect();
assert_eq!(candidates.len(), 1, "{width}x{height}: settings link {label}: {candidates:?}");
links.extend(candidates);
}
for (i, r) in links.iter().enumerate() {
assert!(screen.contains_rect(*r), "{width}x{height}: link is clipped: {r:?}");
for other in links.iter().skip(i + 1) {
assert!(!r.intersects(*other), "{width}x{height}: links overlap: {r:?}, {other:?}");
}
}
snapshot(&mut h, &format!("settings-hu-{width}x{height}-bottom"));
h.get_by_label("Nyelv").scroll_to_me();
h.run_steps(8);
let language = h.get_by_label("Nyelv").rect();
assert!(screen.contains_rect(language), "the top of settings must remain reachable");
snapshot(&mut h, &format!("settings-hu-{width}x{height}-top"));
}
h.get_by_label("Kész").click();
h.run_steps(4);
assert!(h.query_by_label("Kész").is_none(), "Done must still close the resized dialog");
}
+202
View File
@@ -0,0 +1,202 @@
//! The Japanese UI, in its own test binary: the UI language is process-wide, so it can't share one
//! with the English UI tests. Japanese is drawn with the system's Japanese font; where there is
//! none (CI, minimal Linux installs), the UI stays in English and says why.
//! `ARTCRAFT_LAUNCHER_SNAPSHOTS=<dir>` also saves screenshots (run with `-- --test-threads=1`).
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use artcraft_launcher_core::protocol::{Backend, Capabilities, Command, Event, JobPhase, LatestRelease, Origin, PausedDownload, Source};
use artcraft_launcher_core::release::{Asset, Release};
use artcraft_launcher_core::status::InstallSource;
use artcraft_launcher_core::{Arch, Installed, Os, Platform, SUITE, version};
use artcraft_launcher_ui_egui::settings::ViewMode;
use artcraft_launcher_ui_egui::{Host, LauncherUi, Settings};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
const NOW: i64 = 1_791_500_000;
struct Fake {
events: Vec<Event>,
sent: Rc<RefCell<Vec<Command>>>,
manage: bool,
clock: Rc<Cell<i64>>,
}
impl Backend for Fake {
fn send(&mut self, command: Command) {
self.sent.borrow_mut().push(command);
}
fn poll(&mut self) -> Vec<Event> {
std::mem::take(&mut self.events)
}
fn capabilities(&self) -> Capabilities {
Capabilities { manage_apps: self.manage, reveal_label: "Show in Finder", self_update: self.manage }
}
fn platform(&self) -> Option<Platform> {
Some(Platform { os: Os::MacOs, arch: Arch::Arm64 })
}
fn now(&self) -> i64 {
self.clock.get()
}
}
struct NoHost;
impl Host for NoHost {}
fn release(id: &str, v: &str, at: i64) -> Release {
let name = format!("{id}-{v}-macos-universal.dmg");
Release {
tag: format!("v{v}"),
version: version::parse(v),
name: format!("v{v}"),
published_at: Some(at),
html_url: format!("https://github.com/storytold/{id}/releases/tag/v{v}"),
notes: "## What's Changed\n* Faster things by @a in https://github.com/storytold/x/pull/1".into(),
prerelease: false,
assets: vec![Asset {
name: name.clone(),
size: 150 << 20,
url: format!("https://github.com/storytold/{id}/releases/download/v{v}/{name}"),
sha256: None,
}],
}
}
/// A realistic mix: updates, up-to-date apps, a download in progress, one app with no release.
fn events() -> Vec<Event> {
let mut ev = Vec::new();
let versions = [
("photocraft", "0.5.0"),
("vectorcraft", "0.7.0"),
("filmcraft", "0.4.0"),
("lightcraft", "0.4.0"),
("pdfcraft", "0.4.0"),
("effectcraft", "0.6.0"),
("designcraft", "0.4.0"),
("wordcraft", "0.3.0"),
("deckcraft", "0.3.0"),
("gridcraft", "0.3.0"),
("cadcraft", "0.3.0"),
];
for (i, (id, v)) in versions.iter().enumerate() {
ev.push(Event::Latest {
app: (*id).into(),
latest: LatestRelease::Found(release(id, v, NOW - 86_400 - i as i64 * 600)),
from: Origin::ArtCraftApi,
});
}
ev.push(Event::Latest { app: "soundcraft".into(), latest: LatestRelease::None, from: Origin::ArtCraftApi });
let installed = [("photocraft", "0.4.1"), ("lightcraft", "0.4.0"), ("wordcraft", "0.3.0"), ("effectcraft", "0.5.0")];
for app in &SUITE {
let inst = installed.iter().find(|(id, _)| *id == app.id).map(|(_, v)| Installed {
version: version::parse(v),
source: InstallSource::MacApp,
location: format!("/Applications/{}.app", app.name),
package: None,
});
ev.push(Event::Detected { app: app.id.into(), installed: inst });
}
ev.push(Event::Progress { app: "filmcraft".into(), phase: JobPhase::Downloading { done: 48 << 20, total: 131 << 20 } });
ev.push(Event::Paused { app: "deckcraft".into(), download: Some(PausedDownload { done: 30 << 20, total: 120 << 20 }) });
ev.push(Event::Progress { app: "gridcraft".into(), phase: JobPhase::Reconnecting { done: 10 << 20, total: 90 << 20 } });
ev.push(Event::CheckFinished { at: NOW - 180, source: Source::GitHubApi, rate_limited_until: None });
ev
}
fn harness_language(
view: ViewMode,
theme: egui::Theme,
manage: bool,
language: &str,
) -> (Harness<'static, Option<LauncherUi>>, Rc<RefCell<Vec<Command>>>) {
artcraft_launcher_ui_egui::i18n::set_test_lang(artcraft_launcher_ui_egui::i18n::Lang::EN);
let sent = Rc::new(RefCell::new(Vec::new()));
let sent2 = sent.clone();
let mut backend = Some(Fake { events: events(), sent: sent2, manage, clock: Rc::new(Cell::new(NOW)) });
let settings = Settings { view, notify: false, language: language.into(), ..Settings::default() };
let harness = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).with_theme(theme).build_ui_state(
move |ui, state: &mut Option<LauncherUi>| {
if state.is_none()
&& let Some(b) = backend.take()
{
let mut s = settings.clone();
s.theme = if theme == egui::Theme::Dark {
artcraft_launcher_ui_egui::theme::ThemeChoice::Dark
} else {
artcraft_launcher_ui_egui::theme::ThemeChoice::Light
};
*state = Some(LauncherUi::new(ui.ctx(), Box::new(b), s));
}
if let Some(l) = state.as_mut() {
l.logic(&ui.ctx().clone(), &mut NoHost);
l.ui(ui, &mut NoHost);
}
},
None,
);
(harness, sent)
}
fn snapshot(h: &mut Harness<'_, Option<LauncherUi>>, name: &str) {
let Some(dir) = std::env::var_os("ARTCRAFT_LAUNCHER_SNAPSHOTS") else { return };
let img = h.render().unwrap();
img.save(std::path::Path::new(&dir).join(format!("{name}.png"))).unwrap();
}
// One test: the UI language is process-wide.
#[test]
fn japanese_switches_persists_and_keeps_install_actions() {
use artcraft_launcher_ui_egui::i18n::Lang;
let (mut h, sent) = harness_language(ViewMode::Grid, egui::Theme::Dark, true, "en");
h.run_steps(6);
h.get_by_label("Settings").click();
h.run_steps(3);
h.get_by_value("English").click();
h.run_steps(2);
// Opening Settings doesn't load the (large) Japanese font: the picker names it in English.
assert!(!artcraft_launcher_ui_egui::theme::cjk_font_loaded(Lang::JA));
// The list is long enough to scroll, so the last option may be out of view: click it through
// accesskit, which reaches widgets that aren't visible.
h.get_by_label("Japanese").click_accesskit();
h.run_steps(3);
if !artcraft_launcher_ui_egui::theme::cjk_font_available(Lang::JA) {
// No font for Japanese: choosing it keeps the UI in English, and Settings says why.
assert_eq!(h.state().as_ref().unwrap().settings.language, "ja");
h.get_by_label_contains("No font for this language was found");
h.get_by_label("Notify me when an installed app has an update");
snapshot(&mut h, "settings-ja-no-font");
return;
}
// Choosing it loaded the font: now the picker shows both names.
h.get_by_value("Japanese (日本語)");
h.get_by_label("インストール済みアプリに更新があるときに通知する");
snapshot(&mut h, "settings-ja-dark");
let settings = h.state().as_ref().unwrap().settings.clone();
assert_eq!(settings.language, "ja");
let saved = serde_json::to_string(&settings).unwrap();
let restored: Settings = serde_json::from_str(&saved).unwrap();
assert_eq!(restored.language, "ja");
h.get_by_label("完了").click();
h.run_steps(3);
h.get_by_label("画像編集");
h.get_by_label_contains("最終確認: 3 分前 · 取得元: ArtCraft API");
h.get_by_label("0.5.0 に更新");
// Product names stay in English.
h.get_by_label("Launcher");
snapshot(&mut h, "grid-ja-dark");
h.get_by_label("すべて更新(2)").click();
h.run_steps(2);
let installs: Vec<_> = sent.borrow().iter().filter_map(|c| if let Command::Install { app } = c { Some(app.clone()) } else { None }).collect();
assert_eq!(installs, ["photocraft", "effectcraft"]);
let (mut table, _) = harness_language(ViewMode::Table, egui::Theme::Light, true, &restored.language);
table.run_steps(6);
table.get_by_label("インストール済みバージョン");
assert!(table.query_all_by_label_contains("年10月").count() >= 1);
snapshot(&mut table, "table-ja-light");
let (mut web, _) = harness_language(ViewMode::Grid, egui::Theme::Dark, false, &restored.language);
web.run_steps(6);
assert!(web.query_all_by_label("macOS 版をダウンロード").count() >= 3);
}
+129
View File
@@ -0,0 +1,129 @@
//! The Polish UI, in its own test binary: the UI language is process-wide, so it can't share one
//! with the English UI tests. `ARTCRAFT_LAUNCHER_SNAPSHOTS=<dir>` also saves screenshots (run with
//! `-- --test-threads=1`, see `ui.rs`).
use std::cell::Cell;
use std::rc::Rc;
use artcraft_launcher_core::protocol::{Backend, Capabilities, Command, Event, JobPhase, LatestRelease, Origin, Source};
use artcraft_launcher_core::release::{Asset, Release};
use artcraft_launcher_core::status::InstallSource;
use artcraft_launcher_core::{Arch, Installed, Os, Platform, SUITE, version};
use artcraft_launcher_ui_egui::settings::ViewMode;
use artcraft_launcher_ui_egui::{Host, LauncherUi, Settings};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
const NOW: i64 = 1_791_500_000;
struct Fake {
events: Vec<Event>,
clock: Rc<Cell<i64>>,
}
impl Backend for Fake {
fn send(&mut self, _command: Command) {}
fn poll(&mut self) -> Vec<Event> {
std::mem::take(&mut self.events)
}
fn capabilities(&self) -> Capabilities {
Capabilities { manage_apps: true, reveal_label: "Show in Finder", self_update: true }
}
fn platform(&self) -> Option<Platform> {
Some(Platform { os: Os::MacOs, arch: Arch::Arm64 })
}
fn now(&self) -> i64 {
self.clock.get()
}
}
struct NoHost;
impl Host for NoHost {}
fn release(id: &str, v: &str, at: i64) -> Release {
let name = format!("{id}-{v}-macos-universal.dmg");
Release {
tag: format!("v{v}"),
version: version::parse(v),
name: format!("v{v}"),
published_at: Some(at),
html_url: format!("https://github.com/storytold/{id}/releases/tag/v{v}"),
notes: String::new(),
prerelease: false,
assets: vec![Asset {
name: name.clone(),
size: 150 << 20,
url: format!("https://github.com/storytold/{id}/releases/download/v{v}/{name}"),
sha256: None,
}],
}
}
fn events() -> Vec<Event> {
let mut ev = Vec::new();
for (i, app) in SUITE.iter().enumerate() {
let latest = LatestRelease::Found(release(app.id, "0.5.0", NOW - 40 * 86_400 - i as i64 * 600));
ev.push(Event::Latest { app: app.id.into(), latest, from: Origin::ArtCraftApi });
let installed = matches!(app.id, "photocraft" | "lightcraft").then(|| Installed {
version: version::parse(if app.id == "photocraft" { "0.4.1" } else { "0.5.0" }),
source: InstallSource::MacApp,
location: format!("/Applications/{}.app", app.name),
package: None,
});
ev.push(Event::Detected { app: app.id.into(), installed });
}
ev.push(Event::Progress { app: "filmcraft".into(), phase: JobPhase::Verifying });
ev.push(Event::CheckFinished { at: NOW - 180, source: Source::Feed, rate_limited_until: None });
// A backend message, translated through its `@msg` template where it's shown.
ev.push(Event::JobFinished { app: "lightcraft".into(), result: Ok("LightCraft updated to 0.5.0".into()) });
ev
}
fn harness(view: ViewMode) -> Harness<'static, Option<LauncherUi>> {
artcraft_launcher_ui_egui::i18n::set_test_lang(artcraft_launcher_ui_egui::i18n::Lang::EN);
let mut backend = Some(Fake { events: events(), clock: Rc::new(Cell::new(NOW)) });
let settings =
Settings { view, notify: false, language: "pl".into(), theme: artcraft_launcher_ui_egui::theme::ThemeChoice::Dark, ..Settings::default() };
Harness::builder().with_size(egui::vec2(1180.0, 900.0)).with_theme(egui::Theme::Dark).build_ui_state(
move |ui, state: &mut Option<LauncherUi>| {
if state.is_none()
&& let Some(b) = backend.take()
{
*state = Some(LauncherUi::new(ui.ctx(), Box::new(b), settings.clone()));
}
if let Some(l) = state.as_mut() {
l.logic(&ui.ctx().clone(), &mut NoHost);
l.ui(ui, &mut NoHost);
}
},
None,
)
}
fn snapshot(h: &mut Harness<'_, Option<LauncherUi>>, name: &str) {
let Some(dir) = std::env::var_os("ARTCRAFT_LAUNCHER_SNAPSHOTS") else { return };
let img = h.render().unwrap();
img.save(std::path::Path::new(&dir).join(format!("{name}.png"))).unwrap();
}
// One test: the UI language is process-wide.
#[test]
fn polish_ui_translates_labels_dates_and_messages() {
let mut h = harness(ViewMode::Grid);
h.run_steps(8);
snapshot(&mut h, "grid-pl-dark");
// Product names stay in English.
h.get_by_label("Launcher");
h.get_by_label("Craft App Suite");
h.get_by_label("Edycja zdjęć");
h.get_by_label("Zaktualizuj do 0.5.0");
h.get_by_label_contains("Weryfikowanie sumy kontrolnej…");
h.get_by_label_contains("Ostatnio sprawdzono 3 min temu · przy użyciu ArtCraft API");
h.get_by_label("Zaktualizowano LightCraft do wersji 0.5.0");
let mut table = harness(ViewMode::Table);
table.run_steps(8);
snapshot(&mut table, "table-pl-dark");
table.get_by_label("Zainstalowana");
assert!(table.query_all_by_label_contains("sie 2026").count() >= 1);
}
+129
View File
@@ -0,0 +1,129 @@
//! The Spanish UI, in its own test binary: the UI language is process-wide, so it can't share one
//! with the English UI tests. `ARTCRAFT_LAUNCHER_SNAPSHOTS=<dir>` also saves screenshots (run with
//! `-- --test-threads=1`, see `ui.rs`).
use std::cell::Cell;
use std::rc::Rc;
use artcraft_launcher_core::protocol::{Backend, Capabilities, Command, Event, JobPhase, LatestRelease, Origin, Source};
use artcraft_launcher_core::release::{Asset, Release};
use artcraft_launcher_core::status::InstallSource;
use artcraft_launcher_core::{Arch, Installed, Os, Platform, SUITE, version};
use artcraft_launcher_ui_egui::settings::ViewMode;
use artcraft_launcher_ui_egui::{Host, LauncherUi, Settings};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
const NOW: i64 = 1_791_500_000;
struct Fake {
events: Vec<Event>,
clock: Rc<Cell<i64>>,
}
impl Backend for Fake {
fn send(&mut self, _command: Command) {}
fn poll(&mut self) -> Vec<Event> {
std::mem::take(&mut self.events)
}
fn capabilities(&self) -> Capabilities {
Capabilities { manage_apps: true, reveal_label: "Show in Finder", self_update: true }
}
fn platform(&self) -> Option<Platform> {
Some(Platform { os: Os::MacOs, arch: Arch::Arm64 })
}
fn now(&self) -> i64 {
self.clock.get()
}
}
struct NoHost;
impl Host for NoHost {}
fn release(id: &str, v: &str, at: i64) -> Release {
let name = format!("{id}-{v}-macos-universal.dmg");
Release {
tag: format!("v{v}"),
version: version::parse(v),
name: format!("v{v}"),
published_at: Some(at),
html_url: format!("https://github.com/storytold/{id}/releases/tag/v{v}"),
notes: String::new(),
prerelease: false,
assets: vec![Asset {
name: name.clone(),
size: 150 << 20,
url: format!("https://github.com/storytold/{id}/releases/download/v{v}/{name}"),
sha256: None,
}],
}
}
fn events() -> Vec<Event> {
let mut ev = Vec::new();
for (i, app) in SUITE.iter().enumerate() {
let latest = LatestRelease::Found(release(app.id, "0.5.0", NOW - 40 * 86_400 - i as i64 * 600));
ev.push(Event::Latest { app: app.id.into(), latest, from: Origin::ArtCraftApi });
let installed = matches!(app.id, "photocraft" | "lightcraft").then(|| Installed {
version: version::parse(if app.id == "photocraft" { "0.4.1" } else { "0.5.0" }),
source: InstallSource::MacApp,
location: format!("/Applications/{}.app", app.name),
package: None,
});
ev.push(Event::Detected { app: app.id.into(), installed });
}
ev.push(Event::Progress { app: "filmcraft".into(), phase: JobPhase::Verifying });
ev.push(Event::CheckFinished { at: NOW - 180, source: Source::Feed, rate_limited_until: None });
// A backend message, translated through its `@msg` template where it's shown.
ev.push(Event::JobFinished { app: "lightcraft".into(), result: Ok("LightCraft updated to 0.5.0".into()) });
ev
}
fn harness(view: ViewMode) -> Harness<'static, Option<LauncherUi>> {
artcraft_launcher_ui_egui::i18n::set_test_lang(artcraft_launcher_ui_egui::i18n::Lang::EN);
let mut backend = Some(Fake { events: events(), clock: Rc::new(Cell::new(NOW)) });
let settings =
Settings { view, notify: false, language: "es".into(), theme: artcraft_launcher_ui_egui::theme::ThemeChoice::Dark, ..Settings::default() };
Harness::builder().with_size(egui::vec2(1180.0, 900.0)).with_theme(egui::Theme::Dark).build_ui_state(
move |ui, state: &mut Option<LauncherUi>| {
if state.is_none()
&& let Some(b) = backend.take()
{
*state = Some(LauncherUi::new(ui.ctx(), Box::new(b), settings.clone()));
}
if let Some(l) = state.as_mut() {
l.logic(&ui.ctx().clone(), &mut NoHost);
l.ui(ui, &mut NoHost);
}
},
None,
)
}
fn snapshot(h: &mut Harness<'_, Option<LauncherUi>>, name: &str) {
let Some(dir) = std::env::var_os("ARTCRAFT_LAUNCHER_SNAPSHOTS") else { return };
let img = h.render().unwrap();
img.save(std::path::Path::new(&dir).join(format!("{name}.png"))).unwrap();
}
// One test: the UI language is process-wide.
#[test]
fn spanish_ui_translates_labels_dates_and_messages() {
let mut h = harness(ViewMode::Grid);
h.run_steps(8);
snapshot(&mut h, "grid-es-dark");
// Product names stay in English.
h.get_by_label("Launcher");
h.get_by_label("Craft App Suite");
h.get_by_label("Edición de imágenes");
h.get_by_label("Actualizar a 0.5.0");
h.get_by_label_contains("Verificando la suma de comprobación…");
h.get_by_label_contains("Comprobado hace 3 min · con ArtCraft API");
h.get_by_label("LightCraft actualizada a 0.5.0");
let mut table = harness(ViewMode::Table);
table.run_steps(8);
snapshot(&mut table, "table-es-dark");
table.get_by_label("Instalada");
assert!(table.query_all_by_label_contains("ago. 2026").count() >= 1);
}
+609 -30
View File
@@ -5,7 +5,9 @@
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::sync::OnceLock;
use artcraft_launcher_core::details::AppDetails;
use artcraft_launcher_core::protocol::{
Backend, CANCELLED, Capabilities, Command, Event, INSTALL_CANCELLED, JobPhase, LatestRelease, Origin, PausedDownload, Source,
};
@@ -19,19 +21,66 @@ use egui_kittest::kittest::{NodeT, Queryable};
const NOW: i64 = 1_791_500_000;
/// How the fake backend answers a request for an app's details pictures, whenever it is polled.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
enum Pictures {
/// Every picture arrives.
#[default]
Online,
/// Every picture fails, as with no connection.
Offline,
/// Nothing is answered yet: the requests wait until the mode changes.
Silent,
/// The second picture of each page fails (not published), the others arrive.
SecondFails,
}
/// A small JPEG, made here so that no picture is kept in the repository for the tests.
fn test_jpeg() -> Vec<u8> {
static JPEG: OnceLock<Vec<u8>> = OnceLock::new();
JPEG.get_or_init(|| {
let (w, h) = (320u32, 200u32);
let rgb: Vec<u8> = (0..h).flat_map(|y| (0..w).flat_map(move |x| [(x * 255 / w) as u8, (y * 255 / h) as u8, 160])).collect();
let mut out = Vec::new();
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 80).encode(&rgb, w, h, image::ExtendedColorType::Rgb8).unwrap();
out
})
.clone()
}
struct Fake {
events: Vec<Event>,
sent: Rc<RefCell<Vec<Command>>>,
manage: bool,
pictures: Rc<Cell<Pictures>>,
/// Apps whose pictures were asked for and not answered yet.
asked: Vec<String>,
clock: Rc<Cell<i64>>,
}
impl Backend for Fake {
fn send(&mut self, command: Command) {
if let Command::FetchScreenshots { app } = &command {
self.asked.push(app.clone());
}
self.sent.borrow_mut().push(command);
}
fn poll(&mut self) -> Vec<Event> {
std::mem::take(&mut self.events)
let mut out = std::mem::take(&mut self.events);
if self.pictures.get() != Pictures::Silent {
for app in std::mem::take(&mut self.asked) {
let Some(page) = AppDetails::by_id(&app) else { continue };
for (i, shot) in page.shots.iter().enumerate() {
let result = match self.pictures.get() {
Pictures::Offline => Err("couldn't reach raw.githubusercontent.com (no connection)".to_string()),
Pictures::SecondFails if i == 1 => Err("this picture isn't published".to_string()),
_ => Ok(test_jpeg()),
};
out.push(Event::Screenshot { app: app.clone(), key: shot.key.to_string(), result });
}
}
}
out
}
fn capabilities(&self) -> Capabilities {
Capabilities { manage_apps: self.manage, reveal_label: "Show in Finder", self_update: self.manage }
@@ -123,12 +172,53 @@ fn harness_with(
manage: bool,
scale: f32,
) -> (Harness<'static, Option<LauncherUi>>, Rc<RefCell<Vec<Command>>>) {
harness_full(events, view, theme, manage, scale, artcraft_launcher_ui_egui::TitleBar::Native, Rc::default())
}
/// Like `harness_with`, with the details pictures answered as `pictures` says (changeable during the test).
fn harness_pictures(
events: Vec<Event>,
view: ViewMode,
theme: egui::Theme,
manage: bool,
scale: f32,
pictures: Rc<Cell<Pictures>>,
) -> (Harness<'static, Option<LauncherUi>>, Rc<RefCell<Vec<Command>>>) {
harness_full(events, view, theme, manage, scale, artcraft_launcher_ui_egui::TitleBar::Native, pictures)
}
fn harness_with_title_bar(
events: Vec<Event>,
view: ViewMode,
theme: egui::Theme,
manage: bool,
scale: f32,
bar: artcraft_launcher_ui_egui::TitleBar,
) -> (Harness<'static, Option<LauncherUi>>, Rc<RefCell<Vec<Command>>>) {
harness_full(events, view, theme, manage, scale, bar, Rc::default())
}
fn harness_full(
events: Vec<Event>,
view: ViewMode,
theme: egui::Theme,
manage: bool,
scale: f32,
bar: artcraft_launcher_ui_egui::TitleBar,
pictures: Rc<Cell<Pictures>>,
) -> (Harness<'static, Option<LauncherUi>>, Rc<RefCell<Vec<Command>>>) {
struct HeaderHost(artcraft_launcher_ui_egui::TitleBar);
impl Host for HeaderHost {
fn title_bar(&self) -> artcraft_launcher_ui_egui::TitleBar {
self.0
}
}
// Integration tests compile without cfg!(test), so system_lang() picks up the
// developer's locale. Force English so labels match the assertions.
artcraft_launcher_ui_egui::i18n::set_test_lang(artcraft_launcher_ui_egui::i18n::Lang::EN);
let sent = Rc::new(RefCell::new(Vec::new()));
let sent2 = sent.clone();
let mut backend = Some(Fake { events, sent: sent2, manage, clock: Rc::new(Cell::new(NOW)) });
let mut backend = Some(Fake { events, sent: sent2, manage, pictures, asked: Vec::new(), clock: Rc::new(Cell::new(NOW)) });
let settings = Settings { view, notify: false, ..Settings::default() };
// No `.wgpu()`: it gives every test its own GPU device, and those segfault on parallel test threads (see above).
// The default renderer creates one only when a test renders.
@@ -146,8 +236,8 @@ fn harness_with(
*state = Some(LauncherUi::new(ui.ctx(), Box::new(b), s));
}
if let Some(l) = state.as_mut() {
l.logic(&ui.ctx().clone(), &mut NoHost);
l.ui(ui, &mut NoHost);
l.logic(&ui.ctx().clone(), &mut HeaderHost(bar));
l.ui(ui, &mut HeaderHost(bar));
}
},
None,
@@ -164,7 +254,8 @@ fn snapshot(h: &mut Harness<'_, Option<LauncherUi>>, name: &str) {
#[test]
fn keeps_working_with_no_window_then_draws_in_a_new_one() {
let sent = Rc::new(RefCell::new(Vec::new()));
let backend = Fake { events: events(), sent: sent.clone(), manage: true, clock: Rc::new(Cell::new(NOW)) };
let backend =
Fake { events: events(), sent: sent.clone(), manage: true, pictures: Rc::default(), asked: Vec::new(), clock: Rc::new(Cell::new(NOW)) };
let windowless = egui::Context::default();
let mut ui = LauncherUi::new(&windowless, Box::new(backend), Settings { notify: false, ..Settings::default() });
// No window: the backend's news is still taken in.
@@ -201,11 +292,9 @@ fn grid_shows_every_status_and_installs_on_click() {
h.get_by_label("Update to 0.5.0");
h.get_by_label("Update to 0.6.0");
h.get_by_label_contains("0.4.1 installed → 0.5.0 released");
h.get_by_label("Like Adobe Photoshop®");
// The window has no title bar of its own here (the web build, and the tests).
assert!(h.query_by_label("Close").is_none());
assert!(h.query_all_by_label_contains("Discord").count() >= 2); // header link and footer link
h.get_by_label_contains("Adobe, Photoshop, Illustrator");
assert!(h.query_all_by_label("Coming soon").count() >= 1);
h.get_by_label_contains("48 MB of 131 MB");
h.get_by_label("Update all (2)").click();
@@ -367,9 +456,6 @@ fn table_view_and_light_theme() {
let (mut h, sent) = harness(ViewMode::Table, egui::Theme::Light, true);
h.run_steps(8);
snapshot(&mut h, "table-light");
h.get_by_label("Adobe Photoshop®");
h.get_by_label("Microsoft Excel®");
h.get_by_label_contains("Avid and Pro Tools are trademarks");
h.get_by_label("Update to 0.5.0").click();
h.run_steps(2);
assert!(sent.borrow().contains(&Command::Install { app: "photocraft".into() }));
@@ -378,6 +464,35 @@ fn table_view_and_light_theme() {
assert!(sent.borrow().iter().any(|c| matches!(c, Command::Launch { .. })));
}
#[test]
fn hiding_an_app_removes_it_until_it_is_unhidden() {
let (mut h, _) = harness(ViewMode::Table, egui::Theme::Dark, true);
h.run_steps(6);
// PhotoCraft has an update, so hiding it also drops the Updates count.
h.get_by_label_contains("Updates 2");
let row = h.get_all_by_label("PhotoCraft").next().unwrap().rect().center().y;
h.get_all_by_label("…").find(|n| (n.rect().center().y - row).abs() < 25.0).unwrap().click();
h.run_steps(3);
h.get_by_label("Hide App").click();
h.run_steps(4);
assert!(h.query_by_label("PhotoCraft").is_none());
assert!(h.state().as_ref().unwrap().settings.hidden.contains("photocraft"));
h.get_by_label_contains("Updates 1");
h.get_by_label_contains("Hidden Apps").click();
h.run_steps(4);
h.get_by_label("PhotoCraft");
assert!(h.query_by_label("WordCraft").is_none());
let row = h.get_all_by_label("PhotoCraft").next().unwrap().rect().center().y;
h.get_all_by_label("…").find(|n| (n.rect().center().y - row).abs() < 25.0).unwrap().click();
h.run_steps(3);
h.get_by_label("Unhide App").click();
h.run_steps(4);
h.get_by_label("PhotoCraft");
assert!(h.query_by_label_contains("Hidden Apps").is_none());
assert!(h.state().as_ref().unwrap().settings.hidden.is_empty());
assert_eq!(h.state().as_ref().unwrap().settings.filter, artcraft_launcher_ui_egui::settings::Filter::All);
}
#[test]
fn switching_views_and_filters() {
let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true);
@@ -411,7 +526,7 @@ fn name_and_tagline_are_centred_on_the_icon() {
let (mut h, _) = harness_at(ViewMode::Grid, egui::Theme::Dark, true, scale);
h.run_steps(8);
snapshot(&mut h, &format!("grid-dark{suffix}"));
let off = below_icon_centre(&h, &["PhotoCraft", "Image editing", "Like Adobe Photoshop®"]);
let off = below_icon_centre(&h, &["PhotoCraft", "Image editing"]);
assert!(off.abs() < 0.25, "grid at {scale}x: {off}");
let (mut h, _) = harness_at(ViewMode::Table, egui::Theme::Light, true, scale);
h.run_steps(8);
@@ -453,11 +568,6 @@ fn every_table_column_sorts_both_ways() {
h.run_steps(3);
let o = order(&h);
assert_eq!(&o[..4], ["WordCraft", "LightCraft", "PhotoCraft", "EffectCraft"], "{o:?}");
// Inspired by: A to Z.
h.get_by_label("Inspired by").click();
h.run_steps(3);
let o = order(&h);
assert_eq!(o.first().map(String::as_str), Some("PdfCraft"), "{o:?}"); // Adobe Acrobat
// Latest: highest version first, SoundCraft (no release) last.
h.get_by_label("Latest").click();
h.run_steps(3);
@@ -485,6 +595,31 @@ fn settings_link_to_discord() {
assert!(h.query_all_by_label_contains("Discord").count() >= 2);
}
#[test]
fn the_uninstall_prompt_keeps_its_own_size_after_a_taller_dialog() {
let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true);
h.run_steps(6);
// Settings is the tallest dialog: open it and close it again.
h.get_by_label("Settings").click();
h.run_steps(4);
h.get_by_label("Done").click();
h.run_steps(3);
// PhotoCraft is installed, so its … menu offers Uninstall.
h.get_all_by_label("…").next().unwrap().click();
h.run_steps(3);
h.get_by_label("Uninstall…").click();
h.run_steps(4);
snapshot(&mut h, "uninstall-prompt-dark");
// The buttons follow the text closely, not somewhere down the height Settings had (#88).
let text = h.get_by_label("The app will be moved to the Trash. Your documents and settings stay.").rect();
let button = h.get_by_label("Uninstall").rect();
let gap = button.top() - text.bottom();
assert!((0.0..48.0).contains(&gap), "the buttons sit {gap} px below the text");
h.get_by_label("Cancel").click();
h.run_steps(3);
assert!(h.query_by_label("Uninstall").is_none());
}
#[test]
fn links_to_the_website_github_and_each_apps_pages() {
let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true);
@@ -493,18 +628,19 @@ fn links_to_the_website_github_and_each_apps_pages() {
for label in ["Website", "GitHub", "Discord"] {
assert!(h.query_all_by_label(label).count() >= 1, "{label}");
}
// Cards: every app links to its repo; only the seven classic apps have a website (plus the footer's).
// Cards: every app links to its repo and to its own page on getartcraft.com (plus the footer's).
let shown_cards = h.query_all_by_label("GitHub").count() - 1;
let shown_sites = h.query_all_by_label("Website").count() - 1;
assert!(shown_cards >= 7, "{shown_cards}");
assert_eq!(shown_sites, 7, "only classic apps have website pages");
// The table's … menu has the app's GitHub repo and, for a new app, no website yet.
assert!(shown_cards >= 13, "{shown_cards}");
assert_eq!(shown_sites, 13, "every app has a website page");
// The table's … menu has the app's website and GitHub repo.
let (mut h, _) = harness(ViewMode::Table, egui::Theme::Dark, true);
h.run_steps(6);
h.get_all_by_label("…").nth(7).unwrap().click(); // WordCraft
h.run_steps(3);
h.get_by_label("GitHub repository");
h.get_by_label("Website (coming soon)");
// The footer's Website link and WordCraft's now-live one in the menu.
assert!(h.query_all_by_label_contains("Website").count() >= 2);
}
/// Clicks the `n`th widget labelled `label` and returns the URL the app asked to open.
@@ -561,7 +697,8 @@ fn links_open_the_right_pages() {
fn looks_again_at_installed_apps_every_few_minutes_not_every_frame() {
let sent = Rc::new(RefCell::new(Vec::new()));
let clock = Rc::new(Cell::new(NOW));
let mut backend = Some(Fake { events: events(), sent: sent.clone(), manage: true, clock: clock.clone() });
let mut backend =
Some(Fake { events: events(), sent: sent.clone(), manage: true, pictures: Rc::default(), asked: Vec::new(), clock: clock.clone() });
let mut h = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).build_ui_state(
move |ui, state: &mut Option<LauncherUi>| {
if state.is_none()
@@ -624,8 +761,10 @@ fn the_launcher_updates_itself_and_quits_to_restart() {
}
let quit = Rc::new(Cell::new(false));
let mut host = QuitHost(quit.clone());
let mut backend =
Some(Scripted { inner: Fake { events, sent: sent.clone(), manage: true, clock: Rc::new(Cell::new(NOW)) }, later: shared_events.clone() });
let mut backend = Some(Scripted {
inner: Fake { events, sent: sent.clone(), manage: true, pictures: Rc::default(), asked: Vec::new(), clock: Rc::new(Cell::new(NOW)) },
later: shared_events.clone(),
});
let mut h = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).build_ui_state(
move |ui, state: &mut Option<LauncherUi>| {
if state.is_none()
@@ -704,7 +843,7 @@ fn feed_announcements_and_alerts_show_until_dismissed() {
announcements: vec![notice("a1", "CADCraft 1.0 is out", Some("https://getartcraft.com/news"))],
alerts: vec![notice("x1", "Security update", Some("https://getartcraft.com/security"))],
});
let mut backend = Some(Fake { events: evs, sent, manage: true, clock: Rc::new(Cell::new(NOW)) });
let mut backend = Some(Fake { events: evs, sent, manage: true, pictures: Rc::default(), asked: Vec::new(), clock: Rc::new(Cell::new(NOW)) });
let mut h = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).build_ui_state(
move |ui, state: &mut Option<LauncherUi>| {
if state.is_none()
@@ -751,7 +890,8 @@ fn automatic_updates_queue_installs_for_outdated_apps_and_launcher() {
ev.push(Event::LauncherUpdate(release("artcraft-launcher", "0.2.0", NOW)));
let sent = Rc::new(RefCell::new(Vec::new()));
let sent2 = sent.clone();
let mut backend = Some(Fake { events: ev, sent: sent2, manage: true, clock: Rc::new(Cell::new(NOW)) });
let mut backend =
Some(Fake { events: ev, sent: sent2, manage: true, pictures: Rc::default(), asked: Vec::new(), clock: Rc::new(Cell::new(NOW)) });
let mut h = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).build_ui_state(
move |ui, state: &mut Option<LauncherUi>| {
if state.is_none()
@@ -779,7 +919,8 @@ fn automatic_updates_are_disabled_by_default() {
ev.push(Event::LauncherUpdate(release("artcraft-launcher", "0.2.0", NOW)));
let sent = Rc::new(RefCell::new(Vec::new()));
let sent2 = sent.clone();
let mut backend = Some(Fake { events: ev, sent: sent2, manage: true, clock: Rc::new(Cell::new(NOW)) });
let mut backend =
Some(Fake { events: ev, sent: sent2, manage: true, pictures: Rc::default(), asked: Vec::new(), clock: Rc::new(Cell::new(NOW)) });
let mut h = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).build_ui_state(
move |ui, state: &mut Option<LauncherUi>| {
if state.is_none()
@@ -806,7 +947,7 @@ fn the_footer_names_the_services_behind_what_is_on_screen() {
let mut evs = events();
evs.extend(extra);
let sent = Rc::new(RefCell::new(Vec::new()));
let mut backend = Some(Fake { events: evs, sent, manage: true, clock: Rc::new(Cell::new(NOW)) });
let mut backend = Some(Fake { events: evs, sent, manage: true, pictures: Rc::default(), asked: Vec::new(), clock: Rc::new(Cell::new(NOW)) });
let mut h = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).build_ui_state(
move |ui, state: &mut Option<LauncherUi>| {
if state.is_none()
@@ -849,7 +990,14 @@ fn custom_title_bar_has_window_buttons() {
artcraft_launcher_ui_egui::TitleBar::Custom
}
}
let mut backend = Some(Fake { events: events(), sent: Rc::new(RefCell::new(Vec::new())), manage: true, clock: Rc::new(Cell::new(NOW)) });
let mut backend = Some(Fake {
events: events(),
sent: Rc::new(RefCell::new(Vec::new())),
manage: true,
pictures: Rc::default(),
asked: Vec::new(),
clock: Rc::new(Cell::new(NOW)),
});
let mut h = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).build_ui_state(
move |ui, state: &mut Option<LauncherUi>| {
if state.is_none()
@@ -874,12 +1022,272 @@ fn custom_title_bar_has_window_buttons() {
assert!(closes, "the close button asks the window to close (the shell decides whether that hides it to the tray)");
}
#[test]
fn clicking_a_card_opens_its_details_drawer() {
let (mut h, _sent) = harness(ViewMode::Grid, egui::Theme::Dark, true);
h.run_steps(8);
// Every app has a page: a card of each generation opens one.
h.get_by_label("VectorCraft details");
h.get_by_label("WordCraft details");
h.get_by_label("PhotoCraft details").click();
h.run_steps(10);
snapshot(&mut h, "details-dark");
h.get_by_label_contains("The image editor you already know how to use");
h.get_by_label_contains("Curves and type layers · 1/4");
// The links the page offers as buttons of their own.
h.get_by_label("Website ↗");
h.get_by_label("GitHub ↗");
h.get_by_label("Releases ↗");
// The picture arrows wrap around, and the thumbnails jump.
h.get_by_label("Previous picture (←)").click();
h.run_steps(3);
h.get_by_label_contains("Selections and masked adjustments · 4/4");
h.get_by_label("Next picture (→)").click();
h.run_steps(3);
h.get_by_label_contains("Curves and type layers · 1/4");
// Esc closes it and the page is gone once it has slid out.
h.key_press(egui::Key::Escape);
h.run_steps(10);
assert!(h.query_by_label_contains("The image editor you already know").is_none());
}
#[test]
fn details_drawer_in_the_light_theme() {
let (mut h, _sent) = harness(ViewMode::Grid, egui::Theme::Light, true);
h.run_steps(8);
h.get_by_label("PhotoCraft details").click();
h.run_steps(10);
snapshot(&mut h, "details-light");
h.get_by_label_contains("The image editor you already know how to use");
}
fn fetches(sent: &Rc<RefCell<Vec<Command>>>, app: &str) -> usize {
sent.borrow().iter().filter(|c| matches!(c, Command::FetchScreenshots { app: a } if a == app)).count()
}
#[test]
fn opening_a_page_asks_for_its_pictures_once_and_only_while_it_lacks_some() {
let (mut h, sent) = harness(ViewMode::Grid, egui::Theme::Dark, true);
h.run_steps(8);
assert_eq!(fetches(&sent, "photocraft"), 0, "nothing is fetched before a page is opened");
h.get_by_label("PhotoCraft details").click();
h.run_steps(10);
assert_eq!(fetches(&sent, "photocraft"), 1);
h.get_by_label("Enlarge picture");
// Closed and opened again: the pictures are in hand, so nothing is asked.
h.key_press(egui::Key::Escape);
h.run_steps(10);
h.get_by_label("PhotoCraft details").click();
h.run_steps(10);
assert_eq!(fetches(&sent, "photocraft"), 1);
h.get_by_label("Enlarge picture");
// Another page asks for its own, and a page without pictures asks for nothing.
h.key_press(egui::Key::Escape);
h.run_steps(10);
h.get_by_label("VectorCraft details").click();
h.run_steps(10);
assert_eq!(fetches(&sent, "vectorcraft"), 1);
h.key_press(egui::Key::Escape);
h.run_steps(10);
h.get_by_label("PdfCraft details").click();
h.run_steps(10);
assert_eq!(fetches(&sent, "pdfcraft"), 0);
}
#[test]
fn a_frame_stands_in_for_the_pictures_until_they_arrive() {
let pictures = Rc::new(Cell::new(Pictures::Silent));
let (mut h, sent) = harness_pictures(events(), ViewMode::Grid, egui::Theme::Dark, true, 1.0, pictures.clone());
h.run_steps(8);
h.get_by_label("PhotoCraft details").click();
h.run_steps(10);
snapshot(&mut h, "details-loading-dark");
h.get_by_label("Loading pictures…");
assert!(h.query_by_label("Enlarge picture").is_none(), "there is nothing to enlarge yet");
// The rest of the page is there, and the arrows work on the frames.
h.get_by_label_contains("The image editor you already know how to use");
h.get_by_label("Next picture (→)").click();
h.run_steps(3);
h.get_by_label_contains("Levels with a live histogram · 2/4");
// Asking again meanwhile is not done: the answer is on its way.
assert_eq!(fetches(&sent, "photocraft"), 1);
pictures.set(Pictures::Online);
h.run_steps(6);
assert!(h.query_by_label("Loading pictures…").is_none());
h.get_by_label("Enlarge picture");
}
#[test]
fn offline_the_frame_says_so_and_opening_the_page_again_retries() {
let pictures = Rc::new(Cell::new(Pictures::Offline));
let (mut h, sent) = harness_pictures(events(), ViewMode::Grid, egui::Theme::Light, true, 1.0, pictures.clone());
h.run_steps(8);
h.get_by_label("PhotoCraft details").click();
h.run_steps(10);
snapshot(&mut h, "details-offline-light");
h.get_by_label("Pictures couldn't be loaded. Reopen this page to try again.");
assert!(h.query_by_label("Loading pictures…").is_none());
assert!(h.query_by_label("Enlarge picture").is_none());
h.get_by_label_contains("The image editor you already know how to use");
// Back online: the page is closed and opened again, which asks again.
pictures.set(Pictures::Online);
h.key_press(egui::Key::Escape);
h.run_steps(10);
h.get_by_label("PhotoCraft details").click();
h.run_steps(10);
assert_eq!(fetches(&sent, "photocraft"), 2);
h.get_by_label("Enlarge picture");
assert!(h.query_by_label("Pictures couldn't be loaded. Reopen this page to try again.").is_none());
}
#[test]
fn the_enlarged_view_shows_a_frame_for_a_picture_still_missing() {
let pictures = Rc::new(Cell::new(Pictures::SecondFails));
let (mut h, _sent) = harness_pictures(events(), ViewMode::Grid, egui::Theme::Dark, true, 1.0, pictures);
h.run_steps(8);
h.get_by_label("PhotoCraft details").click();
h.run_steps(10);
h.get_by_label("Enlarge picture").click();
h.run_steps(3);
// The second picture is missing: flipping to it keeps the view open, with a frame; the others still show.
h.key_press(egui::Key::ArrowRight);
h.run_steps(3);
// Once in the drawer behind (it shows the same picture) and once in the enlarged view.
assert_eq!(h.query_all_by_label("Pictures couldn't be loaded. Reopen this page to try again.").count(), 2);
assert_eq!(h.query_all_by_label("Close (Esc)").count(), 2, "the enlarged view is still open");
h.key_press(egui::Key::ArrowRight);
h.run_steps(3);
assert!(h.query_by_label("Pictures couldn't be loaded. Reopen this page to try again.").is_none(), "the third picture is fine");
}
#[test]
fn a_page_without_pictures_has_no_gallery() {
let (mut h, _sent) = harness(ViewMode::Grid, egui::Theme::Dark, true);
h.run_steps(8);
// PdfCraft's screenshots still show its old name (PrintCraft), so its page has none for now.
h.get_by_label("PdfCraft details").click();
h.run_steps(10);
snapshot(&mut h, "details-no-pictures-dark");
h.get_by_label_contains("The open-source PDF workbench.");
h.get_by_label_contains("Search as you type");
assert!(h.query_by_label("Enlarge picture").is_none());
assert!(h.query_by_label("Next picture (→)").is_none());
}
#[test]
fn the_lead_picture_can_be_enlarged() {
let (mut h, _sent) = harness(ViewMode::Grid, egui::Theme::Dark, true);
h.run_steps(8);
h.get_by_label("PhotoCraft details").click();
h.run_steps(10);
// The drawer has one "Close (Esc)"; the enlarged view adds its own.
assert_eq!(h.query_all_by_label("Close (Esc)").count(), 1);
h.get_by_label("Enlarge picture").click();
h.run_steps(4);
snapshot(&mut h, "details-zoom-dark");
assert_eq!(h.query_all_by_label("Close (Esc)").count(), 2);
// The keyboard flips through the pictures there too.
h.key_press(egui::Key::ArrowRight);
h.run_steps(3);
h.get_by_label_contains("Levels with a live histogram · 2/4");
// Esc closes the enlarged picture first, and the drawer only the second time.
h.key_press(egui::Key::Escape);
h.run_steps(4);
assert_eq!(h.query_all_by_label("Close (Esc)").count(), 1);
h.get_by_label_contains("The image editor you already know how to use");
h.key_press(egui::Key::Escape);
h.run_steps(10);
assert!(h.query_by_label_contains("The image editor you already know").is_none());
}
/// Every page, whatever it holds: no pictures (PdfCraft), one (DesignCraft) or several. The arrow keys are
/// pressed more often than there are pictures, both ways, to wrap around the list and to do nothing on a
/// page that has one or none; the enlarged view is opened wherever there is a picture to enlarge.
#[test]
fn every_page_opens_and_pages_through_its_pictures() {
let (mut h, _sent) = harness(ViewMode::Grid, egui::Theme::Dark, true);
// Tall enough to show all thirteen cards at once.
h.set_size(egui::vec2(1180.0, 3000.0));
h.run_steps(8);
for d in &artcraft_launcher_core::details::DETAILS {
let name = SUITE.iter().find(|a| a.id == d.id).map(|a| a.name).unwrap_or(d.id);
h.get_by_label(&format!("{name} details")).click();
h.run_steps(8);
h.get_by_label_contains(d.headline);
let presses = d.shots.len() + 2;
for key in [egui::Key::ArrowRight, egui::Key::ArrowLeft] {
for _ in 0..presses {
h.key_press(key);
h.run_steps(1);
}
}
// The picture can be enlarged exactly when the page has one.
assert_eq!(h.query_by_label("Enlarge picture").is_some(), !d.shots.is_empty(), "{}", d.id);
if !d.shots.is_empty() {
h.get_by_label("Enlarge picture").click();
h.run_steps(3);
for key in [egui::Key::ArrowRight, egui::Key::ArrowLeft] {
for _ in 0..presses {
h.key_press(key);
h.run_steps(1);
}
}
// A full lap in each direction brings it back to the first picture. Its caption is in the drawer
// and in the enlarged view, and has a counter only when there are several pictures.
let first = &d.shots[0];
let caption = if d.shots.len() > 1 { format!("{} · 1/{}", first.caption, d.shots.len()) } else { first.caption.to_string() };
assert!(h.query_all_by_label_contains(&caption).count() >= 1, "{}: {caption}", d.id);
h.key_press(egui::Key::Escape);
h.run_steps(3);
}
h.key_press(egui::Key::Escape);
h.run_steps(10);
assert!(h.query_by_label_contains(d.headline).is_none(), "{} did not close", d.id);
}
}
/// The drawer and the enlarged picture at sizes far below what the desktop window allows (its minimum is
/// 720 x 500, but the web build has no such floor) and far above, resized while they are open.
#[test]
fn the_drawer_and_the_enlarged_picture_survive_any_window_size() {
let (mut h, _sent) = harness(ViewMode::Grid, egui::Theme::Dark, true);
h.run_steps(8);
h.get_by_label("PhotoCraft details").click();
h.run_steps(10);
let sizes = [(1180.0, 900.0), (720.0, 500.0), (480.0, 320.0), (320.0, 200.0), (120.0, 90.0), (40.0, 30.0), (4000.0, 2400.0)];
for zoom in [false, true] {
if zoom {
h.get_by_label("Enlarge picture").click();
h.run_steps(3);
}
for (w, ht) in sizes {
h.set_size(egui::vec2(w, ht));
h.run_steps(3);
}
// Back to a normal size, still working.
h.set_size(egui::vec2(1180.0, 900.0));
h.run_steps(4);
if zoom {
h.key_press(egui::Key::Escape);
h.run_steps(3);
}
h.get_by_label_contains("The image editor you already know how to use");
}
}
#[test]
fn the_rate_limit_notice_never_runs_under_the_footer_links() {
for width in [720.0, 960.0, 1180.0] {
let mut evs = events();
evs.push(Event::CheckFinished { at: NOW - 180, source: Source::GitHubApi, rate_limited_until: Some(NOW + 1800) });
let mut backend = Some(Fake { events: evs, sent: Rc::new(RefCell::new(Vec::new())), manage: true, clock: Rc::new(Cell::new(NOW)) });
let mut backend = Some(Fake {
events: evs,
sent: Rc::new(RefCell::new(Vec::new())),
manage: true,
pictures: Rc::default(),
asked: Vec::new(),
clock: Rc::new(Cell::new(NOW)),
});
let mut h = Harness::builder().with_size(egui::vec2(width, 900.0)).build_ui_state(
move |ui, state: &mut Option<LauncherUi>| {
if state.is_none()
@@ -917,3 +1325,174 @@ fn the_rate_limit_notice_never_runs_under_the_footer_links() {
}
}
}
#[test]
fn narrow_header_keeps_the_view_switch_clear_of_brand_text() {
use artcraft_launcher_ui_egui::TitleBar;
for theme in [egui::Theme::Dark, egui::Theme::Light] {
for bar in [TitleBar::Native, TitleBar::MacOs, TitleBar::Custom] {
let (mut h, _) = harness_with_title_bar(events(), ViewMode::Grid, theme, true, 1.0, bar);
for width in (720..=1180).step_by(10).chain([720, 1180]).map(|w| w as f32) {
h.set_size(egui::vec2(width, 900.0));
h.run_steps(6);
for label in ["Grid", "Table", "Settings", "Check for updates"] {
let rect = h.get_by_label(label).rect();
assert!(rect.left() >= 0.0 && rect.right() <= width, "{bar:?} {width}: {label} {rect:?}");
for label in ["Launcher", "Craft App Suite"] {
if let Some(text) = h.query_by_label(label) {
assert!(!rect.intersects(text.rect()), "{bar:?} {width}: {label} overlaps {rect:?}");
}
}
}
if bar == TitleBar::Custom {
for label in ["Minimize", "Maximize", "Close"] {
let rect = h.get_by_label(label).rect();
assert!(rect.left() >= 0.0 && rect.right() <= width, "{width}: {label} {rect:?}");
}
}
if width == 1180.0 {
h.get_by_label("Launcher");
h.get_by_label("Craft App Suite");
}
if [720.0, 800.0, 1180.0].contains(&width) {
snapshot(&mut h, &format!("header-{theme:?}-{bar:?}-{width}"));
}
}
h.get_by_label("Table").click();
h.run_steps(2);
assert_eq!(h.state().as_ref().unwrap().settings.view, ViewMode::Table);
}
}
}
/// A shell on an OS whose animation setting the test switches.
struct OsMotion(Rc<Cell<bool>>);
impl Host for OsMotion {
fn reduce_motion(&self) -> bool {
self.0.get()
}
}
/// The grid in a window too short for it, at 60 frames a second, on an OS asking for fewer
/// animations (`reduce`) or not. Nothing is downloading, so the window is still unless something
/// animates.
fn short_window(reduce: Rc<Cell<bool>>) -> Harness<'static, Option<LauncherUi>> {
artcraft_launcher_ui_egui::i18n::set_test_lang(artcraft_launcher_ui_egui::i18n::Lang::EN);
let events = events().into_iter().filter(|e| !matches!(e, Event::Progress { .. })).collect();
let mut backend =
Some(Fake { events, sent: Rc::default(), manage: true, pictures: Rc::default(), asked: Vec::new(), clock: Rc::new(Cell::new(NOW)) });
let mut host = OsMotion(reduce);
let mut h = Harness::builder().with_size(egui::vec2(1180.0, 500.0)).with_step_dt(1.0 / 60.0).with_max_steps(120).build_ui_state(
move |ui, state: &mut Option<LauncherUi>| {
if state.is_none()
&& let Some(b) = backend.take()
{
let settings = Settings { view: ViewMode::Grid, notify: false, ..Settings::default() };
*state = Some(LauncherUi::new(ui.ctx(), Box::new(b), settings));
}
if let Some(l) = state.as_mut() {
l.logic(&ui.ctx().clone(), &mut host);
l.ui(ui, &mut host);
}
},
None,
);
h.run_steps(8);
h
}
/// How far down the window the grid's first card is.
fn grid_top(h: &Harness<'_, Option<LauncherUi>>) -> f32 {
h.get_by_label("Image editing").rect().top()
}
/// Turns the mouse wheel `lines` down over the grid, by way of the shell's `raw_input_hook`. The
/// next frame takes it in.
fn turn_wheel(h: &mut Harness<'_, Option<LauncherUi>>, lines: f32) {
h.hover_at(egui::pos2(590.0, 300.0));
h.run_steps(2);
let wheel = egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Line,
delta: egui::vec2(0.0, -lines),
phase: egui::TouchPhase::Move,
modifiers: egui::Modifiers::NONE,
};
let mut raw = egui::RawInput { events: vec![wheel], ..Default::default() };
if let Some(l) = h.state() {
l.raw_input(&h.ctx, &mut raw);
}
for event in raw.events {
h.event(event);
}
}
#[test]
fn the_os_animation_setting_is_followed_as_it_changes() {
let reduce = Rc::new(Cell::new(true));
let mut h = short_window(reduce.clone());
assert_eq!(h.ctx.global_style().animation_time, 0.0);
// Switched back on while the launcher runs: noticed within a couple of seconds.
reduce.set(false);
h.run_steps(150);
assert_eq!(h.ctx.global_style().animation_time, egui::Style::default().animation_time);
reduce.set(true);
h.run_steps(150);
assert_eq!(h.ctx.global_style().animation_time, 0.0);
}
#[test]
fn with_animations_off_the_wheel_scrolls_at_once() {
let mut h = short_window(Rc::new(Cell::new(true)));
let notch = 3.0 * h.ctx.options(|o| o.input_options.line_scroll_speed);
let start = grid_top(&h);
turn_wheel(&mut h, 3.0);
// The frame the wheel turned in, and the one that draws where it scrolled to.
h.run_steps(2);
let moved = start - grid_top(&h);
assert!((moved - notch).abs() < 0.5, "moved {moved} of {notch}");
// Nothing is left to glide.
h.run_steps(30);
assert_eq!(start - grid_top(&h), moved);
}
#[test]
fn with_animations_on_the_wheel_glides() {
let mut h = short_window(Rc::new(Cell::new(false)));
let notch = 3.0 * h.ctx.options(|o| o.input_options.line_scroll_speed);
let start = grid_top(&h);
turn_wheel(&mut h, 3.0);
h.run_steps(2);
let moved = start - grid_top(&h);
assert!(moved > 0.0 && moved < notch / 2.0, "moved {moved} of {notch}");
// And is there within the second.
h.run_steps(60);
let moved = start - grid_top(&h);
assert!((moved - notch).abs() < 0.5, "moved {moved} of {notch}");
}
/// Moves the pointer from one card to the next. Returns how many frames are drawn before the
/// window is still again.
fn frames_to_hover_the_next_card(h: &mut Harness<'_, Option<LauncherUi>>) -> u64 {
let cards: Vec<egui::Pos2> = ["Image editing", "Vector illustration"].map(|t| h.get_by_label(t).rect().center()).to_vec();
// Still to begin with: the icons are in, the first card is lit and the scroll bar is showing.
h.hover_at(cards[0]);
h.run();
h.hover_at(cards[1]);
h.run()
}
#[test]
fn with_animations_off_hovering_a_card_shows_at_once() {
let mut h = short_window(Rc::new(Cell::new(true)));
let frames = frames_to_hover_the_next_card(&mut h);
assert!(frames <= 5, "{frames} frames");
}
#[test]
fn with_animations_on_hovering_a_card_animates() {
let mut h = short_window(Rc::new(Cell::new(false)));
// The rule wipes in and the icon rises over 0.3 s, at 60 frames a second.
let frames = frames_to_hover_the_next_card(&mut h);
assert!(frames >= 10, "{frames} frames");
}
Generated
+96
View File
@@ -0,0 +1,96 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1791594108,
"narHash": "sha256-XK4KceonkwD8LuMAt/vNrz982mXJM5jeHAxMwsUXXAQ=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "6510408d147d8c5e6d76043e899e9251756f6a75",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1744536153,
"narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1791707771,
"narHash": "sha256-w3m8HtAVCfGTHw+gcCTAy7jFHSdGbokm8NngbeFrkek=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "c1cfa7a2c809d364ac1a7e9b3ec418afa3f19793",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+82
View File
@@ -0,0 +1,82 @@
{
description = "Experimental Nix flake for ArtCraft Launcher";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
rust-overlay.url = "github:oxalica/rust-overlay";
};
outputs = { self, nixpkgs, flake-utils, rust-overlay }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs {
inherit system;
overlays = [ rust-overlay.overlays.default ];
};
rust = pkgs.rust-bin.stable.latest.default.override {
extensions = [ "rustfmt" "clippy" "rust-src" ];
};
version = (builtins.fromTOML (builtins.readFile ./Cargo.toml)).workspace.package.version;
commonDeps = with pkgs; [
pkg-config
openssl
libxkbcommon
libx11
libxcursor
libxrandr
libxi
libxcb
libGL
vulkan-loader
mesa
wayland
wayland-protocols
fontconfig
freetype
alsa-lib
zlib
];
in
{
devShells.default = pkgs.mkShell {
nativeBuildInputs = with pkgs; [
rust
cmake
ninja
libclang
];
buildInputs = commonDeps;
shellHook = ''
export LIBCLANG_PATH="${pkgs.libclang.lib}/lib"
export PKG_CONFIG_PATH="${pkgs.openssl.dev}/lib/pkgconfig:$PKG_CONFIG_PATH"
'';
};
packages.default = pkgs.rustPlatform.buildRustPackage {
pname = "artcraft-launcher";
version = version;
src = ./.;
cargoLock.lockFile = ./Cargo.lock;
nativeBuildInputs = with pkgs; [
pkg-config
cmake
ninja
libclang
];
buildInputs = commonDeps;
doCheck = false;
};
apps.default = {
type = "app";
program = "${self.packages.${system}.default}/bin/artcraft-launcher";
};
});
}
@@ -13,8 +13,8 @@
<description>
<p>
ArtCraft Launcher lists every app in the Craft App Suite (PhotoCraft, LightCraft, FilmCraft,
DesignCraft, VectorCraft, PdfCraft, EffectCraft, WordCraft, DeckCraft, GridCraft, CADCraft
and SoundCraft), installs the right build for your computer, and keeps them up to date.
DesignCraft, VectorCraft, PdfCraft, EffectCraft, WordCraft, DeckCraft, GridCraft, CADCraft,
SoundCraft and FigCraft), installs the right build for your computer, and keeps them up to date.
</p>
<ul>
<li>One place to find, install and open every Craft app</li>
@@ -24,18 +24,27 @@ finish-args:
- --share=ipc
# GPU access for egui's wgpu renderer (Vulkan, or GL as a fallback).
- --device=dri
# SoundCraft's audio, for the case where it runs inside this sandbox rather than on the host
# (the launcher falls back to its release tarball when the host has no usable Flatpak): its
# ALSA goes through the runtime's PulseAudio plugin to this socket, which PipeWire's pulse
# server provides on modern desktops. Note this socket also covers the microphone (SoundCraft
# records, so it needs it); it grants recording, not raw hardware access.
- --socket=pulseaudio
# The launcher reads the Craft apps' GitHub releases and downloads them.
- --share=network
# Tray icon (StatusNotifierItem) and desktop notifications, both over the session bus.
- --talk-name=org.kde.StatusNotifierWatcher
- --talk-name=org.freedesktop.Notifications
# LIMITATION: a launcher inside a Flatpak sandbox cannot install software on the host by
# itself. It may run `flatpak-spawn --host` (org.freedesktop.Flatpak) to start installed apps
# and host installers, and it gets the home directory so downloaded AppImages and tarballs land
# where the host can run them (~/Applications, ~/.local). Distro packages (.deb/.rpm) and
# anything needing root still have to be installed by the user. org.freedesktop.Flatpak is a
# sandbox escape that Flathub reviews case by case; the AppImage, .deb and .rpm builds are the
# recommended way to run the launcher on Linux.
# Apps normally install as Flatpaks and run on the host: `flatpak-spawn --host` (below) starts
# them, and the home directory is granted so files opened by path work. When the host has no
# usable Flatpak the launcher falls back to the release tarball, which it unpacks into its own
# folder (~/.var/app/ai.storyteller.artcraft-launcher/data/artcraft-launcher/apps/<app>) and
# runs in the same sandbox with HOME pointed at a home/ folder beside it, so each app's
# settings stay in the sandbox too.
# LIMITATION: installing software on the host still needs `flatpak-spawn --host`. Distro
# packages (.deb/.rpm) or anything needing root still have to be installed by the user.
# org.freedesktop.Flatpak is a sandbox escape that Flathub reviews case by case; the AppImage,
# .deb and .rpm builds are the recommended way to run the launcher on Linux.
- --talk-name=org.freedesktop.Flatpak
- --filesystem=home
modules:
@@ -26,18 +26,27 @@ finish-args:
- --share=ipc
# GPU access for egui's wgpu renderer (Vulkan, or GL as a fallback).
- --device=dri
# SoundCraft's audio, for the case where it runs inside this sandbox rather than on the host
# (the launcher falls back to its release tarball when the host has no usable Flatpak): its
# ALSA goes through the runtime's PulseAudio plugin to this socket, which PipeWire's pulse
# server provides on modern desktops. Note this socket also covers the microphone (SoundCraft
# records, so it needs it); it grants recording, not raw hardware access.
- --socket=pulseaudio
# The launcher reads the Craft apps' GitHub releases and downloads them.
- --share=network
# Tray icon (StatusNotifierItem) and desktop notifications, both over the session bus.
- --talk-name=org.kde.StatusNotifierWatcher
- --talk-name=org.freedesktop.Notifications
# LIMITATION: a launcher inside a Flatpak sandbox cannot install software on the host by
# itself. It may run `flatpak-spawn --host` (org.freedesktop.Flatpak) to start installed apps
# and host installers, and it gets the home directory so downloaded AppImages and tarballs land
# where the host can run them (~/Applications, ~/.local). Distro packages (.deb/.rpm) and
# anything needing root still have to be installed by the user. org.freedesktop.Flatpak is a
# sandbox escape that Flathub reviews case by case; the AppImage, .deb and .rpm builds are the
# recommended way to run the launcher on Linux.
# Apps normally install as Flatpaks and run on the host: `flatpak-spawn --host` (below) starts
# them, and the home directory is granted so files opened by path work. When the host has no
# usable Flatpak the launcher falls back to the release tarball, which it unpacks into its own
# folder (~/.var/app/ai.storyteller.artcraft-launcher/data/artcraft-launcher/apps/<app>) and
# runs in the same sandbox with HOME pointed at a home/ folder beside it, so each app's
# settings stay in the sandbox too.
# LIMITATION: installing software on the host still needs `flatpak-spawn --host`. Distro
# packages (.deb/.rpm) or anything needing root still have to be installed by the user.
# org.freedesktop.Flatpak is a sandbox escape that Flathub reviews case by case; the AppImage,
# .deb and .rpm builds are the recommended way to run the launcher on Linux.
- --talk-name=org.freedesktop.Flatpak
- --filesystem=home
build-options:
+3 -3
View File
@@ -19,9 +19,9 @@ license: MIT OR Apache-2.0
description: |-
Install, update and launch the Craft App Suite.
ArtCraft Launcher lists PhotoCraft, LightCraft, FilmCraft, DesignCraft,
VectorCraft, PdfCraft, EffectCraft, WordCraft, DeckCraft, GridCraft, CADCraft
and SoundCraft, installs the right build for this computer and keeps them up
to date from GitHub releases. It can live in the system tray.
VectorCraft, PdfCraft, EffectCraft, WordCraft, DeckCraft, GridCraft, CADCraft,
SoundCraft and FigCraft, installs the right build for this computer and keeps
them up to date from GitHub releases. It can live in the system tray.
# The binary links only glibc and libgcc_s directly. Windowing (X11/Wayland, xkbcommon) and the
# GPU (Vulkan, EGL/GL) are loaded at runtime with dlopen, so they aren't ELF NEEDED entries and
+47
View File
@@ -0,0 +1,47 @@
# macOS DMG window
What Finder shows when the DMG opens: a 660 × 400 pt background with the app icon and the
`Applications` link side by side, laid out like the other Craft apps' DMGs. `package.sh` copies
these files into the image; nothing here is generated at build time, so the DMG builds with a plain
`hdiutil create -srcfolder` (no Finder scripting on CI, nothing extra installed in the signing job).
| File | What |
|---|---|
| `background.svg` | Source of the background: the ArtCraft mark in black (`docs/brand/artcraft-mark-black.svg`, linked, not copied) whole on the mark's blue (`#4e7bfb`), Ink and Paper. The mark is a trademark: it stays exactly as supplied, never cropped or recoloured (`docs/brand/LICENSE-brand.txt`), unlike the cropped icons on the other apps' DMGs. Its text (Inter, Geist Mono from `assets/fonts`) is outlined, so rendering it needs no fonts. No paper grain: noise doesn't compress, and this keeps `background.tiff` small. |
| `background.tiff` | The background at 1x (660 × 400 px, 72 dpi) and 2x (1320 × 800 px, 144 dpi) in one HiDPI TIFF (16-colour palette, Deflate, sRGB). Goes to `.background/background.tiff`. |
| `dmg-layout.DS_Store` | Finder's view settings for the volume: window size, icon size 128, ArtCraft Launcher.app at (326, 205), `Applications` at (574, 205), and the background. Goes to `.DS_Store` in the image; named so it isn't mistaken for (or ignored like) a Finder-generated `.DS_Store`. |
| `generate.py` | Writes `background.tiff` and `dmg-layout.DS_Store` from the SVG and the layout above. |
## The volume name has no version
The mounted volume is called `ArtCraft Launcher`, not `ArtCraft Launcher <version>`. The layout
points at the background through an alias that includes the volume name, and Finder resolves it by
that name: with a versioned name the window keeps its size and icon positions but shows no
background. The DMG file name (`artcraft-launcher-<version>-macos-<arch>.dmg`) still carries the
version.
## Rules
- **Finder draws the icon labels in black in light and dark mode** when a window has a background,
so the area under both icons stays light (Paper).
- **Nothing goes inside the icon boxes:** artwork keeps 10 pt clear of each 128 pt icon box and of
the label strip under it.
## Regenerate
Edit `background.svg` (or the layout constants in `generate.py`), then run, on any OS:
```sh
pip install 'pillow>=12' ds_store==1.3.3 mac_alias==2.2.3
python3 packaging/macos/dmg/generate.py # needs resvg on PATH
```
Both outputs are byte-for-byte reproducible and carry nothing from the machine that ran it: the
SVG renders without any fonts, and `dmg-layout.DS_Store` is written from scratch, its background
alias holding only the volume name and `/.background/background.tiff`. If you add text to the SVG,
outline it (`usvg --skip-system-fonts --use-font-file assets/fonts/Inter-Variable.ttf
--use-font-file assets/fonts/GeistMono-Variable.ttf` converts text to paths). The window is
660 × 432 with Finder's 32 pt title bar; the content area is 660 × 400.
This follows the other Craft apps' `packaging/macos/dmg/` (storytold/wordcraft#21,
storytold/photocraft#1201, first in storytold/vectorcraft#493).
+27
View File
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="660" height="400" viewBox="0 0 660 400">
<defs><clipPath id="panel"><rect width="232" height="400"/></clipPath></defs>
<rect width="660" height="400" fill="#efe9dc"/>
<g clip-path="url(#panel)"><rect width="232" height="400" fill="#4e7bfb"/><image href="../../../docs/brand/artcraft-mark-black.svg" x="31" y="115" width="170" height="170"/></g>
<line x1="232.5" y1="0" x2="232.5" y2="400" stroke="#0b0b0c" stroke-width="1.2"/>
<line x1="236.5" y1="0" x2="236.5" y2="400" stroke="#0b0b0c" stroke-width="0.5"/>
<line x1="267" y1="88.5" x2="624" y2="88.5" stroke="#0b0b0c" stroke-width="0.6"/>
<path d="M271.5,81.5 L276,88.5 L271.5,95.5 L267,88.5 Z" fill="#4e7bfb"/>
<rect x="410.5" y="199.5" width="11" height="11" fill="#efe9dc" stroke="#0b0b0c" stroke-opacity="0.45" stroke-width="0.7"/><rect x="425.5" y="199.5" width="11" height="11" fill="#efe9dc" stroke="#0b0b0c" stroke-opacity="0.45" stroke-width="0.7"/><rect x="440.5" y="199.5" width="11" height="11" fill="#efe9dc" stroke="#0b0b0c" stroke-opacity="0.45" stroke-width="0.7"/><rect x="455.5" y="199.5" width="11" height="11" fill="#4e7bfb" stroke="#0b0b0c" stroke-width="0.7"/><line x1="472" y1="205" x2="484" y2="205" stroke="#0b0b0c" stroke-width="1.2"/><path d="M495,205 L484,210.5 L484,199.5 Z" fill="#0b0b0c"/>
<line x1="267" y1="335.5" x2="624" y2="335.5" stroke="#0b0b0c" stroke-width="0.6"/>
<g id="text">
<g>
<path fill="#0b0b0c" stroke="none" d="M 266.24902 72 L 274.02734 50.17383 L 278.90527 50.17383 L 286.66895 72 L 282.3916 72 L 278.56836 60.54492 Q 277.99707 58.728516 277.3672 56.670406 Q 276.7373 54.612305 275.9463 51.81445 L 276.98633 51.81445 Q 276.18066 54.612305 275.55078 56.670406 Q 274.9209 58.728516 274.33496 60.54492 L 270.42383 72 Z M 270.86328 66.77051 L 270.86328 63.57715 L 282.0547 63.57715 L 282.0547 66.77051 Z M 288.10254 72 L 288.10254 56.428715 L 291.67676 56.428715 L 291.67676 59.021484 L 291.73535 59.021484 Q 292.16016 57.673832 293.09766 56.9707 Q 294.03516 56.26758 295.51465 56.26758 Q 295.88086 56.26758 296.1958 56.28955 Q 296.51074 56.31152 296.74512 56.340816 L 296.74512 59.63672 Q 296.54004 59.607426 296.03467 59.56348 Q 295.5293 59.519527 294.9873 59.519527 Q 294.1084 59.519527 293.37598 59.922367 Q 292.64355 60.325195 292.22607 61.13086 Q 291.8086 61.936523 291.8086 63.137695 L 291.8086 72 Z M 307.0703 56.428715 L 307.0703 59.402344 L 297.53418 59.402344 L 297.53418 56.428715 Z M 300.2002 52.253906 L 303.90625 52.253906 L 303.90625 67.546875 Q 303.90625 68.36719 304.2505 68.7041 Q 304.59473 69.041016 305.48828 69.041016 Q 305.83984 69.041016 306.30127 69.03369 Q 306.7627 69.02637 307.05566 69.01172 L 307.11426 71.98535 Q 306.7041 72 306.08154 72.01465 Q 305.45898 72.0293 304.8584 72.0293 Q 302.48535 72.0293 301.34277 71.04785 Q 300.2002 70.06641 300.2002 68.05957 Z M 318.64063 72.35156 Q 315.65234 72.35156 313.3379 70.95996 Q 311.02344 69.56836 309.70508 67.041504 Q 308.38672 64.51465 308.38672 61.101563 Q 308.38672 57.659184 309.70508 55.125004 Q 311.02344 52.590824 313.3379 51.206543 Q 315.65234 49.822266 318.64063 49.822266 Q 320.48633 49.822266 322.07568 50.371586 Q 323.66504 50.9209 324.90283 51.946293 Q 326.14063 52.97168 326.9463 54.407223 Q 327.75195 55.842773 328.01563 57.62988 L 324.08984 57.62988 Q 323.87012 56.604492 323.38672 55.798832 Q 322.90332 54.993164 322.2002 54.43652 Q 321.49707 53.879887 320.61084 53.59424 Q 319.7246 53.308594 318.68457 53.308594 Q 316.70703 53.308594 315.27148 54.268066 Q 313.83594 55.22754 313.0669 56.978027 Q 312.29785 58.728516 312.29785 61.101563 Q 312.29785 63.459965 313.0669 65.203125 Q 313.83594 66.94629 315.27148 67.90576 Q 316.70703 68.865234 318.68457 68.865234 Q 319.7246 68.865234 320.60352 68.57959 Q 321.48242 68.293945 322.18555 67.74463 Q 322.88867 67.19531 323.3794 66.39697 Q 323.87012 65.59863 324.08984 64.57324 L 328.01563 64.57324 Q 327.75195 66.331055 326.9536 67.75928 Q 326.15527 69.1875 324.91748 70.21289 Q 323.6797 71.23828 322.09033 71.79492 Q 320.50098 72.35156 318.64063 72.35156 Z M 330.25488 72 L 330.25488 56.428715 L 333.8291 56.428715 L 333.8291 59.021484 L 333.8877 59.021484 Q 334.3125 57.673832 335.25 56.9707 Q 336.1875 56.26758 337.667 56.26758 Q 338.0332 56.26758 338.34814 56.28955 Q 338.6631 56.31152 338.89746 56.340816 L 338.89746 59.63672 Q 338.69238 59.607426 338.187 59.56348 Q 337.68164 59.519527 337.13965 59.519527 Q 336.26074 59.519527 335.52832 59.922367 Q 334.7959 60.325195 334.37842 61.13086 Q 333.96094 61.936523 333.96094 63.137695 L 333.96094 72 Z M 344.9746 72.26367 Q 343.43652 72.26367 342.2207 71.74365 Q 341.00488 71.22363 340.30908 70.17627 Q 339.61328 69.12891 339.61328 67.59082 Q 339.61328 66.25781 340.11133 65.393555 Q 340.60938 64.5293 341.4663 64.00928 Q 342.32324 63.489258 343.42188 63.225586 Q 344.5205 62.961914 345.70703 62.83008 Q 347.1426 62.66894 347.9922 62.551758 Q 348.84177 62.43457 349.208 62.18555 Q 349.57422 61.936523 349.57422 61.409176 L 349.57422 61.291992 Q 349.57422 60.618164 349.23727 60.10547 Q 348.9004 59.592777 348.2705 59.299805 Q 347.64063 59.006836 346.74707 59.006836 Q 345.85352 59.006836 345.17236 59.292484 Q 344.4912 59.57813 344.08102 60.083496 Q 343.6709 60.588867 343.59766 61.2334 L 340.05273 61.160156 Q 340.18457 59.622074 341.0415 58.49414 Q 341.89844 57.36621 343.3706 56.743656 Q 344.84277 56.121094 346.8203 56.121094 Q 348.2852 56.121094 349.47165 56.47998 Q 350.6582 56.838867 351.5078 57.52002 Q 352.35742 58.201168 352.8042 59.17529 Q 353.25095 60.14941 353.25095 61.409176 L 353.25095 72 L 349.61816 72 L 349.61816 69.802734 L 349.55957 69.802734 Q 349.1934 70.49121 348.60013 71.04785 Q 348.00684 71.60449 347.1206 71.93408 Q 346.23438 72.26367 344.9746 72.26367 Z M 345.89746 69.509766 Q 347.09863 69.509766 347.92627 69.07031 Q 348.7539 68.63086 349.1787 67.89111 Q 349.60352 67.15137 349.60352 66.25781 L 349.60352 64.55859 Q 349.42776 64.66113 349.07614 64.770996 Q 348.72458 64.88086 348.25586 64.976074 Q 347.7871 65.07129 347.24512 65.15918 Q 346.70313 65.24707 346.16116 65.32031 Q 345.37015 65.4375 344.6963 65.69385 Q 344.02246 65.950195 343.62695 66.39697 Q 343.23145 66.84375 343.23145 67.546875 Q 343.23145 68.16211 343.56104 68.60156 Q 343.89063 69.041016 344.4912 69.27539 Q 345.0918 69.509766 345.89746 69.509766 Z M 364.41113 56.428715 L 364.41113 59.402344 L 354.68457 59.402344 L 354.68457 56.428715 Z M 357.49707 72 L 357.49707 54.246094 Line truncated
</g>
<g>
<path fill="#0b0b0c" fill-opacity="0.7" stroke="none" d="M 269.718 112.144 Q 268.953 112.144 268.422 111.739006 Q 267.891 111.334 267.6165 110.587006 Q 267.342 109.84 267.342 108.813995 Q 267.342 107.779 267.6165 107.032005 Q 267.891 106.285 268.422 105.8755 Q 268.953 105.465996 269.718 105.465996 Q 270.564 105.465996 271.158 106.0195 Q 271.752 106.573 271.95 107.56301 L 271.122 107.617 Q 270.969 106.924 270.5955 106.573 Q 270.222 106.222 269.718 106.222 Q 269.205 106.222 268.8585 106.52349 Q 268.512 106.825005 268.332 107.40551 Q 268.152 107.986 268.152 108.813995 Q 268.152 109.633 268.332 110.20901 Q 268.512 110.785 268.8585 111.0865 Q 269.205 111.38799 269.718 111.38799 Q 270.276 111.38799 270.663 111.001 Q 271.05 110.614 271.185 109.849 L 272.004 109.894 Q 271.833 110.947 271.23 111.545494 Q 270.627 112.144 269.718 112.144 Z M 273.21 112 L 273.21 111.334 L 274.82098 111.334 L 274.452 111.694 L 274.452 107.527 L 274.82098 107.896 L 273.21 107.896 L 273.21 107.23 L 275.082 107.23 L 275.154 108.508 L 275.082 108.454 Q 275.154 107.83299 275.46 107.5315 Q 275.766 107.23 276.279 107.23 L 277.25998 107.23 L 277.25998 107.914 L 276.288 107.914 Q 275.93698 107.914 275.6985 108.0535 Q 275.46 108.193 275.33398 108.4585 Q 275.208 108.724 275.208 109.12 L 275.208 111.694 L 274.848 111.334 L 276.72 111.334 L 276.72 112 Z M 279.87 112.108 Q 279.41998 112.108 279.06 111.94599 Q 278.69998 111.784 278.49298 111.4915 Q 278.28598 111.199 278.28598 110.812 Q 278.28598 110.19999 278.65048 109.8625 Q 279.01498 109.525 279.79797 109.37201 L 281.4 109.04799 Q 281.4 108.436 281.1255 108.139 Q 280.85098 107.84201 280.33798 107.84201 Q 279.843 107.84201 279.564 108.06251 Q 279.28497 108.28299 279.177 108.697 L 278.37598 108.634 Q 278.511 107.959 279.0195 107.5405 Q 279.52798 107.122 280.33798 107.122 Q 281.202 107.122 281.679 107.6395 Q 282.15598 108.157 282.15598 109.066 L 282.15598 111.046 Q 282.15598 111.208 282.228 111.271 Q 282.3 111.334 282.444 111.334 L 282.75 111.334 L 282.75 112 Q 282.69598 112.009 282.57898 112.0135 Q 282.46198 112.018005 282.35397 112.018005 Q 282.04797 112.018005 281.8365 111.919 Q 281.625 111.82 281.517 111.5995 Q 281.409 111.379 281.4 111.028 L 281.54398 111.028 Q 281.481 111.334 281.2425 111.581505 Q 281.004 111.829 280.64398 111.9685 Q 280.284 112.108 279.87 112.108 Z M 279.942 111.442 Q 280.42798 111.442 280.75198 111.2755 Q 281.076 111.10899 281.23798 110.8075 Q 281.4 110.506 281.4 110.11 L 281.4 109.696 L 279.942 109.984 Q 279.456 110.074 279.267 110.25851 Q 279.078 110.443 279.078 110.74 Q 279.078 111.07299 279.3075 111.2575 Q 279.537 111.442 279.942 111.442 Z M 285.108 112 L 285.108 106.924 Q 285.108 106.285 285.43652 105.9475 Q 285.765 105.61001 286.44 105.61001 L 287.97 105.61001 L 287.97 106.276 L 286.44 106.276 Q 286.161 106.276 286.0125 106.4515 Q 285.864 106.627 285.864 106.924 L 285.864 112 Z M 283.83002 107.896 L 283.83002 107.23 L 287.88 107.23 L 287.88 107.896 Z M 292.002 112 Q 291.30902 112 290.967 111.67601 Q 290.625 111.35199 290.625 110.68599 L 290.625 106.114 L 291.381 106.114 L 291.381 110.68599 Q 291.381 111.019 291.534 111.1765 Q 291.687 111.334 292.002 111.334 L 293.28 111.334 L 293.28 112 Z M 289.05002 107.896 L 289.05002 107.23 L 293.28 107.23 L 293.28 107.896 Z M 299.634 112 L 301.578 105.61001 L 302.62198 105.61001 L 304.56598 112 L 303.738 112 L 302.1 106.312 L 300.462 112 Z M 300.615 110.182 L 300.88498 109.426 L 303.315 109.426 L 303.585 110.182 Z M 305.52 113.35 L 305.52 107.23 L 306.222 107.23 L 306.24 108.31 L 306.132 108.256 Q 306.31198 107.698006 306.7305 107.41 Q 307.149 107.122 307.698 107.122 Q 308.409 107.122 308.8635 107.473 Q 309.318 107.824 309.534 108.39099 Q 309.75 108.958 309.75 109.615 Q 309.75 110.272 309.534 110.839 Q 309.318 111.406 308.8635 111.757 Q 308.409 112.108 307.698 112.108 Q 307.32898 112.108 307.0095 111.981995 Q 306.69 111.856 306.46948 111.631004 Q 306.249 111.406 306.168 111.1 L 306.276 110.97401 L 306.276 113.35 Z M 307.62598 111.38799 Q 308.24698 111.38799 308.60248 110.92 Q 308.95798 110.451996 308.95798 109.615 Q 308.95798 108.77801 308.60248 108.31 Q 308.24698 107.84201 307.62598 107.84201 Q 307.21198 107.84201 306.9105 108.04 Q 306.60898 108.238 306.44247 108.634 Q 306.276 109.03 306.276 109.615 Q 306.276 110.19999 306.438 110.596 Q 306.59998 110.992 306.90598 111.19 Q 307.21198 111.38799 307.62598 111.38799 Z M 310.92 113.35 L 310.92 107.23 L 311.622 107.23 L 311.64 108.31 L 311.532 108.256 Q 311.712 107.698006 312.13052 107.41 Q 312.549 107.122 313.09802 107.122 Q 313.80902 107.122 314.26352 107.473 Q 314.71802 107.824 314.93402 108.39099 Q 315.15002 108.958 315.15002 109.615 Q 315.15002 110.272 314.93402 110.839 Q 314.71802 111.406 314.26352 111.757 Q 313.80902 112.108 313.09802 112.108 Q 312.729 112.108 312.40952 111.981995 Q 312.09003 111.856 311.8695 111.631004 Q 311.64902 111.406 311.56802 111.1 L 311.67603 110.97401 L 311.67603 113.35 Z M 313.026 111.38799 Q 313.647 111.38799 314.0025 110.92 Q 314.358 110.451Line truncated
</g>
<g>
<path fill="#0b0b0c" fill-opacity="0.65" stroke="none" d="M 270.48584 358 L 268.50928 358 L 268.50928 356.92578 L 270.4214 356.92578 Q 271.3291 356.92578 271.93066 356.5874 Q 272.53223 356.24902 272.833 355.59103 Q 273.1338 354.9331 273.1338 353.9878 Q 273.1338 353.04785 272.8357 352.39798 Q 272.5376 351.74805 271.94946 351.4097 Q 271.36133 351.07126 270.48584 351.07126 L 268.4663 351.07126 L 268.4663 349.99707 L 270.5664 349.99707 Q 271.74805 349.99707 272.5913 350.4751 Q 273.43457 350.95316 273.88574 351.8474 Q 274.3369 352.7417 274.3369 353.9878 Q 274.3369 355.23923 273.88306 356.13623 Q 273.4292 357.0332 272.56714 357.5166 Q 271.70508 358 270.48584 358 Z M 269.11084 349.99707 L 269.11084 358 L 267.88623 358 L 267.88623 349.99707 Z M 277.3247 358 L 277.3247 349.99707 L 280.19287 349.99707 Q 281.1167 349.99707 281.73438 350.31668 Q 282.35205 350.63623 282.6582 351.20557 Q 282.96436 351.77487 282.96436 352.51074 Q 282.96436 353.25195 282.65552 353.80786 Q 282.34668 354.36377 281.729 354.66992 Q 281.11133 354.97607 280.18213 354.97607 L 278.02832 354.97607 L 278.02832 353.9287 L 280.06396 353.9287 Q 280.6548 353.9287 281.01733 353.75952 Q 281.37988 353.5903 281.55176 353.27344 Q 281.72363 352.95654 281.72363 352.51074 Q 281.72363 352.05957 281.55176 351.7319 Q 281.37988 351.40427 281.01465 351.2244 Q 280.6494 351.04443 280.05322 351.04443 L 278.54932 351.04443 L 278.54932 358 Z M 281.87402 358 L 279.93506 354.38525 L 281.29395 354.38525 L 283.26514 358 Z M 285.33984 358 L 288.18652 349.99707 L 289.69043 349.99707 L 292.59082 358 L 291.26416 358 L 289.70654 353.54733 Q 289.52393 353.00488 289.3064 352.269 Q 289.08887 351.53323 288.8042 350.50198 L 289.05127 350.50198 Q 288.77734 351.54395 288.5598 352.2878 Q 288.3423 353.03174 288.17578 353.54733 L 286.6665 358 Z M 286.88135 355.88916 L 286.88135 354.86862 L 291.04395 354.86862 L 291.04395 355.88916 Z M 298.21045 358.10742 Q 297.1255 358.10742 296.3064 357.6079 Q 295.4873 357.1084 295.02808 356.18726 Q 294.56885 355.2661 294.56885 354.00388 Q 294.56885 352.72562 295.03345 351.80444 Q 295.49805 350.8833 296.30908 350.38647 Q 297.12012 349.88965 298.15674 349.88965 Q 298.80127 349.88965 299.36255 350.08032 Q 299.92383 350.271 300.36694 350.6228 Q 300.81006 350.9746 301.0974 351.4607 Q 301.38477 351.9468 301.48145 352.52686 L 300.2246 352.52686 Q 300.1172 352.17773 299.9292 351.89575 Q 299.7412 351.6138 299.4807 351.41504 Q 299.2202 351.2163 298.8899 351.11157 Q 298.55957 351.00684 298.16748 351.00684 Q 297.49072 351.00684 296.95093 351.3506 Q 296.41113 351.6943 296.09692 352.36304 Q 295.7827 353.03174 295.7827 353.99854 Q 295.7827 354.96533 296.09692 355.63135 Q 296.41113 356.29736 296.95898 356.6438 Q 297.50684 356.99023 298.21582 356.99023 Q 298.86035 356.99023 299.34106 356.72974 Q 299.82178 356.46924 300.08765 355.98853 Q 300.35352 355.5078 300.35352 354.85254 L 300.6704 354.90625 L 298.39307 354.90625 L 298.39307 353.89648 L 301.55127 353.89648 L 301.55127 354.81494 Q 301.55127 355.8247 301.1189 356.56323 Q 300.68652 357.30173 299.93457 357.7046 Q 299.18262 358.10742 298.21045 358.10742 Z M 312.33936 358.10742 Q 311.30273 358.10742 310.48364 357.616 Q 309.66455 357.1245 309.19458 356.20337 Q 308.7246 355.2822 308.7246 354.00388 Q 308.7246 352.7202 309.19458 351.7991 Q 309.66455 350.87793 310.48364 350.3838 Q 311.30273 349.88965 312.33936 349.88965 Q 313.37598 349.88965 314.1897 350.3838 Q 315.00342 350.87793 315.4734 351.7991 Q 315.94336 352.7202 315.94336 354.00388 Q 315.94336 355.2822 315.4734 356.20337 Q 315.00342 357.1245 314.1897 357.616 Q 313.37598 358.10742 312.33936 358.10742 Z M 312.33936 356.99023 Q 313.02148 356.99023 313.56128 356.64917 Q 314.10107 356.3081 314.41528 355.6394 Q 314.7295 354.9707 314.7295 354.00388 Q 314.7295 353.02637 314.41528 352.35767 Q 314.10107 351.68896 313.56128 351.34793 Q 313.02148 351.00684 312.33936 351.00684 Q 311.65186 351.00684 311.10938 351.3506 Q 310.5669 351.6943 310.2527 352.36304 Q 309.93848 353.03174 309.93848 354.00388 Q 309.93848 354.9707 310.2527 355.63672 Q 310.5669 356.30273 311.10938 356.6465 Q 311.65186 356.99023 312.33936 356.99023 Z M 318.93652 358 L 318.93652 349.99707 L 320.34912 349.99707 L 323.3623 354.76123 Q 323.5127 354.9976 323.70068 355.32523 Q 323.88867 355.6528 324.09814 356.06372 Q 324.30762 356.47458 324.51172 356.958 L 324.36133 356.958 Q 324.313 356.50146 324.28882 356.05298 Q 324.26465 355.6045 324.2539 355.2124 Q 324.24316 354.8203 324.24316 354.55176 L 324.24316 349.99707 L 325.4839 349.99707 L 325.4839 358 L 324.06592 358 L 321.36426 353.74072 Q 321.1494 353.3916 320.94263 353.0371 Q 320.73584 352.6826 320.48608 352.21802 Q 320.23633 351.75342 319.87646 351.0874 L 320.06982 351.0874 Q 320.09668 351.67822 320.12354 352.19116 Q 320.1504 352.7041 320.16382 353.09888 Q 320.17725 353.49365 320.17725 353.72998 L 320.17725 358 Z M 328.35352 351.0498 L 328.35352 349.99707 L 334.56787 349.99707 L 334.56787 351.0498 L 332.08105 351.0498 L 332.08105 358 L 330.85107 358 L 330.85Line truncated
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.
Binary file not shown.
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env python3
"""Regenerate background.tiff and dmg-layout.DS_Store for the macOS DMG window (see README.md here).
Needs resvg on PATH and:
pip install 'pillow>=12' ds_store==1.3.3 mac_alias==2.2.3
Runs on any OS, no Mac needed. Both outputs come from this script alone, so they carry nothing
from the machine that made them: no local paths, user or disk names, dates or volume UUIDs.
python3 packaging/macos/dmg/generate.py
"""
import datetime
import os
import shutil
import subprocess
import sys
import tempfile
from ds_store import DSStore
from mac_alias import Alias, TargetInfo, VolumeInfo
from PIL import Image, ImageCms
HERE = os.path.dirname(os.path.abspath(__file__))
# Keep in sync with package.sh (volume name) and README.md (layout).
VOLUME = "ArtCraft Launcher"
WIDTH, HEIGHT = 660, 400 # window content, pt; the background's 1x size
TITLE_BAR = 32
ICON_SIZE = 128
PALETTE = 16 # colours in background.tiff
ICONS = {"ArtCraft Launcher.app": (326, 205), "Applications": (574, 205)}
# A fixed date for the alias and the colour profile. Finder never matches it against the image
# (each build is a new volume): it finds the background by volume name and path.
FIXED_DATE = datetime.datetime(2026, 10, 11, tzinfo=datetime.timezone.utc)
def render(svg, width, out):
# The SVG's text is already outlined and no fonts are loaded, so every machine draws the
# same pixels.
subprocess.run(
["resvg", "--skip-system-fonts", "-w", str(width), svg, out],
check=True,
)
with Image.open(out) as im:
return im.convert("RGB")
def quantize(im):
# No dithering: dither noise compresses badly and the artwork has flat fills.
return im.quantize(PALETTE, method=Image.Quantize.FASTOCTREE, dither=Image.Dither.NONE)
def srgb_profile():
# LittleCMS's built-in sRGB ("No copyright, use freely"), with its creation date fixed.
icc = bytearray(ImageCms.ImageCmsProfile(ImageCms.createProfile("sRGB")).tobytes())
d = FIXED_DATE
icc[24:36] = b"".join(v.to_bytes(2, "big") for v in (d.year, d.month, d.day, 0, 0, 0))
if icc[84:100] != bytes(16):
sys.exit("error: the sRGB profile has an ID; recompute it after fixing the date")
return bytes(icc)
def write_tiff(svg, out):
"""1x (72 dpi) and 2x (144 dpi) in one TIFF, as `tiffutil -cathidpicheck` writes them."""
with tempfile.TemporaryDirectory() as tmp:
one, two = (
quantize(render(svg, WIDTH * s, os.path.join(tmp, f"{s}x.png"))) for s in (1, 2)
)
two.encoderinfo = {"dpi": (144, 144)} # the rest as the first page
# Opaque 16-colour palette, Deflate: the artwork is three inks plus antialiasing, and a palette
# keeps the file under ~100 KB (repository assets must stay small). As PhotoCraft's.
one.save(
out,
"TIFF",
save_all=True,
append_images=[two],
compression="tiff_adobe_deflate", # brand-ok: Pillow's name for TIFF compression 8 (Deflate)
dpi=(72, 72),
icc_profile=srgb_profile(),
)
with Image.open(out) as im:
im.seek(1)
if im.info.get("dpi") != (144, 144):
sys.exit("error: the 2x page isn't 144 dpi (needs Pillow 12 or later)")
strips = []
for page in (0, 1):
im.seek(page)
strips += zip(im.tag_v2[273], im.tag_v2[279]) # StripOffsets, StripByteCounts
# TIFF keeps directories and tag data on even offsets. When a strip ends on an odd one, libtiff
# leaves the padding byte after it uninitialised, so the file changed from run to run with
# identical pixels. Zero it: nothing refers to that byte.
starts = {o for o, _ in strips}
with open(out, "r+b") as f:
for o, n in strips:
if (o + n) % 2 and o + n not in starts:
f.seek(o + n)
f.write(bytes(1))
def background_alias():
# What Finder stores for /.background/background.tiff on the volume, minus anything about the
# machine (dmgbuild writes the same kind of alias from a mounted image). Disk type 5 (ejectable),
# the volume flags and the folder/file ids (25, 26) are what Finder wrote on a test image; the ids
# are only hints, as the volume's creation date.
volume = VolumeInfo(VOLUME, FIXED_DATE, b"H+", 5, 0x0D02, b"\0\0")
volume.posix_path = f"/Volumes/{VOLUME}"
target = TargetInfo(0, "background.tiff", 25, 26, FIXED_DATE, b"\0\0\0\0", b"\0\0\0\0")
target.folder_name = ".background"
target.cnid_path = [25]
target.carbon_path = f"{VOLUME}:.background:\0background.tiff"
target.posix_path = "/.background/background.tiff"
return Alias(volume=volume, target=target).to_bytes()
def write_ds_store(out):
if os.path.exists(out):
os.remove(out)
with DSStore.open(out, "w+") as d:
d["."]["bwsp"] = {
"ShowStatusBar": False,
"ShowToolbar": False,
"ShowTabView": False,
"ContainerShowSidebar": False,
"ShowSidebar": False,
"WindowBounds": f"{{{{200, 528}}, {{{WIDTH}, {HEIGHT + TITLE_BAR}}}}}",
}
d["."]["icvp"] = {
"viewOptionsVersion": 1,
"backgroundType": 2,
"backgroundImageAlias": background_alias(),
"backgroundColorRed": 1.0,
"backgroundColorGreen": 1.0,
"backgroundColorBlue": 1.0,
"gridOffsetX": 0.0,
"gridOffsetY": 0.0,
"gridSpacing": 100.0,
"arrangeBy": "none",
"showIconPreview": True,
"showItemInfo": False,
"labelOnBottom": True,
"textSize": 16.0,
"iconSize": float(ICON_SIZE),
}
d["."]["vSrn"] = ("long", 1)
for name, pos in ICONS.items():
d[name]["Iloc"] = pos
def main():
if not shutil.which("resvg"):
sys.exit("error: resvg not found (brew install resvg / cargo install resvg --locked)")
write_tiff(os.path.join(HERE, "background.svg"), os.path.join(HERE, "background.tiff"))
write_ds_store(os.path.join(HERE, "dmg-layout.DS_Store"))
if __name__ == "__main__":
main()
+29 -6
View File
@@ -113,6 +113,10 @@ if /usr/libexec/PlistBuddy -c 'Print :LSUIElement' "$APP/Contents/Info.plist" >/
fi
printf 'APPL????' >"$APP/Contents/PkgInfo"
# Drop extended attributes picked up during the build (quarantine, FinderInfo) before signing:
# `codesign --verify --strict` rejects them, and nothing after signing may touch the bundle.
xattr -cr "$APP"
# Sign inside-out: nested code first, then the bundle itself (no --deep on the final signature).
# Today the only nested code is the main executable; frameworks/helpers would be signed here too.
sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP/Contents/MacOS/$APP_NAME"
@@ -133,12 +137,31 @@ STAGE="$WORK/dmg"
mkdir -p "$STAGE"
ditto "$APP" "$STAGE/$APP_NAME.app"
ln -s /Applications "$STAGE/Applications"
rm -f "$DMG" "$WORK/raw.dmg"
# makehybrid + convert builds the image without attaching a device, unlike `create -srcfolder`,
# which is flaky on CI runners ("Resource busy") and hangs in sandboxed sessions.
hdiutil makehybrid -hfs -hfs-volume-name "$APP_NAME $VERSION" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE"
hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG"
rm -f "$WORK/raw.dmg"
# Finder window layout: background, icon size and positions (packaging/macos/dmg/README.md).
mkdir -p "$STAGE/.background"
cp "$HERE/dmg/background.tiff" "$STAGE/.background/background.tiff"
cp "$HERE/dmg/dmg-layout.DS_Store" "$STAGE/.DS_Store"
rm -f "$DMG"
# `hdiutil create -srcfolder` writes the files as they are. `makehybrid -hfs` gave every file a
# com.apple.FinderInfo, which `codesign --verify --strict` rejects as "Finder information, or
# similar detritus" on the app in the image and on a copy installed from it. `create` attaches a
# device while it copies, which fails now and then on CI runners ("Resource busy"), so it gets a
# few tries. verify.sh checks the app inside the finished image with --strict. The volume name has
# no version: .DS_Store finds the background through an alias that includes it.
# Usage: hdiutil_retry <what> <hdiutil args…>; tries five times with growing pauses.
hdiutil_retry() {
local what="$1" attempt
shift
for attempt in 1 2 3 4 5; do
if hdiutil "$@"; then
return 0
fi
[ "$attempt" = 5 ] && { echo "hdiutil $what failed 5 times" >&2; return 1; }
warn "hdiutil $what failed (attempt $attempt of 5); retrying in $((attempt * 10))s"
sleep $((attempt * 10))
done
}
hdiutil_retry create create -srcfolder "$STAGE" -volname "$APP_NAME" -fs HFS+ -format UDZO -imagekey zlib-level=9 -ov "$DMG"
sign "$DMG"
codesign --verify --strict --verbose=2 "$DMG"
if [ "$NOTARIZE" = 1 ]; then
+7 -4
View File
@@ -112,10 +112,13 @@ if /usr/libexec/PlistBuddy -c 'Print :LSUIElement' "$APP/Contents/Info.plist" >/
exit 1
fi
# Integrity first: this must hold even for ad-hoc builds. Not --strict here: files read back from
# the DMG's HFS volume (hdiutil makehybrid) report a com.apple.FinderInfo attribute, which strict
# mode calls "detritus". package.sh already ran the strict check on the bundle before imaging it.
codesign --verify --deep --verbose=2 "$APP"
# Integrity first: this must hold even for ad-hoc builds, and with --strict, on the app in the
# image and on a copy installed from it with ditto. A valid DMG signature says nothing about the
# bundle inside: the old makehybrid image gave every file a com.apple.FinderInfo, which strict
# mode rejects as "detritus".
codesign --verify --strict --deep --verbose=2 "$APP"
ditto "$APP" "$WORK/copy/$APP_NAME.app"
codesign --verify --strict --deep --verbose=2 "$WORK/copy/$APP_NAME.app"
details="$(codesign -d --verbose=2 "$APP" 2>&1)"
printf '%s\n' "$details" | grep -E '^(Identifier|Format|CodeDirectory|Authority|Timestamp|TeamIdentifier)[= ]' || true
+4 -4
View File
@@ -111,7 +111,7 @@ function New-InstallerArt([string] $BannerOut, [string] $DialogOut) {
$line = [System.Drawing.Color]::FromArgb(38, 242, 241, 238)
# The Craft App Suite, in the launcher's order (crates/core/src/catalog.rs).
$apps = 'photocraft', 'vectorcraft', 'filmcraft', 'lightcraft', 'pdfcraft', 'effectcraft',
'designcraft', 'wordcraft', 'deckcraft', 'gridcraft', 'cadcraft', 'soundcraft'
'designcraft', 'wordcraft', 'deckcraft', 'gridcraft', 'cadcraft', 'soundcraft', 'figcraft'
$mark = [System.Drawing.Image]::FromFile((Join-Path $Root 'assets\app-icon\hicolor\256x256\apps\ai.storyteller.artcraft-launcher.png'))
$wordmark = [System.Drawing.Image]::FromFile((Join-Path $Root 'docs\brand\artcraft-logo-white.png'))
$icons = $apps | ForEach-Object { [System.Drawing.Image]::FromFile((Join-Path $Root "assets\app-icons\$_.png")) }
@@ -148,14 +148,14 @@ function New-InstallerArt([string] $BannerOut, [string] $DialogOut) {
$x += $g.MeasureString([string]$ch, $labelFont, 100, [System.Drawing.StringFormat]::GenericTypographic).Width + 1.35
}
$brush.Dispose()
# The apps, three to a row, in 40 px cells divided by hairlines.
# The apps, three to a row, in 36 px cells divided by hairlines (five rows fit above 312).
$pen = New-Object System.Drawing.Pen $line, 1
$left = 22; $top = 126; $cell = 40
$left = 22; $top = 118; $cell = 36
for ($i = 0; $i -lt $icons.Count; $i++) {
$cx = $left + ($i % 3) * $cell
$cy = $top + [math]::Floor($i / 3) * $cell
$g.DrawRectangle($pen, $cx, $cy, $cell, $cell)
$g.DrawImage($icons[$i], $cx + 6, $cy + 6, $cell - 12, $cell - 12)
$g.DrawImage($icons[$i], $cx + 5, $cy + 5, $cell - 10, $cell - 10)
}
$pen.Dispose()
}
+1
View File
@@ -31,6 +31,7 @@ const APPS: &[(&str, &str)] = &[
("gridcraft", "GridCraft"),
("cadcraft", "CADCraft"),
("soundcraft", "SoundCraft"),
("figcraft", "FigCraft"),
];
const DEFAULT_PORT: u16 = 8787;