Compare commits

...

16 Commits

Author SHA1 Message Date
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 Thomas 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 <noreply@anthropic.com>

* Release 0.4.1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:28:29 -04:00
Brandon Thomas d6fdab6596
Release 0.4.0 (#291)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 06:29:23 -04:00
Brandon Thomas 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 <noreply@anthropic.com>
2026-10-12 05:41:18 -04:00
Brandon Thomas 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 <noreply@anthropic.com>
2026-10-12 05:02:23 -04:00
Alexis 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 <echelon@gmail.com>
2026-10-12 05:00:22 -04:00
Fabio Lussi 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 04:26:54 -04:00
Abdelali Bourassine 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 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 02:50:10 -04:00
Jeremy Yao 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 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 <82441193+DzikStar@users.noreply.github.com>
Co-authored-by: ferivoq <ferikegameplay@gmail.com>
Co-authored-by: Losminek <301412321+Losminek@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: leonaci <leonacism@gmail.com>
Co-authored-by: satchmo91 <eherrmann1991@gmail.com>
Co-authored-by: Vikram Bhaskaran <vikram.bhaskaran@outlook.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 02:49:59 -04:00
Vikram Bhaskaran 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 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 02:49:47 -04:00
satchmo91 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 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 <82441193+DzikStar@users.noreply.github.com>
Co-authored-by: ferivoq <ferikegameplay@gmail.com>
Co-authored-by: Losminek <301412321+Losminek@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: leonaci <leonacism@gmail.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 02:48:47 -04:00
leonaci 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 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 <82441193+DzikStar@users.noreply.github.com>
Co-authored-by: ferivoq <ferikegameplay@gmail.com>
Co-authored-by: Losminek <301412321+Losminek@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 02:48:34 -04:00
FeriVOQ 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 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 <82441193+DzikStar@users.noreply.github.com>
Co-authored-by: Losminek <301412321+Losminek@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 02:48:21 -04:00
DzikStar 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Losminek <301412321+Losminek@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 02:48:08 -04:00
Daniel Lošák 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 <noreply@anthropic.com>

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Losminek <301412321+Losminek@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 02:45:12 -04:00
Brandon Thomas 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 <noreply@anthropic.com>
2026-10-12 02:02:14 -04:00
66 changed files with 5451 additions and 258 deletions

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

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

16
Cargo.lock generated
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"

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"

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">
@ -84,21 +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 (Русский), 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, 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.
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. 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
@ -244,6 +247,7 @@ stand on its own.
| <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.

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"] }

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;

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"] }

View File

@ -6,9 +6,12 @@
#![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;
@ -74,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;
@ -149,12 +161,19 @@ fn native_options(hidden: bool) -> eframe::NativeOptions {
/// 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. 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, Dx12Compiler, 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 {
@ -206,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()
}
@ -382,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;
}
@ -390,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,
@ -417,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 },
@ -648,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);
}
@ -944,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);
}
}

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]));
}
}

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();
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

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.
//!
@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize};
pub enum AppGroup {
/// The seven original apps.
Classic,
/// The five apps added after them.
/// The six apps added after them.
New,
}
@ -94,8 +94,8 @@ const fn app(
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",
@ -196,6 +196,14 @@ pub const SUITE: [CraftApp; 12] = [
[[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",
),
];
/// ArtCraft's website.
@ -239,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();

483
crates/core/src/details.rs Normal file
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);
}
}
}

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;

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]

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.

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() };
@ -858,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 {
@ -906,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);
@ -1006,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()),
}
}

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),
}
}

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)))?;
@ -681,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.

View File

@ -746,6 +746,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,
})
}

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;

View File

@ -46,6 +46,12 @@ 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")
}

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());
}
}

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"] }

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) {}
}
@ -104,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,
@ -122,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
@ -138,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();
@ -160,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,
@ -171,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
@ -282,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;
@ -376,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 };
@ -413,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)) }
@ -444,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 {
@ -459,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));
@ -501,6 +557,7 @@ impl LauncherUi {
ui.add_space(16.0);
});
});
self.details_drawer(&ctx);
self.dialogs(&ctx, host);
self.draw_toasts(&ctx);
if host.title_bar() == TitleBar::Custom {
@ -516,14 +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))
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;
@ -590,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) {

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());
}
}
}
}

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()
}

View File

@ -178,28 +178,76 @@ impl LauncherUi {
}
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));
}
});
});
@ -207,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());
@ -245,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)"),
@ -290,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));
@ -301,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);
});
@ -313,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
}
}

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`.

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í
Can't render this file because it has a wrong number of fields in line 8.

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.
@ -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.

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", "Finder", "makers".
# 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}.
@ -117,6 +117,10 @@
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
@ -161,6 +165,7 @@ 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.
@ -196,6 +201,7 @@ month Dec dic.
{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
@ -245,6 +251,20 @@ 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

Can't render this file because it contains an unexpected character in line 5 and column 48.

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.
@ -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.

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.

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.

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, German and Spanish: 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; 7] = [
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; 7] = [
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; 7] = [
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; 7] = [
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; 7] = [
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,7 +154,7 @@ pub static LANGUAGES: [LangInfo; 7] = [
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,
@ -127,13 +165,57 @@ pub static LANGUAGES: [LangInfo; 7] = [
code: "es",
name: "Español",
english_name: "Spanish",
cjk: false,
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 {
@ -168,6 +250,10 @@ impl Lang {
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
@ -200,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?

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};

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.

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.
@ -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 has a wrong number of fields in line 9.

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.
@ -116,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
@ -164,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 янв.
@ -253,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} приложений
@ -320,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.

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"] {
@ -279,6 +335,14 @@ fn spanish_locales_and_plurals_work() {
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"] {
@ -302,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
@ -336,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.

View File

@ -174,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} 天前
@ -246,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…
@ -262,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 重新下载
@ -283,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.

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,
})
}

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;

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);
}
}
}

View File

@ -22,6 +22,8 @@ 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)]
@ -88,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,
@ -110,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 {
@ -117,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(),
@ -181,6 +185,7 @@ impl Default for Settings {
dismissed_notices: std::collections::BTreeSet::new(),
notified: BTreeMap::new(),
last_check: None,
hidden: std::collections::BTreeSet::new(),
}
}
}

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()
}

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;
@ -126,6 +127,9 @@ impl LauncherUi {
}
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()
@ -195,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 {
@ -215,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() });
}
@ -393,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.
@ -558,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)));
}
@ -648,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.
@ -731,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) {
@ -769,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;
@ -828,16 +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);
}
};
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.close();
}
});
}
}
@ -854,7 +906,7 @@ 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();
let inst_ver = e.installed.as_ref().and_then(|i| i.version.as_ref().map(ToString::to_string));
let latest = e.latest_label();

View File

@ -70,6 +70,17 @@ 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 / 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| {
ui.spacing_mut().item_spacing.x = 0.0;
hud(ui, index, t.faint);
hud(ui, " / ", t.faint);
hud(ui, label, t.muted);
});
}
/// A solid chip with a bold mono label: an app's number tab (the site's `AppTab`).
pub fn tag(ui: &mut Ui, text: &str, fill: Color32, ink: Color32) -> Response {
let galley = caps(ui, text, theme::HUD_SIZE, theme::mono_bold(), theme::HUD_SPACING, ink);
@ -300,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());

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;

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");
}

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);
}

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);
}

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,7 +172,19 @@ fn harness_with(
manage: bool,
scale: f32,
) -> (Harness<'static, Option<LauncherUi>>, Rc<RefCell<Vec<Command>>>) {
harness_with_title_bar(events, view, theme, manage, scale, artcraft_launcher_ui_egui::TitleBar::Native)
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(
@ -133,6 +194,18 @@ fn harness_with_title_bar(
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 {
@ -145,7 +218,7 @@ fn harness_with_title_bar(
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.
@ -181,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.
@ -390,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);
@ -528,8 +631,8 @@ fn links_to_the_website_github_and_each_apps_pages() {
// 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 >= 12, "{shown_cards}");
assert_eq!(shown_sites, 12, "every app has a website page");
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);
@ -594,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()
@ -657,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()
@ -737,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()
@ -784,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()
@ -812,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()
@ -839,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()
@ -882,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()
@ -907,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()
@ -989,3 +1364,135 @@ fn narrow_header_keeps_the_view_switch_clear_of_brand_text() {
}
}
}
/// 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");
}

View File

@ -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>

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

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).

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Binary file not shown.

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()

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

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

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()
}

View File

@ -31,6 +31,7 @@ const APPS: &[(&str, &str)] = &[
("gridcraft", "GridCraft"),
("cadcraft", "CADCraft"),
("soundcraft", "SoundCraft"),
("figcraft", "FigCraft"),
];
const DEFAULT_PORT: u16 = 8787;