Compare commits

..

214 commits

Author SHA1 Message Date
c9282706f0 Update README.md 2026-10-13 06:27:43 +00:00
2eff59c883 chore(config): track macos apple-darwin linker configuration 2026-10-13 11:46:49 +05:30
f3355bd180 docs: add download guide image to README 2026-10-11 12:59:33 +05:30
d28fd9ce46 docs: add download guide image for releases 2026-10-11 12:58:15 +05:30
e99da40629 docs: establish cracked release suffix progression and build rules in AGENTS.md 2026-10-11 09:23:56 +05:30
a8826b83d3 fix(skins): fix skin lookup across directories, reliable TCP flush, modern userProperties, and optimize compile time 2026-10-11 09:13:55 +05:30
e06775aae5 feat: update base to official upstream 2.8.0 with all cracked features intact (2.8.0-c1) 2026-10-10 20:21:19 +05:30
e4555c4b8e chore(build): optimize release profile with ThinLTO and remove unused import 2026-10-10 20:20:46 +05:30
0a29f0ab47 feat(ui): port 3D skins tab, offline onboarding and settings, and Forgejo auto-updater to 2.8.0 2026-10-10 18:49:26 +05:30
8d7939c041 feat(skins): integrate proper isolated offline skin system via Authlib-Injector 2026-10-10 18:29:12 +05:30
1e66c9216c feat(auth): enable offline mode accounts and launch without Microsoft account 2026-10-10 18:23:10 +05:30
b979feff0c chore: configure 2.8.0-c1 metadata, packaging, and release workflows 2026-10-10 18:19:17 +05:30
8652be8b87 feat(updater): switch auto-updater and documentation to Forgejo releases API 2026-10-10 18:11:33 +05:30
6793d6f68f chore: ignore dist directory 2026-10-10 15:22:21 +05:30
40838abf2d ci: add native forgejo release workflow with linux packaging and direct asset publishing 2026-10-10 14:06:05 +05:30
LynithDev
1d6ec59825 chore: pin freya to Polyfrost fork on 0.5.0-beta.10 without libxdo 2026-10-10 02:00:17 +02:00
github-actions[bot]
2ae679d220 chore: release 2.8.0 2026-10-09 23:26:24 +00:00
Maciek
56d73e3ab1
feat: multiple minecraft instances (#955)
* feat: mutliple minecraft instances (only dedicated clusters)

* changeset
2026-10-10 01:24:25 +02:00
LynithDev
011c4416b1 fix: open folder freeze issue 2026-10-10 01:16:07 +02:00
Maciek
76e4cac6f4
fix: fixed downloading notification alerting about new notification on every byte read (#952) 2026-10-10 01:10:05 +02:00
Maciek
6ee7bdb443
feat: added status bar for auth states (#954) 2026-10-10 01:02:39 +02:00
Maciek
97d4477c69
fix: added scrollbar gutter to the notifications overlay (#947) 2026-10-10 01:01:51 +02:00
LynithDev
51cb12c964
fix: bundled package regression causing it to disable and not update (#967) 2026-10-10 01:01:30 +02:00
LynithDev
a976a66c8b fix: nightly executable uploads 2026-10-10 01:00:46 +02:00
LynithDev
2f5a64b97d
Fix/linux skia crashes (#966)
* fix: run hooks before early returns in cluster and gallery views

* chore: upgrade freya to 0.5.0-beta.9

* chore: pin freya to 0.5.0-beta.10 branch for skia 0.103.2
2026-10-10 00:58:42 +02:00
Maciek
c6361c7266
fix: fixed shield.io badges not rendering properly (#953)
* fix: fixed shield.io badges not rendering properly

* changeset
2026-10-10 00:55:21 +02:00
Maciek
61d24b688e
fix: added better errors for mclogs (#963)
* fix: added better errors for mclogs

* chore: added a warning when the log is shortened
2026-10-10 00:54:49 +02:00
Maciek
d21c0dc22b
feat: added filter for loaders in browser view (#946)
* feat: added filter for loaders in browser view

* changeset
2026-10-10 00:54:45 +02:00
2b1bfd1485 docs: add offline usage warning embed to README
Some checks are pending
OneClient Nightly (Unsigned) / build (aarch64, app,dmg, darwin, macos-latest, aarch64-apple-darwin) (push) Waiting to run
OneClient Nightly (Unsigned) / build (x86_64, app,dmg, 10.14, darwin, macos-latest, x86_64-apple-darwin) (push) Waiting to run
OneClient Nightly (Unsigned) / build (x86_64, deb,appimage, linux, ubuntu-22.04, ) (push) Waiting to run
OneClient Nightly (Unsigned) / build (x86_64, nsis, windows, windows-latest, ) (push) Waiting to run
2026-10-09 22:23:58 +05:30
Julian Chang
5a8728ef04
Retroactive fix for 1ab294f2 2026-10-09 00:01:40 -04:00
Julian Chang
1b633adcf2
fix bundle newer mods being overwritten by local mods 2026-10-08 03:11:19 -04:00
Julian Chang
66b4ab4e64
update disabled mods still 2026-10-08 00:27:31 -04:00
github-actions[bot]
0f21171fe2 chore: release 2.7.0 2026-10-05 21:22:43 +00:00
Julian Chang
d073e10a3b
Add custom game args to settings (#905) 2026-10-05 23:21:05 +02:00
LynithDev
e7ddd85960
fix: url opening, dgpu dropdown instead of switcher, invisible icons (#941) 2026-10-05 23:20:32 +02:00
Julian Chang
abf708fdb4
mods folder sync (#937) 2026-10-05 23:20:04 +02:00
Julian Chang
1ab294f293
fix opt-in bundles not being asked for new mc versions (#939) 2026-10-05 23:19:49 +02:00
LynithDev
7683651ad4 chore: make nightly release action upload portable executables 2026-10-05 22:10:24 +02:00
LynithDev
a9fab65a0b
chore: add tooltip on settings system info (#926)
* chore: add tooltip on settings system info

* chore: add changeset
2026-10-05 22:06:40 +02:00
LynithDev
c765a3c2d4
feat: github packages can now be opened in browser (#943) 2026-10-05 21:03:29 +02:00
LynithDev
0621da9db0 chore: update description for launcher animation setting 2026-10-05 20:58:05 +02:00
LynithDev
97852aea41
fix: mclogs notification showing repeatedly (#942) 2026-10-05 20:42:47 +02:00
Julian Chang
7a6a7cbc0b
Merge branch 'main' of https://github.com/Polyfrost/OneLauncher 2026-10-03 15:14:39 -04:00
Julian Chang
7c304a2d3a
fix: match os argument rules on apple silicon for old versions 2026-10-03 15:14:35 -04:00
LynithDev
b2384afb4e
feat: log censoring (#925) 2026-10-03 16:00:44 +02:00
LynithDev
c43272683b
feat: add code block and update the open link dialogs with the code block (#924) 2026-10-03 15:17:52 +02:00
LynithDev
cfcf7a7669
chore: new changelog generator (#855)
* chore: new changelog generator

* ci: require a change file on PRs
2026-10-03 00:23:41 +02:00
github-actions[bot]
a2a3e5b7b1 chore: bump to 2.6.1 2026-10-02 14:17:51 +00:00
Maciek
a3d90cc2ce
Merge pull request #908 from Polyfrost/fix/aur-distribution
aur distribution fix
2026-10-02 16:15:17 +02:00
rozwader
ee7c84901f stray commas 2026-10-02 16:14:37 +02:00
rozwader
ad6ca92519 fix 2026-10-02 15:38:05 +02:00
Julian Chang
fb80e6eb60
Merge branch 'main' of https://github.com/Polyfrost/OneLauncher 2026-10-02 04:42:04 -04:00
Julian Chang
a8ba800dba
Reapply "chore: bump to 2.6.0"
This reverts commit 17a99b671e.
2026-10-02 04:41:58 -04:00
Maciek
ed6b014aac
chore: deleted icons from stats views (#906) 2026-10-02 04:23:02 -04:00
Julian Chang
17a99b671e
Revert "chore: bump to 2.6.0"
This reverts commit e74eb60ba2.
2026-10-02 04:21:05 -04:00
Julian Chang
e2db158653
fix: refresh loader version info and metadata manifests (#904) 2026-10-02 03:09:17 -04:00
Julian Chang
80e7cf0e59
Merge branch 'main' of https://github.com/Polyfrost/OneLauncher 2026-10-02 01:43:04 -04:00
Julian Chang
538a0da24d
prompt for different mc versions when installing modpacks 2026-10-02 01:38:42 -04:00
Julian Chang
03c4412d15
bundle opt out per content type (#810)
* exclude opted out bundles from package list

* bundle opt out per content type

* list only the content types a bundle is still opted into

* count inferred bundle installs as opted in

* remove comments from merge

* key bundle opt-out by external id so outdated GitHub installs still count

* fix: keep bundle type opt-out across catalog changes

* fix: keep switched-on bundle files from being re-suppressed

* fix: share bundle liveness and keep package list read-only
2026-10-02 00:48:27 -04:00
Julian Chang
7402fb6e78
exclude opted out bundles from package list (#879)
* exclude opted out bundles from package list

* count inferred bundle installs as opted in

* let opted-in bundles own shared rows
2026-10-02 00:07:44 -04:00
github-actions[bot]
e74eb60ba2 chore: bump to 2.6.0 2026-10-01 22:04:41 +00:00
LynithDev
6f415d65e0
fix: graphics drivers issues on linux in appimage (#903) 2026-10-02 00:03:50 +02:00
LynithDev
7bc9064469
fix: libxdo crash in deb packages (#902) 2026-10-01 22:45:37 +02:00
LynithDev
30f0e1843e
chore: add JVM argument to improve vector operations on Java 21+ (#901) 2026-10-01 22:42:25 +02:00
Maciek
959458abde
chore: added enabling disabled bundled mods from browser (#900) 2026-10-01 22:12:53 +02:00
Maciek
76d929b82a
feat: added warning on clicking cosmetics button (#899) 2026-10-01 11:41:55 -04:00
Julian Chang
efbde53326
ui: improve migration ui 2026-10-01 03:46:10 -04:00
Julian Chang
190723a4f8
feat: optionally copy configs into dedicated-folder targets on migration 2026-10-01 03:46:09 -04:00
Julian Chang
0e98be3d5a
feat: manual migration in both directions, only when there's something to copy 2026-10-01 03:45:55 -04:00
Julian Chang
0b78f16e9a
improve more design elements 2026-10-01 02:01:30 -04:00
Julian Chang
b86080ac62
improve design in some areas 2026-10-01 01:35:20 -04:00
Julian Chang
67cfc4eb83
fix: key cached version info by loader format version
Bump interfrost to 1.5.0 and pass the cluster's loader to download_version_info so a format bump doesn't reuse stale version JSON.
2026-10-01 01:34:40 -04:00
Julian Chang
fc3046d60e
feat: list/grid layout toggle on clusters page 2026-10-01 00:52:30 -04:00
Julian Chang
03c00b4199
feat: group user-created OneClient clusters by release line 2026-10-01 00:52:30 -04:00
Julian Chang
51608f7d94
feat: don't re-provision clusters the user deleted 2026-10-01 00:52:30 -04:00
Julian Chang
13e52ab3ec
feat: use gallery images for package card banners 2026-10-01 00:52:30 -04:00
Julian Chang
cd122e877e
fix: create config dir in file-lock override test 2026-10-01 00:52:30 -04:00
Julian Chang
b2a84490ed
split 1.21.x into minor drops 2026-09-30 21:12:02 -04:00
Julian Chang
4995276c51
pre-warm cluster art 2026-09-30 21:01:26 -04:00
LynithDev
50bf2c07ee fix: use correct OneClient logo 2026-09-30 22:38:13 +02:00
LynithDev
3ef452d451
fix: swapped home screen cluster labels to show the cluster name in big text (#898) 2026-09-30 22:02:33 +02:00
LynithDev
65a9c670cc
feat: package selection (#897) 2026-09-30 21:45:46 +02:00
LynithDev
0512727c22
fix: clear all notifications shouldn't be clearing progress notis (#896) 2026-09-30 21:20:07 +02:00
LynithDev
9b6f872a8d
fix: libappindicator crashing and bundling on linux (#893) 2026-09-30 20:44:10 +02:00
Maciek
06fa7739b3
feat: working modpacks from modrinth and curseforge as well as manually imported; couple ui fixes (#894) 2026-09-30 20:44:04 +02:00
LynithDev
d630c27a88
chore: forcefully migrate old launcher path to new path (#895) 2026-09-30 20:43:59 +02:00
Julian Chang
e1be4c8bcc
wipe off incompatible mc version check entirely 2026-09-30 06:12:11 -04:00
LynithDev
c9c4c9536b
feat: better versions UI (#890) 2026-09-29 20:58:57 +02:00
LynithDev
b2a62f8e17
feat: list required dependencies in the browser versions page (#889) 2026-09-29 20:39:37 +02:00
LynithDev
ef5847f417
feat: improved stats screen (#888) 2026-09-29 20:31:42 +02:00
Maciek
55125b551f
fix: fixed worlds and datapacks tabs jumping in (#887) 2026-09-29 20:27:58 +02:00
Maciek
0ab544f265
feat: custom cluster instances (#859)
* feat: added adding new instances of a cluster with 'start from scratch' option enabling user to create a vanilla/modded cluster; fixed couple pre-existing bugs that wouldn't let other loaders boot up

* feat: fully implemented cluster creation modal design

* chore: refactoring

* feat: expanded the tags section on customization step; found that fabric doesnt work with 1.8.9

* feat: added gallery covers handling for new cluster instances

* feat: added 'redirecting' from fabric to orinthe on versions pre-1.14

* feat: added java-version-override

* feat: added loader types migrations (right now they dont migrate mods because there are no bundles)

* chore: wider loader dropdown for multiloader cluster (if it will be used in the future)

* chore: fixed more issues with isolated and shared dir, with migrations and instances naming

* chore: refactor; taken the loader version dropdown from version picker view back to loader selector

* chore: deleted Quilt from UI

---------

Co-authored-by: LynithDev <61880709+LynithDev@users.noreply.github.com>
2026-09-29 19:08:21 +02:00
LynithDev
bdb9780794
fix: move (almost) all IO off the UI thread (#886) 2026-09-29 19:03:25 +02:00
LynithDev
1574d1afb1 fix: open crate on windows not opening urls properly 2026-09-29 15:34:14 +02:00
LynithDev
95eba5503f chore: update dependencies and optimise archive operations 2026-09-29 15:04:51 +02:00
LynithDev
00237d5281 chore: update open crate to fix issue on windows 2026-09-29 13:59:01 +02:00
Maciek
495dbf05dc
feat: polyplus mark and removal warning (#877)
* feat: polyplus mark and removal warning (some issues with merging)

* chore: deleted the blue background from the polyplus mod

* feat: added warning for removing bundle mods; fixed the new/updated badges on mods cards
2026-09-29 12:38:05 +02:00
Maciek
45da20fac2
feat: release migration (#851)
* feat: added migration mechanism for versions 26.3 and above

* feat: added checking for mods that the migrating target already has

* feat: added scheduled migrations for up to 30 days

* feat: added button in cluster settings for manual migratting; added migratting disabled mods with status saving

* feat: added checking for show_initial_migration flag in metadata.json

* feat: added fabric -> orinthe migration (for 1.8.9)

* chore: force open from debug page

* chore: change some UI elements

* fix: don't migrate packages whose dependency failed to install

* fix: keep checking the release migration waitlist past a broken cluster

* fix: make Migrate on a debug fake plan a no-op

* perf: install the version the migration plan already fetched

* chore: fix the clippy warnings the release migration added

* refactor: open every release migration prompt through one helper

* chore(lint): cargo fmt the release migration code

* fix: check the release migration waitlist queries at compile time

---------

Co-authored-by: LynithDev <61880709+LynithDev@users.noreply.github.com>
2026-09-28 21:06:30 +02:00
Maciek
abb41dcc52
feat: worlds and datapacks views (#870)
* feat: added worlds and datapacks tab

* feat: added datapacks in browser; fixed datapacks view loading too long

* feat: added icons to datapacks

* fix: scope world query invalidation to the world that changed

* fix: keep the selected data pack world in one place and guard deletes

* chore: share one dialog shell between the world prompt and delete confirm

* fix: stop watching every subfolder of saves

---------

Co-authored-by: LynithDev <61880709+LynithDev@users.noreply.github.com>
2026-09-28 21:05:52 +02:00
LynithDev
48a25f0c2c
fix: rotate the advanced section chevron when it opens (#885) 2026-09-28 20:53:41 +02:00
Maciek
7f1a3176cf
feat: added sections for advanced mods (#876)
* feat: added sections for advanced mods

* fix: treat a null bundle file type as normal

* chore: drop ScrollArea::lazy_grid

* chore: pass one header height to lazy_sections

* chore: name BundleFileType::Normal in test fixtures

* feat: don't offer advanced bundle mods as optional mods

---------

Co-authored-by: LynithDev <61880709+LynithDev@users.noreply.github.com>
2026-09-28 20:31:18 +02:00
Maciek
f94ca969b0
feat: bad mods flagging (#882)
* feat: first version of flagging mods thru datastorage

* feat: added fallbacks, version-specific flag and project-specific flag

* chore: downsized the amount of requests sent when looking up for alternatives

* feat: added explanations for flagging mods

* fix: prompt for flagged dependencies instead of silently skipping them

* chore: size the flagged popup with max_height instead of measuring it

* chore: drop is_flagged and the explanation size cap

* feat: refresh the bad mods list with the pre-launch checks

---------

Co-authored-by: LynithDev <61880709+LynithDev@users.noreply.github.com>
2026-09-28 17:40:00 +02:00
Maciek
695406145f
chore: deleted the download process from onboarding (#865)
* chore: deleted the download process from onboarding

* fix: gate onboarding Finish on loaded bundles and correct failure copy

* chore: drop unused install_cluster_bundles re-export from oneclient_core

* chore: drop the unread predownload flag from the versions manifest

---------

Co-authored-by: LynithDev <61880709+LynithDev@users.noreply.github.com>
2026-09-28 17:33:01 +02:00
Maciek
b3b436bc3e
feat: added support for github hosted bundle mods (#883)
* feat: added support for github hosted bundle mods

* fix: keep external bundle mods when their manifest entry gains an id

* fix: match a pending external bundle update to its installed jar

* chore: parse the bundle file host with url instead of by hand

* fix: namespace external bundle file ids under ext:

* chore: rename the Browser tab to Online and list unbundled GitHub mods there

---------

Co-authored-by: LynithDev <61880709+LynithDev@users.noreply.github.com>
2026-09-28 17:30:50 +02:00
Maciek
abc452f294
feat: added deleting browser and local mods (#853)
* feat: added deleting browser and local mods by context menu and on list view by trashcan icon

* fix: only confirm shared deletes that reach other clusters

* fix: confirm shared removals from the browser package page too

* fix: keep "View in folder" alive after the context menu closes

---------

Co-authored-by: LynithDev <61880709+LynithDev@users.noreply.github.com>
2026-09-28 17:30:10 +02:00
Maciek
ac66752b99
feat: added reseting back to the default options in settings tabs (#818)
* feat: added reseting back to the default options in settings tabs

* chore: removed the reset button for whole page and changed it to a reset button for every setting row

* chore: removed old reset options from search index

* chore: remove unused use_game_active and GameState::any_active

* fix: reset global memory to the Default option instead of a fixed size

* feat: add reset buttons to the launcher tab's system settings

* chore: fold with_reset into resettable_all and drop unneeded dispatch clones

* chore: rustfmt the import lists touched by the merge

* fix: treat a missing discrete GPU setting as on

* fix: read the discrete GPU reset default with the same true fallback

---------

Co-authored-by: LynithDev <61880709+LynithDev@users.noreply.github.com>
2026-09-28 17:29:05 +02:00
Maciek
35dcd584d0
chore: changed the UX for optional mods modal (#878)
* chore: changed the UX for optional mods modal

* refactor: fold cancel_optional_mods into close_optional_mods

* fix: forget optional mod picks once the modal closes

* chore: rustfmt the optional mods import

* chore: say what the optional mods buttons actually do

---------

Co-authored-by: LynithDev <61880709+LynithDev@users.noreply.github.com>
2026-09-28 17:24:10 +02:00
LynithDev
8e66212596
fix: treat a missing discrete GPU setting as on (#884) 2026-09-28 17:22:13 +02:00
Luna
7ca7ac04cf
fix: preserve executable bits when extracting Java runtimes (#873)
Co-authored-by: LynithDev <61880709+LynithDev@users.noreply.github.com>
2026-09-28 17:15:41 +02:00
Julian Chang
d632c47320
remove unnecessary tests (#881) 2026-09-28 17:13:41 +02:00
Luna
5f7b624d82
fix: it's -> its for possessive (#872) 2026-09-28 17:13:27 +02:00
Julian Chang
e675b99300
fix corrupted file detection 2026-09-24 23:24:36 -04:00
LynithDev
520e6c1ef1
chore: redesigned browser grid (#869) 2026-09-23 23:49:57 +02:00
LynithDev
3a5f06e8ac
chore: redesigned package grid (#867) 2026-09-23 13:39:11 +02:00
Maciek
36a925da06
fix: homepage cover images are now in much better quality without bigger memory use (#862)
* fix: fixed background covers rendering poorly on bigger resolutions

* fix: fixed onboarding cached images stacking from ~80MB to again ~20MB (like it was before)
2026-09-23 13:31:29 +02:00
LynithDev
2f7476ba6e
chore: redesigned the navbar links to be pills (#866) 2026-09-23 13:27:39 +02:00
a83bda9b53 chore(release): bump version to 2.5.1-c3
Some checks failed
Release Multiplatform Binaries / build (deb,appimage, linux-x86_64, ubuntu-22.04, x86_64-unknown-linux-gnu) (push) Waiting to run
Release Multiplatform Binaries / build (dmg,app, macos-arm64, macos-14, aarch64-apple-darwin) (push) Waiting to run
Release Multiplatform Binaries / build (dmg,app, macos-x86_64, macos-14, x86_64-apple-darwin) (push) Waiting to run
Release Multiplatform Binaries / build (nsis, windows-x86_64, windows-latest, x86_64-pc-windows-msvc) (push) Waiting to run
Release Multiplatform Binaries / create-release (push) Blocked by required conditions
Publish AUR Packages / resolve (release) Waiting to run
Publish AUR Packages / publish-source (release) Blocked by required conditions
Publish AUR Packages / publish-bin (release) Blocked by required conditions
Publish COPR Package / resolve (release) Failing after 0s
Publish COPR Package / publish-bin (release) Has been skipped
2026-09-23 07:12:02 +05:30
70e34d1b66 fix(launch): gate 1.21.11 offlineskins mod to matching minecraft version 2026-09-23 07:08:52 +05:30
7bb797eb16 feat(skins): integrate client-side offline skin mod and isolate player textures 2026-09-23 06:37:28 +05:30
017c9a85f9 fix(skins): fix authlib-injector routing, add RSA profile signing, and populate userProperties 2026-09-22 22:20:13 +05:30
LynithDev
80e7d4d4f8
chore: update freya and enable debug overlay in release (#856)
* chore: bump freya to 0.5.0-rc.6

* feat: add freya debug overlay plugin

* chore: bump to rc.7
2026-09-22 16:13:05 +02:00
700b11a3ce feat(skins): integrate proper isolated offline skin system via Authlib-Injector and eliminate multiplayer skin cloning (2.5.1-c2)
Some checks are pending
Release Multiplatform Binaries / build (deb,appimage, linux-x86_64, ubuntu-22.04, x86_64-unknown-linux-gnu) (push) Waiting to run
Release Multiplatform Binaries / build (dmg,app, macos-arm64, macos-14, aarch64-apple-darwin) (push) Waiting to run
Release Multiplatform Binaries / build (dmg,app, macos-x86_64, macos-14, x86_64-apple-darwin) (push) Waiting to run
Release Multiplatform Binaries / build (nsis, windows-x86_64, windows-latest, x86_64-pc-windows-msvc) (push) Waiting to run
Release Multiplatform Binaries / create-release (push) Blocked by required conditions
2026-09-21 19:49:57 +05:30
Julian Chang
ac52d55cf2
rename Fabric (Ornithe) -> Fabric 2026-09-19 14:09:38 -04:00
LynithDev
850c695f91
feat: system tray and process state controls (#687) 2026-09-19 01:19:34 +02:00
LynithDev
a8cc4e8b7f
feat: better text inputs (#854) 2026-09-19 01:18:49 +02:00
sakuyma
45a970c38a
Small QoL fixes: Java refresh, clipboard toasts, custom image endpoints (#852)
* fix(java): add refresh button for stale runtimes

* fix(notifications): keep clipboard toasts out of the notification center

* fix(images): allow custom endpoints on non-public hosts

* chore: make toast_only notifications not increment notification counter

* chore: reorder navbar links

---------

Co-authored-by: LynithDev <61880709+LynithDev@users.noreply.github.com>
2026-09-18 23:43:49 +02:00
LynithDev
daaa209b30 chore(lint): cargo fmt 2026-09-18 23:05:50 +02:00
991a85c589 feat: update base to official upstream 2.5.1 with all cracked features intact (2.5.1-c1)
Some checks are pending
Release Multiplatform Binaries / build (deb,appimage, linux-x86_64, ubuntu-22.04, x86_64-unknown-linux-gnu) (push) Waiting to run
Release Multiplatform Binaries / build (dmg,app, macos-arm64, macos-14, aarch64-apple-darwin) (push) Waiting to run
Release Multiplatform Binaries / build (dmg,app, macos-x86_64, macos-14, x86_64-apple-darwin) (push) Waiting to run
Release Multiplatform Binaries / build (nsis, windows-x86_64, windows-latest, x86_64-pc-windows-msvc) (push) Waiting to run
Release Multiplatform Binaries / create-release (push) Blocked by required conditions
2026-09-18 07:16:25 +05:30
LynithDev
e0947848f9 fix: oneclient aur source publishes failing 2026-09-18 01:10:22 +02:00
github-actions[bot]
dad96c8d40 chore: bump to 2.5.1 2026-09-17 22:38:18 +00:00
LynithDev
c7f3fdc52b
chore: update bundled packages only after cluster launch (#849) 2026-09-18 00:09:53 +02:00
LynithDev
43108bc8bc fix: update stale package metadata if it's supported versions were updated 2026-09-17 23:54:24 +02:00
Maciek
e25c3e8243
feat: better error handling for process hooks (#847) 2026-09-17 23:23:46 +02:00
Luna
9dccb42fd1
chore: optimize assets with oxipng (#846) 2026-09-17 23:18:00 +02:00
8093cafd53 docs: restore original custom README description and false positive notice 2026-09-17 10:39:13 +05:30
d214afe99b feat: update base to official upstream 2.5.0 with all cracked features intact (2.5.0-c1)
Some checks are pending
Release Multiplatform Binaries / build (deb,appimage, linux-x86_64, ubuntu-22.04, x86_64-unknown-linux-gnu) (push) Waiting to run
Release Multiplatform Binaries / build (dmg,app, macos-arm64, macos-14, aarch64-apple-darwin) (push) Waiting to run
Release Multiplatform Binaries / build (dmg,app, macos-x86_64, macos-14, x86_64-apple-darwin) (push) Waiting to run
Release Multiplatform Binaries / build (nsis, windows-x86_64, windows-latest, x86_64-pc-windows-msvc) (push) Waiting to run
Release Multiplatform Binaries / create-release (push) Blocked by required conditions
- Rebased base onto official Polyfrost OneLauncher 2.5.0 (release v2.5.0 + latest bundle fixes).
- Integrated new Upstream Control Center quick settings & system info overlay and navbar avatar-with-gear trigger.
- Preserved top navbar 'Skins' direct route navigation.
- Maintained guaranteed offline mode launch without requiring any Microsoft accounts.
- Maintained custom 3D offline skin changer, player model switch (Classic/Slim), and targeted Minecraft Java UUID character slot isolation.
- Maintained CustomSkinLoader auto-configuration for local skins, Ely.by, and SkinRestorer.
- Integrated direct GitHub Releases auto-updater pointing to oiupoyt/OneLauncher-Cracked.
- Preserved inline username renaming modal and Change Skin navigation in account settings and onboarding.
- Maintained mods filter popover blur/text ghosting fix in package manager.
- Preserved fast dev profile and multiplatform release workflow for v2.5.0-c1.
- All workspace tests and clippy checks pass with 0 errors.
2026-09-17 07:15:57 +05:30
618731da7d ci: update release workflow fallback tag and release notes for v2.4.2-c1
Some checks are pending
Release Multiplatform Binaries / build (deb,appimage, linux-x86_64, ubuntu-22.04, x86_64-unknown-linux-gnu) (push) Waiting to run
Release Multiplatform Binaries / build (dmg,app, macos-arm64, macos-14, aarch64-apple-darwin) (push) Waiting to run
Release Multiplatform Binaries / build (dmg,app, macos-x86_64, macos-14, x86_64-apple-darwin) (push) Waiting to run
Release Multiplatform Binaries / build (nsis, windows-x86_64, windows-latest, x86_64-pc-windows-msvc) (push) Waiting to run
Release Multiplatform Binaries / create-release (push) Blocked by required conditions
2026-09-17 06:29:04 +05:30
Julian Chang
41ce177483
Fix version-tag matching and bundle filtering 2026-09-16 18:02:21 -04:00
Julian Chang
09509b1b3d
ignore mc version check for tracked bundle mods 2026-09-16 17:01:50 -04:00
936e4247a3 feat: update base to official upstream 2.4.2 with all cracked features intact (2.4.2-c1)
- Rebased base onto official Polyfrost OneLauncher 2.4.2 (release v2.4.2).
- Ensured offline mode launches with 0 Microsoft accounts required (no OfflineRequiresMicrosoft error).
- Maintained custom 3D offline skin changer, player model switch (Classic/Slim), and targeted Minecraft Java UUID character slot isolation to prevent multiplayer skin cloning.
- Maintained CustomSkinLoader auto-configuration for local skins, Ely.by, and SkinRestorer.
- Integrated direct GitHub Releases auto-updater pointing to oiupoyt/OneLauncher-Cracked.
- Ported inline username renaming modal and Change Skin navigation in account settings and onboarding.
- Maintained mods filter popover blur/text ghosting fix in package manager.
- Preserved fast dev profile and multiplatform release workflow for v2.4.2-c1.
- All workspace tests and clippy checks pass with 0 errors.
2026-09-17 00:28:04 +05:30
github-actions[bot]
ec055f3043 chore: bump to 2.5.0 2026-09-16 18:32:52 +00:00
LynithDev
a2859e6681 chore: add flag to AUR PKGBUILD to prevent rustc from getting OOM killed 2026-09-16 20:32:19 +02:00
Maciek
a00e11d204
feat: control center and smaller navbar link gaps (#832)
* feat: added a control center

* feat: added no account signed in handling; added a modal on sign out clicked

* feat: use the discord icon for the discord rpc quick setting

* style: fade out disabled quick setting tiles

* style: drop the 'Active' suffix from the control center account subtitle

* feat: added a gear indicator to the navbar avatar button

* add small gear icon on avatar

---------

Co-authored-by: LynithDev <61880709+LynithDev@users.noreply.github.com>
2026-09-16 20:29:14 +02:00
Maciek
b4f067483a
fix: fixed most of the sizing issues in the launcher (#822) 2026-09-16 18:58:10 +02:00
Maciek
d32d3e1782
feat: added tooltips (#837) 2026-09-16 18:34:50 +02:00
Maciek
0a6c9e0f74
fix: fixed scrollbar overlapping with the scrollview content (#817) 2026-09-16 18:30:55 +02:00
Maciek
9879eb2e72
fix: fixed versions metadata not healing when unable to parse or read (#835) 2026-09-16 18:30:20 +02:00
Julian Chang
c3a45371dd
fix: incorrect database and filesystem structure of mods after updates (#834)
Co-authored-by: LynithDev <61880709+LynithDev@users.noreply.github.com>
2026-09-16 18:24:09 +02:00
github-actions[bot]
ebb476029f chore: bump to 2.4.2 2026-09-14 20:15:28 +00:00
LynithDev
84bbf29882 fix: mod updates applying after launch and not before 2026-09-14 21:03:34 +02:00
LynithDev
6ceba7480f fix: mod updates not being symlinked to a clusters mods folder after update 2026-09-14 20:39:07 +02:00
github-actions[bot]
db764e63f9 chore: bump to 2.4.1 2026-09-14 14:47:44 +00:00
LynithDev
80ba104861
chore: improve stats page and cluster overview (#831)
* fix(stats): drop zero units from durations and cap the server pie at ten slices

* fix(stats): say out loud that "when you play" bars are lifetime totals

* feat(stats): paint every bar in the accent colour until one is hovered

* feat(stats): add a calendar range picker to daily playtime

* fix(stats): stop recovery inventing playtime and carry sessions over midnight

* feat(stats): more stats out of the data already recorded

* feat(stats): eight more personas, and let them see the server data

* fix(charts): stop axis labels being cut off to their own bar's width

* feat(stats): give the overview a headline stat instead of seven identical boxes

* feat(stats): turn personas into wrapping badges

* fix(stats): lay the overview out for the window it is actually in

* feat(stats): fold the monthly chart into one "Playtime by hour/day/month" card

* fix(stats): stop the total playtime card making the tile block so tall

* fix(stats): strip the persona badges back to plain tags
2026-09-14 16:45:02 +02:00
LynithDev
2e0ba533fe
fix: issues with mod updating and mods randomly being disabled (#830)
* fix: stop offering bundle-owned packages in the update modal

* fix: match a version's game versions exactly not by substring

* fix: resolve a mod's materialized root from the layout in use

* fix: stop parking unclaimed jars in the cluster mods folder

* fix: stop the duplicate reconciler filing a user-disabled override

* fix: repair the disables duplicate resolution left behind
2026-09-14 16:44:46 +02:00
Julian Chang
a8bdf997d9
Reapply "fix: enable Wayland clipboard for image copy + align MSRV docs (#826)"
This reverts commit b737e39d1b.
2026-09-13 22:42:52 -04:00
1b5e9eb942 fix(auth): allow offline accounts to launch game without Microsoft accounts (2.3.1-c2)
Some checks are pending
Release Multiplatform Binaries / build (deb,appimage, linux-x86_64, ubuntu-22.04, x86_64-unknown-linux-gnu) (push) Waiting to run
Release Multiplatform Binaries / build (dmg,app, macos-arm64, macos-14, aarch64-apple-darwin) (push) Waiting to run
Release Multiplatform Binaries / build (dmg,app, macos-x86_64, macos-14, x86_64-apple-darwin) (push) Waiting to run
Release Multiplatform Binaries / build (nsis, windows-x86_64, windows-latest, x86_64-pc-windows-msvc) (push) Waiting to run
Release Multiplatform Binaries / create-release (push) Blocked by required conditions
- Removed upstream check in account_for_launch that blocked offline accounts from launching without a Microsoft account
- Added test offline_account_for_launch_succeeds_without_microsoft
- Bumped version to 2.3.1-c2
2026-09-09 21:08:11 +05:30
163eb0ac0c feat: update base to official upstream 2.3.1 with all cracked features intact (2.3.1-c1)
Some checks are pending
Release Multiplatform Binaries / build (deb,appimage, linux-x86_64, ubuntu-22.04, x86_64-unknown-linux-gnu) (push) Waiting to run
Release Multiplatform Binaries / build (dmg,app, macos-arm64, macos-14, aarch64-apple-darwin) (push) Waiting to run
Release Multiplatform Binaries / build (dmg,app, macos-x86_64, macos-14, x86_64-apple-darwin) (push) Waiting to run
Release Multiplatform Binaries / build (nsis, windows-x86_64, windows-latest, x86_64-pc-windows-msvc) (push) Waiting to run
Release Multiplatform Binaries / create-release (push) Blocked by required conditions
- Rebased onto official Polyfrost OneLauncher v2.3.1 (commit 0e49c691)
- Preserved 100% offline authentication without requiring Microsoft accounts
- Integrated custom 3D offline skin changer with UUID character slot targeting
- Maintained CustomSkinLoader auto-configuration for Ely.by and SkinRestorer
- Maintained direct GitHub Releases auto-updater pointing to oiupoyt/OneLauncher-Cracked
- Maintained mods filter popover fix eliminating text ghosting/duplication
- Configured dev profile to prevent debug symbol bloat
2026-09-07 17:38:22 +05:30
c063430e26 build: configure dev profile to prevent debug symbol bloat 2026-09-04 19:03:45 +05:30
a2ca929e0a fix(skins): calculate exact Minecraft Java UUID character slot for targeted skin injection
Some checks are pending
Release Multiplatform Binaries / build (deb,appimage, linux-x86_64, ubuntu-22.04, x86_64-unknown-linux-gnu) (push) Waiting to run
Release Multiplatform Binaries / build (dmg,app, macos-arm64, macos-14, aarch64-apple-darwin) (push) Waiting to run
Release Multiplatform Binaries / build (dmg,app, macos-x86_64, macos-14, x86_64-apple-darwin) (push) Waiting to run
Release Multiplatform Binaries / build (nsis, windows-x86_64, windows-latest, x86_64-pc-windows-msvc) (push) Waiting to run
Release Multiplatform Binaries / create-release (push) Blocked by required conditions
2026-08-30 18:19:18 +05:30
1f979f5717 fix: resolve mods filter text overlap and ensure 100% reliable skin loading across all MC versions 2026-08-30 17:42:54 +05:30
70b6b0c8b1 docs: remove autoupdater from release notes template 2026-08-30 17:30:42 +05:30
31c294b02a docs: update release notes template to highlight multiplayer skin fix 2026-08-30 17:28:25 +05:30
d1f5a715b8 fix(skins): target player specific UUID default character to prevent multiplayer cloning, bump to 2.2.3-c2 2026-08-30 16:54:51 +05:30
f438445c38 ci: update release changelog and default tag to v2.2.3-c1
Some checks are pending
Release Multiplatform Binaries / build (deb,appimage, linux-x86_64, ubuntu-22.04, x86_64-unknown-linux-gnu) (push) Waiting to run
Release Multiplatform Binaries / build (dmg,app, macos-arm64, macos-14, aarch64-apple-darwin) (push) Waiting to run
Release Multiplatform Binaries / build (dmg,app, macos-x86_64, macos-14, x86_64-apple-darwin) (push) Waiting to run
Release Multiplatform Binaries / build (nsis, windows-x86_64, windows-latest, x86_64-pc-windows-msvc) (push) Waiting to run
Release Multiplatform Binaries / create-release (push) Blocked by required conditions
2026-08-30 14:51:06 +05:30
f84fec2c35 feat(updater): implement direct GitHub Releases auto-updater with cracked revision support (2.2.3-c1) 2026-08-30 14:43:16 +05:30
05dac19dd2 feat(skins): directly replace default textures in client jar, add zip resource pack, and fix options.txt formatting
Some checks are pending
Release Multiplatform Binaries / build (deb,appimage, linux-x86_64, ubuntu-22.04, x86_64-unknown-linux-gnu) (push) Waiting to run
Release Multiplatform Binaries / build (dmg,app, macos-arm64, macos-14, aarch64-apple-darwin) (push) Waiting to run
Release Multiplatform Binaries / build (dmg,app, macos-x86_64, macos-14, x86_64-apple-darwin) (push) Waiting to run
Release Multiplatform Binaries / build (nsis, windows-x86_64, windows-latest, x86_64-pc-windows-msvc) (push) Waiting to run
Release Multiplatform Binaries / create-release (push) Blocked by required conditions
2026-08-30 13:17:09 +05:30
f125ac2d0b ci: ensure previous release assets are purged on re-release 2026-08-30 09:50:48 +05:30
d7618833ef chore: keep version as 2.2.3 2026-08-30 09:44:00 +05:30
b4128fc8f7 release: v2.2.4 (remove portable executables from release artifacts) 2026-08-30 09:35:48 +05:30
a3a3d74fd6 refactor: polish codebase, clean clippy lints, and ensure test suite passes 2026-08-30 09:22:58 +05:30
cea98e9923 fix: browser-to-settings transition crash & full in-game skin asset coverage
- Fix crash when transitioning from Browse to Settings:
  * Prevent UI thrashing by executing router settle in use_side_effect rather than during render
  * Add PartialEq/Eq to BrowserUiState and use use_side_effect_with_deps in Browser component
- Fix in-game skin loading across all Minecraft versions:
  * Modern Minecraft (1.19.3 - 1.21.x) default player skin resolution covers all 9 character models (Steve, Alex, Ari, Efe, Kai, Makena, Noor, Sunny, Zuri) for both wide and slim model paths
  * Legacy Minecraft (1.8 - 1.19.2) entity texture paths (textures/entity/steve.png, alex.png)
  * Multi-mod directory synchronization: CustomSkinLoader, OfflineSkins, cachedImages for cluster game dir and shared .minecraft
  * Safe options.txt patching supporting both resourcePacks and incompatibleResourcePacks list formats
2026-08-29 23:30:02 +05:30
de9416573a refactor: replace authlib-injector with zero-overhead resource pack skin injection
Remove the entire skin_server.rs module and authlib-injector approach (HTTP server,
JVM agent, jar download). Replace with a dead-simple resource pack strategy:

- inject_skin_resource_pack() in launch.rs:
  * On every launch, if the player has a saved skin, writes it to
    resourcepacks/onelauncher_skin/assets/minecraft/textures/entity/player/wide/steve.png
    and .../slim/alex.png (both variants so it always matches)
  * Writes a pack.mcmeta with supported_formats 1-9999 covering all MC versions
  * Patches options.txt to add/keep 'file/onelauncher_skin' in the resourcePacks list
  * If no custom skin is saved, cleans up the pack dir and removes the options.txt entry
- patch_resource_packs_option() handles safe idempotent read-modify-write of options.txt
- Remove skin_server module from game/mod.rs
- Remove authlib_injector_path() from paths.rs
- Revert jvm_args to immutable (no longer needs push)

No extra dependencies, no background threads, no network requests.
2026-08-29 20:11:47 +05:30
52c29c034e feat: in-game offline skin support via authlib-injector + local skin server
- Add packages/oneclient_core/src/game/skin_server.rs:
  * Local Tokio TCP skin server that serves a minimal Yggdrasil-compatible API
  * Responds to GET /, GET /session/minecraft/profile/:uuid, GET /textures/:uuid.png
  * Reads skin PNGs and metadata from launcher skins dir
  * sync_offline_skins() copies PNG to CustomSkinLoader/skins/, config/offlineskins/, cachedImages/skins/
  * prepare_authlib_injector() downloads authlib-injector-1.2.5.jar on first launch
- Modify packages/oneclient_core/src/game/launch.rs:
  * Start skin server and attach -javaagent authlib-injector before spawning Minecraft
  * Sync skins to cluster game dir mod folders on every launch
- Add authlib_injector_path() to packages/oneclient_common/src/paths.rs
- Remove dead fetch_skin_online() from skin.rs + all exports
2026-08-29 20:00:45 +05:30
2a040f8e91 perf: eliminate account dropdown micro-delays with global avatar caching and dev optimization 2026-08-29 19:21:11 +05:30
005e7ce77b fix(skins): isolate UrlModal and PreviewPanel as Component structs to prevent conditional hooks 2026-08-29 18:47:44 +05:30
5a7ecf9293 fix(lifecycle): isolate navbar components and eliminate startup query borrow collisions 2026-08-29 18:39:59 +05:30
714dadf388 fix(hooks): eliminate conditional hooks and add render keys to Avatar and PlayerModel 2026-08-29 18:30:28 +05:30
a363af6d20 feat(skins): integrate full offline skin changer with 3D preview, model toggle, file import, and online URL lookup 2026-08-29 17:46:22 +05:30
6a20bcfec5 ci: add rust caching and safe in-place release publishing 2026-08-29 17:11:03 +05:30
6f7977c734 fix(account_switcher): spawn detached async task for account switching to avoid unmounted scope crash 2026-08-29 17:08:53 +05:30
ddfe500494 chore: update repository links to OneLauncher-Cracked 2026-08-29 15:32:11 +05:30
d801eed647
Update README.md 2026-08-29 09:58:05 +05:30
bcfe8b8ea4
Update README.md 2026-08-29 09:57:44 +05:30
03b76306c2 docs: update repository link to OneClient-Cracked 2026-08-29 08:41:39 +05:30
f17ab744d2 fix(onboarding): ensure async offline account creation completes before navigating 2026-08-29 07:37:48 +05:30
27de1c3bd4 docs: update repository and release links to OneClientCracked 2026-08-29 07:22:54 +05:30
a659448ac6
Update README.md 2026-08-29 07:18:29 +05:30
1a552f0c5e ci: set fail_on_unmatched_files false for release publisher 2026-08-29 06:46:14 +05:30
47b7ba27a0 ci: improve artifact upload resiliency and set tag_name/make_latest for release 2026-08-29 06:44:43 +05:30
8ff958743c feat: add input sanitizer and character constraints hint for offline username 2026-08-29 06:28:12 +05:30
c603d59908 fix(auth): ensure custom offline account sets default user and add unit tests 2026-08-29 06:21:11 +05:30
481ca4d77e feat: prompt for custom offline username during onboarding 2026-08-29 05:57:18 +05:30
853c0beac5 fix(onboarding): auto-provision default Player account on fresh install 2026-08-27 19:42:49 +05:30
c6aca56f27 fix(auth): fix onboarding offline account creation and enable continue button 2026-08-27 19:42:49 +05:30
37a09c12de fix(linux): provide static desktop entry for rpm generation and update repo URLs 2026-08-27 19:42:49 +05:30
710c129273
Update README.md 2026-08-27 19:08:13 +05:30
f6e7d7a8c1
Update README.md 2026-08-27 19:07:14 +05:30
8cb08872bf
Update README.md 2026-08-27 18:13:43 +05:30
ca3dc4c638
Update README.md 2026-08-27 18:13:28 +05:30
aba8106b5b ci: add rpm generation and clean up release asset artifacts 2026-08-27 17:21:43 +05:30
945052def8 ci: use macos-14 runner to build x86_64-apple-darwin binaries 2026-08-27 16:49:24 +05:30
db273e7f38 fix: resolve cross-platform packaging formats and update offline auth tests 2026-08-27 16:43:21 +05:30
48fe1c78e7 fix(db): implement get_by_path, get_latest_by_major, delete_by_path, and list_all 2026-08-27 16:07:06 +05:30
130daf7c34 fix(db): switch java dao to runtime query_as for CI build compatibility 2026-08-27 16:07:06 +05:30
7ba19da764
Fix typo in README regarding username launch 2026-08-27 15:43:30 +05:30
71a5dea95f
Update README.md 2026-08-27 15:43:14 +05:30
a65ac9eed2
Update README.md 2026-08-27 15:42:53 +05:30
134c1db5c6 ci: add sqlx offline cache and env configuration 2026-08-27 15:41:38 +05:30
e371653e2c ci: add automated multiplatform release workflow 2026-08-27 15:27:22 +05:30
56b2d23c2b
Update README.md 2026-08-27 15:22:16 +05:30
6d4e29fb81
Update README.md 2026-08-27 15:21:42 +05:30
c28dbd38c2
Create README.md 2026-08-27 15:18:37 +05:30
c24425e06d feat(auth): enable offline mode accounts and inline username editing 2026-08-27 15:05:07 +05:30
99f78f7b2a
Initial commit 2026-08-27 12:59:33 +05:30
485 changed files with 60408 additions and 18536 deletions

View file

@ -1,3 +1,23 @@
[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=lld"]
[target.aarch64-apple-darwin]
linker = "aarch64-apple-darwin-clang"
rustflags = [
"-C", "link-arg=-fuse-ld=lld",
"-C", "link-arg=-isysroot",
"-C", "link-arg=/home/oiupoyt/.local/share/MacOSX11.3.sdk",
]
[target.x86_64-apple-darwin]
linker = "x86_64-apple-darwin-clang"
rustflags = [
"-C", "link-arg=-fuse-ld=lld",
"-C", "link-arg=-isysroot",
"-C", "link-arg=/home/oiupoyt/.local/share/MacOSX11.3.sdk",
]
# Linux and Macos by default have 8MB of stack storage.
# Windows has 1MB, it isn't enough, so it's changed to 8MB also.
[target.x86_64-pc-windows-msvc]

View file

@ -0,0 +1,122 @@
name: Release Binaries (Forgejo)
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
description: 'Release Tag (e.g. v2.8.0-c1)'
required: false
default: 'v2.8.0-c1'
type: string
permissions:
contents: write
env:
SQLX_OFFLINE: true
SQLX_OFFLINE_DIR: packages/oneclient_db/.sqlx
jobs:
build-linux:
runs-on: ubuntu-22.04
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-gnu
- name: Rust Cache
uses: Swatinem/rust-cache@v2
with:
key: x86_64-unknown-linux-gnu
cache-all-crates: true
- name: Install Linux build dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
build-essential \
curl \
wget \
file \
libxdo-dev \
libssl-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
libfontconfig1-dev \
libfreetype6-dev \
libgl1-mesa-dev \
libvulkan-dev \
libasound2-dev
- name: Install cargo-packager
run: cargo install cargo-packager --locked || true
- name: Build Release Binary
run: cargo build --release --bin oneclient_app --target x86_64-unknown-linux-gnu
- name: Package Application
run: cargo packager --release --target x86_64-unknown-linux-gnu --formats deb,appimage
continue-on-error: true
- name: Generate RPM Package (Linux)
run: |
cargo install cargo-generate-rpm --locked || true
cargo generate-rpm --target x86_64-unknown-linux-gnu -p oneclient_app || true
- name: Determine Release Tag
id: release_tag
run: |
if [[ "${{ github.ref_name }}" =~ ^v ]]; then
echo "tag=${{ github.ref_name }}" >> $GITHUB_OUTPUT
elif [ -n "${{ inputs.tag }}" ]; then
echo "tag=${{ inputs.tag }}" >> $GITHUB_OUTPUT
else
echo "tag=v2.8.0-c1" >> $GITHUB_OUTPUT
fi
- name: Publish Assets to Forgejo Release
run: |
TAG="${{ steps.release_tag.outputs.tag }}"
API_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
echo "Connecting to $API_URL for release $TAG"
# 1. Fetch or create release on Forgejo
RELEASE_ID=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" "$API_URL/releases/tags/$TAG" | jq -r '.id // empty')
if [ -z "$RELEASE_ID" ] || [ "$RELEASE_ID" = "null" ]; then
RESP=$(curl -s -X POST "$API_URL/releases" \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"OneLauncher-Cracked $TAG\",\"draft\":false,\"prerelease\":false,\"target_commitish\":\"main\"}")
RELEASE_ID=$(echo "$RESP" | jq -r '.id // empty')
fi
echo "Target Release ID: $RELEASE_ID"
# 2. Upload generated packages
shopt -s nullglob
FILES=(
target/x86_64-unknown-linux-gnu/release/*.deb
target/x86_64-unknown-linux-gnu/release/*.AppImage
target/x86_64-unknown-linux-gnu/generate-rpm/*.rpm
target/release/*.deb
target/release/*.AppImage
target/generate-rpm/*.rpm
)
for f in "${FILES[@]}"; do
if [ -f "$f" ]; then
fname=$(basename "$f")
echo "Uploading asset $fname..."
curl -s -X POST "$API_URL/releases/$RELEASE_ID/assets?name=$fname" \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-F "attachment=@$f"
fi
done
echo "Release upload completed!"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 21 KiB

35
.github/workflows/changelog_publish.yml vendored Normal file
View file

@ -0,0 +1,35 @@
name: Publish Changelog
on:
release:
types: [published, edited]
workflow_dispatch:
concurrency:
group: changelog-publish
cancel-in-progress: true
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: write
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
steps:
# The launcher reads releases/latest/download/changelog.json, so the
# snapshot of the last 50 release bodies always lives on the latest release.
- name: Build changelog.json from release bodies
run: |
gh api "repos/$GH_REPO/releases?per_page=50" --jq '[.[]
| select(.draft | not)
| select(.tag_name | startswith("oneclient-"))
| {
version: (.tag_name | ltrimstr("oneclient-")),
body: ((.body // "") | sub("^Automated release for OneClient [^\n]*\\s*"; ""))
}]' > changelog.json
jq -r '.[].version' changelog.json
- name: Upload to latest release
run: gh release upload "$(gh release view --json tagName -q .tagName)" changelog.json --clobber

23
.github/workflows/changeset_check.yml vendored Normal file
View file

@ -0,0 +1,23 @@
name: Changeset Check
on:
pull_request:
types: [opened, synchronize, reopened, labeled, unlabeled]
jobs:
changeset:
if: ${{ !contains(github.event.pull_request.labels.*.name, 'no changeset') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
fetch-depth: 0
- name: Require a change file
env:
BASE_REF: ${{ github.base_ref }}
run: |
if ! git diff --name-only --diff-filter=A "origin/$BASE_REF...HEAD" -- '.changeset/*.md' | grep -q .; then
echo "::error::Add a change file with \`knope document-change\`, or label the PR 'no changeset' if it isn't user-facing."
exit 1
fi

View file

@ -85,6 +85,7 @@ jobs:
libfontconfig-dev libfreetype-dev \
libgl1-mesa-dev libx11-dev libxcursor-dev libxi-dev libxrandr-dev \
libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev libdbus-1-dev \
libgtk-3-dev libayatana-appindicator3-dev \
libfuse2
- name: Install cargo-packager
@ -263,21 +264,61 @@ jobs:
for f in "${{ steps.outdir.outputs.dir }}"/*.dmg \
"${{ steps.outdir.outputs.dir }}"/*.deb \
"${{ steps.outdir.outputs.dir }}"/*.rpm \
"${{ steps.outdir.outputs.dir }}"/*.AppImage \
"${{ steps.outdir.outputs.dir }}"/*-setup.exe; do
"${{ steps.outdir.outputs.dir }}"/*.AppImage; do
[ -e "$f" ] || continue
cp "$f" dist/
done
# Raw executable, renamed per platform so the unzipped uploads don't collide.
bin="${{ steps.outdir.outputs.dir }}/oneclient_app"
ext=""
[ -e "$bin.exe" ] && ext=".exe"
cp "$bin$ext" "dist/oneclient_app-${{ matrix.os_key }}-${{ matrix.arch }}$ext"
if [ -z "$(ls -A dist)" ]; then
echo "::error::No installers found in ${{ steps.outdir.outputs.dir }}"
exit 1
fi
ls -la dist
- name: Upload unsigned installers
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
- name: Upload unsigned executable
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: oneclient-nightly-${{ matrix.os_key }}-${{ matrix.arch }}
path: dist/*
path: dist/oneclient_app-${{ matrix.os_key }}-${{ matrix.arch }}*
archive: false
if-no-files-found: error
retention-days: 14
- name: (macOS) Upload unsigned dmg
if: matrix.os_key == 'darwin'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
path: dist/*.dmg
archive: false
if-no-files-found: error
retention-days: 14
- name: (Linux) Upload unsigned deb
if: matrix.os_key == 'linux'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
path: dist/*.deb
archive: false
if-no-files-found: error
retention-days: 14
- name: (Linux) Upload unsigned rpm
if: matrix.os_key == 'linux'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
path: dist/*.rpm
archive: false
if-no-files-found: error
retention-days: 14
- name: (Linux) Upload unsigned AppImage
if: matrix.os_key == 'linux'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
path: dist/*.AppImage
archive: false
if-no-files-found: error
retention-days: 14

View file

@ -3,17 +3,6 @@ name: OneClient Release Build
on:
workflow_dispatch:
inputs:
bump:
description: 'Bump the workspace version in Cargo.toml before releasing'
type: choice
required: false
default: none
options:
- none
- patch
- minor
- major
# NOTE: CARGO_PACKAGER_SIGN_PRIVATE_KEY* are deliberately NOT declared at
# workflow level. Swatinem/rust-cache folds every `CARGO*`-prefixed environment
@ -37,18 +26,25 @@ jobs:
sha: ${{ steps.version.outputs.sha }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
# Knope finds the commit (and so the PR author) that added each change file
fetch-depth: 0
- name: Setup Rust
if: inputs.bump != 'none'
uses: ./.github/actions/setup-rust
with:
restore-cache: false
- name: Resolve version, bump if requested, refuse to clobber a release
- name: Install Knope
uses: ./.github/actions/install-cargo-tool
with:
tool: knope
version: 0.23.0
- name: Consume changesets
id: version
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BUMP: ${{ inputs.bump }}
shell: bash
run: |
set -euo pipefail
@ -57,63 +53,29 @@ jobs:
sed -n -E '/^\[workspace\.package\]/,/^\[/ s/^version[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p' Cargo.toml | head -n1
}
# No change files means a re-run of a release whose version commit
# already landed, so carry on with the current version.
RELEASED=0
if compgen -G ".changeset/*.md" > /dev/null; then
knope prepare-release
cargo update --workspace --offline || cargo update --workspace
RELEASED=1
fi
VERSION="$(read_version)"
if [ -z "$VERSION" ]; then
echo "::error::Could not read version from [workspace.package] in Cargo.toml"
exit 1
fi
echo "Current version: $VERSION"
if [ "$BUMP" != "none" ]; then
if ! [[ "$VERSION" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
echo "::error::Version '$VERSION' is not plain semver major.minor.patch; cannot bump automatically."
exit 1
fi
MAJOR="${BASH_REMATCH[1]}"; MINOR="${BASH_REMATCH[2]}"; PATCH="${BASH_REMATCH[3]}"
case "$BUMP" in
major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;;
minor) MINOR=$((MINOR + 1)); PATCH=0 ;;
patch) PATCH=$((PATCH + 1)) ;;
esac
NEW_VERSION="$MAJOR.$MINOR.$PATCH"
echo "Bumping ($BUMP): $VERSION -> $NEW_VERSION"
# Only rewrite the `version` key inside [workspace.package]; the
# dependency pins further down the file must not be touched.
NEW_VERSION="$NEW_VERSION" python3 - <<'PY'
import os, re
new = os.environ["NEW_VERSION"]
src = open("Cargo.toml").read()
start = src.index("[workspace.package]")
end = src.find("\n[", start + 1)
end = len(src) if end == -1 else end
section, count = re.subn(
r'(?m)^version\s*=\s*"[^"]+"', f'version = "{new}"', src[start:end], count=1
)
assert count == 1, "no version key found in [workspace.package]"
open("Cargo.toml", "w").write(src[:start] + section + src[end:])
PY
# Workspace members inherit `version.workspace = true`, so only the
# lockfile's own entries for those members need refreshing.
cargo update --workspace --offline || cargo update --workspace
ACTUAL="$(read_version)"
if [ "$ACTUAL" != "$NEW_VERSION" ]; then
echo "::error::Version rewrite failed (Cargo.toml still reports '$ACTUAL')"
exit 1
fi
VERSION="$NEW_VERSION"
fi
TAG="oneclient-$VERSION"
echo "Releasing OneClient $VERSION"
# Abort rather than clobber: a published release (or an existing tag,
# which only appears once a release is published) means this version
# already shipped. A *draft* is a previous failed/partial run of this
# same workflow, so reuse it.
if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then
echo "::error::Tag $TAG already exists on origin - OneClient $VERSION has already been released. Re-run with a bump, or bump the version in Cargo.toml."
echo "::error::Tag $TAG already exists on origin - OneClient $VERSION has already been released. Add a change file with \`knope document-change\` first."
exit 1
fi
@ -123,17 +85,17 @@ jobs:
RELEASE_EXISTS=1
fi
if [ "$RELEASE_EXISTS" = "1" ] && [ "$IS_DRAFT" != "true" ]; then
echo "::error::Release $TAG already exists and is published - refusing to overwrite OneClient $VERSION. Re-run with a bump, or bump the version in Cargo.toml."
echo "::error::Release $TAG already exists and is published - refusing to overwrite OneClient $VERSION. Add a change file with \`knope document-change\` first."
exit 1
fi
# Commit the bump only after the guards pass, so a rejected release
# does not leave a stray version bump on the branch.
if [ "$BUMP" != "none" ]; then
# Commit only after the guards pass, so a rejected release does not
# leave a stray version bump on the branch.
if [ "$RELEASED" = "1" ]; then
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add Cargo.toml Cargo.lock
git commit -m "chore: bump to $VERSION"
git add -A Cargo.toml Cargo.lock .changeset CHANGELOG.md
git commit -m "chore: release $VERSION"
git push origin "HEAD:${GITHUB_REF_NAME}"
fi
@ -143,11 +105,18 @@ jobs:
echo "Reusing existing draft release $TAG"
gh release edit "$TAG" --target "$SHA"
else
# The body is what the launcher's changelog page shows
awk -v heading="## $VERSION " '/^## /{p = index($0, heading) == 1; next} p' \
CHANGELOG.md > notes.md 2>/dev/null || true
if ! grep -q '[^[:space:]]' notes.md; then
echo "::error::No changelog entry for $VERSION. Add a change file with \`knope document-change\` first."
exit 1
fi
gh release create "$TAG" \
--draft \
--target "$SHA" \
--title "OneClient $VERSION" \
--notes "Automated release for OneClient $VERSION."
--notes-file notes.md
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
@ -189,7 +158,7 @@ jobs:
runs-on: ${{ matrix.platform }}
steps:
# Pin to the commit `prepare` resolved - with `bump` that is the version
# Pin to the commit `prepare` resolved - after a release that is the version
# commit it just pushed, which github.sha would not point at.
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
@ -216,6 +185,7 @@ jobs:
libfontconfig-dev libfreetype-dev \
libgl1-mesa-dev libx11-dev libxcursor-dev libxi-dev libxrandr-dev \
libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev libdbus-1-dev \
libgtk-3-dev libayatana-appindicator3-dev \
libfuse2
- name: Install cargo-packager

171
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,171 @@
name: Release Multiplatform Binaries
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
description: 'Release Tag (e.g. v2.5.1)'
required: false
default: 'v2.5.1-c3'
type: string
permissions:
contents: write
env:
SQLX_OFFLINE: true
SQLX_OFFLINE_DIR: packages/oneclient_db/.sqlx
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
# Linux x86_64
- os: ubuntu-22.04
target: x86_64-unknown-linux-gnu
name: linux-x86_64
formats: deb,appimage
# Windows x86_64
- os: windows-latest
target: x86_64-pc-windows-msvc
name: windows-x86_64
formats: nsis
# macOS Apple Silicon
- os: macos-14
target: aarch64-apple-darwin
name: macos-arm64
formats: dmg,app
# macOS Intel (cross-compiled on macos-14)
- os: macos-14
target: x86_64-apple-darwin
name: macos-x86_64
formats: dmg,app
runs-on: ${{ matrix.os }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Rust Cache
uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.target }}
cache-all-crates: true
- name: Install Linux build dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
build-essential \
curl \
wget \
file \
libxdo-dev \
libssl-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
libfontconfig1-dev \
libfreetype6-dev \
libgl1-mesa-dev \
libvulkan-dev \
libasound2-dev
- name: Install cargo-packager
run: cargo install cargo-packager --locked || true
- name: Build Release Binary
run: cargo build --release --bin oneclient_app --target ${{ matrix.target }}
- name: Package Application
run: cargo packager --release --target ${{ matrix.target }} --formats ${{ matrix.formats }}
continue-on-error: true
- name: Generate RPM Package (Linux)
if: runner.os == 'Linux'
run: |
cargo install cargo-generate-rpm --locked || true
cargo generate-rpm --target ${{ matrix.target }} -p oneclient_app || true
- name: Upload Build Artifacts
uses: actions/upload-artifact@v4
with:
name: binaries-${{ matrix.name }}
if-no-files-found: warn
path: |
target/${{ matrix.target }}/release/*.deb
target/${{ matrix.target }}/release/*.AppImage
target/${{ matrix.target }}/release/*.dmg
target/${{ matrix.target }}/release/*-setup.exe
target/${{ matrix.target }}/generate-rpm/*.rpm
target/generate-rpm/*.rpm
target/release/*.deb
target/release/*.AppImage
target/release/*.dmg
target/release/*-setup.exe
create-release:
needs: build
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: ./artifacts
merge-multiple: true
- name: Determine Release Tag
id: release_tag
run: |
if [[ "${{ github.ref_name }}" =~ ^v ]]; then
echo "tag=${{ github.ref_name }}" >> $GITHUB_OUTPUT
elif [ -n "${{ inputs.tag }}" ]; then
echo "tag=${{ inputs.tag }}" >> $GITHUB_OUTPUT
else
echo "tag=v2.5.1-c3" >> $GITHUB_OUTPUT
fi
- name: Clean up previous release if exists
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release delete "${{ steps.release_tag.outputs.tag }}" -y --cleanup-tag=false || true
- name: Publish GitHub Release
uses: softprops/action-gh-release@v2
with:
files: ./artifacts/**/*
tag_name: ${{ steps.release_tag.outputs.tag }}
name: "OneLauncher-Cracked ${{ steps.release_tag.outputs.tag }}"
body: |
### What's New in ${{ steps.release_tag.outputs.tag }}
- **Complete Multiplayer Server Skin Support**: Native integration with client-side `OfflineSkins Reloaded` for Fabric 1.21.11, allowing skins to render directly on cracked multiplayer servers (e.g. `alwto.net`) when the server transmits empty player profiles.
- **Proper Isolated Offline Skin System (Authlib-Injector)**: Embedded, zero-network-dependency Authlib-Injector session provider with RSA signature verification. Your skin is attached strictly and exclusively to your player profile, completely eliminating the bug where other players or NPCs on offline servers would receive your skin.
- **Dynamic Version Compatibility**: Automatically gates offline skin mod integration strictly to matching versions, while Authlib-Injector handles clean, zero-dependency offline profile signing across all Minecraft versions.
- **Purged Incompatible Mods**: Completely cleaned and prevented CustomSkinLoader crashes on modern Fabric Loader (0.17+) and Sponge Mixin 0.8.7.
- **Vanilla & Multi-server Integrity**: Other offline players remain authentic vanilla Steve/Alex, while online players on hybrid servers display their legitimate Mojang skins via transparent caching session proxying.
- **Automatic Legacy Cleanup**: Restores pristine `client.jar` and removes stale skin resource packs from `options.txt` on launch.
- **Guaranteed Offline Game Launch**: Direct offline launching with 0 Microsoft accounts required.
- **In-Launcher 3D Skin Changer**: Full 3D skin preview, slim/classic arm model toggle, local file import, and online skin downloads.
- **Rebased on Upstream 2.5.1**: All upstream 2.5.1 features, hook error reporting, and metadata self-healing.
draft: false
prerelease: false
generate_release_notes: false
make_latest: true
fail_on_unmatched_files: false

1
.gitignore vendored
View file

@ -1,4 +1,5 @@
/target
/dist
.DS_Store
/.idea
OneLauncher.iml

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n INSERT OR IGNORE INTO cluster_bundle_choices (cluster_id, bundle_name, accepted)\n SELECT ?, bundle_name, accepted\n FROM cluster_bundle_choices\n WHERE cluster_id = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "001a994c740f7a015152319eb16fc113954af25ebd2a44d55e95833adda53268"
}

View file

@ -0,0 +1,170 @@
{
"db_name": "SQLite",
"query": "\n\t\tSELECT name, java_path, resolution, force_fullscreen, mem_max, launch_args, game_args, launch_env,\n\t\t hook_pre, hook_wrapper, hook_post, os_extra, browser_update_mode\n\t\tFROM setting_profiles\n\t\tORDER BY name ASC\n\t\t",
"describe": {
"columns": [
{
"name": "name",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "setting_profiles",
"name": "name"
}
}
},
{
"name": "java_path",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "setting_profiles",
"name": "java_path"
}
}
},
{
"name": "resolution",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "setting_profiles",
"name": "resolution"
}
}
},
{
"name": "force_fullscreen",
"ordinal": 3,
"type_info": "Integer",
"origin": {
"Table": {
"table": "setting_profiles",
"name": "force_fullscreen"
}
}
},
{
"name": "mem_max",
"ordinal": 4,
"type_info": "Integer",
"origin": {
"Table": {
"table": "setting_profiles",
"name": "mem_max"
}
}
},
{
"name": "launch_args",
"ordinal": 5,
"type_info": "Text",
"origin": {
"Table": {
"table": "setting_profiles",
"name": "launch_args"
}
}
},
{
"name": "game_args",
"ordinal": 6,
"type_info": "Text",
"origin": {
"Table": {
"table": "setting_profiles",
"name": "game_args"
}
}
},
{
"name": "launch_env",
"ordinal": 7,
"type_info": "Text",
"origin": {
"Table": {
"table": "setting_profiles",
"name": "launch_env"
}
}
},
{
"name": "hook_pre",
"ordinal": 8,
"type_info": "Text",
"origin": {
"Table": {
"table": "setting_profiles",
"name": "hook_pre"
}
}
},
{
"name": "hook_wrapper",
"ordinal": 9,
"type_info": "Text",
"origin": {
"Table": {
"table": "setting_profiles",
"name": "hook_wrapper"
}
}
},
{
"name": "hook_post",
"ordinal": 10,
"type_info": "Text",
"origin": {
"Table": {
"table": "setting_profiles",
"name": "hook_post"
}
}
},
{
"name": "os_extra",
"ordinal": 11,
"type_info": "Text",
"origin": {
"Table": {
"table": "setting_profiles",
"name": "os_extra"
}
}
},
{
"name": "browser_update_mode",
"ordinal": 12,
"type_info": "Text",
"origin": {
"Table": {
"table": "setting_profiles",
"name": "browser_update_mode"
}
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true
]
},
"hash": "01b2ef24541af92505a3c2bc14037a61470a393dffa06da8bebfba98138709d0"
}

View file

@ -0,0 +1,110 @@
{
"db_name": "SQLite",
"query": "\n\t\tSELECT\n\t\t\ttarget_cluster_id, provider, project_id, content_type,\n\t\t\tsource_hash, display_name, expires_at, enabled\n\t\tFROM release_migration_waitlist\n\t\t",
"describe": {
"columns": [
{
"name": "target_cluster_id",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "release_migration_waitlist",
"name": "target_cluster_id"
}
}
},
{
"name": "provider",
"ordinal": 1,
"type_info": "Integer",
"origin": {
"Table": {
"table": "release_migration_waitlist",
"name": "provider"
}
}
},
{
"name": "project_id",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "release_migration_waitlist",
"name": "project_id"
}
}
},
{
"name": "content_type",
"ordinal": 3,
"type_info": "Integer",
"origin": {
"Table": {
"table": "release_migration_waitlist",
"name": "content_type"
}
}
},
{
"name": "source_hash",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "release_migration_waitlist",
"name": "source_hash"
}
}
},
{
"name": "display_name",
"ordinal": 5,
"type_info": "Text",
"origin": {
"Table": {
"table": "release_migration_waitlist",
"name": "display_name"
}
}
},
{
"name": "expires_at",
"ordinal": 6,
"type_info": "Text",
"origin": {
"Table": {
"table": "release_migration_waitlist",
"name": "expires_at"
}
}
},
{
"name": "enabled",
"ordinal": 7,
"type_info": "Integer",
"origin": {
"Table": {
"table": "release_migration_waitlist",
"name": "enabled"
}
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "15dc690e91fe0bc7e1932a5051f8edd855deaeb31e52209ae436680962dea954"
}

View file

@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "\n SELECT bundle_name, accepted\n FROM cluster_bundle_choices\n WHERE cluster_id = ?\n ",
"describe": {
"columns": [
{
"name": "bundle_name",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "cluster_bundle_choices",
"name": "bundle_name"
}
}
},
{
"name": "accepted",
"ordinal": 1,
"type_info": "Integer",
"origin": {
"Table": {
"table": "cluster_bundle_choices",
"name": "accepted"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false
]
},
"hash": "171c6771d06656c2fe375f6a0da889f828f400e950f103f0ed44f5f98a48dc50"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM release_migration_waitlist WHERE target_cluster_id = ? AND provider = ? AND project_id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "1e43f93b56bb495e13826c734fe5fb64515cf1d7c28bc6a1bf1fb1645fdba2a1"
}

View file

@ -0,0 +1,218 @@
{
"db_name": "SQLite",
"query": "\n\t\tUPDATE clusters\n\t\tSET overall_played = COALESCE(overall_played, 0) + ?,\n\t\t last_played = ?\n\t\tWHERE id = ?\n\t\tRETURNING\n\t\t\tid, name, folder_name, setting_profile_name, mc_version, mc_loader,\n\t\t\tstage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash,\n\t\t\tkind, user_created, description, tags, cover_path\n\t\t",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "id"
}
}
},
{
"name": "name",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "name"
}
}
},
{
"name": "folder_name",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "folder_name"
}
}
},
{
"name": "setting_profile_name",
"ordinal": 3,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "setting_profile_name"
}
}
},
{
"name": "mc_version",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "mc_version"
}
}
},
{
"name": "mc_loader",
"ordinal": 5,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "mc_loader"
}
}
},
{
"name": "stage",
"ordinal": 6,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "stage"
}
}
},
{
"name": "mc_loader_version",
"ordinal": 7,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "mc_loader_version"
}
}
},
{
"name": "created_at",
"ordinal": 8,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "created_at"
}
}
},
{
"name": "last_played",
"ordinal": 9,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "last_played"
}
}
},
{
"name": "overall_played",
"ordinal": 10,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "overall_played"
}
}
},
{
"name": "linked_modpack_hash",
"ordinal": 11,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "linked_modpack_hash"
}
}
},
{
"name": "kind",
"ordinal": 12,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "kind"
}
}
},
{
"name": "user_created",
"ordinal": 13,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "user_created"
}
}
},
{
"name": "description",
"ordinal": 14,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "description"
}
}
},
{
"name": "tags",
"ordinal": 15,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "tags"
}
}
},
{
"name": "cover_path",
"ordinal": 16,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "cover_path"
}
}
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
false,
false,
true,
false,
false,
false,
true,
true,
true,
true,
true,
false,
false,
true,
false,
true
]
},
"hash": "4c6d40e97401e1321abccd7f413b49dda16a5bdf29eb90459ac140d8208db479"
}

View file

@ -0,0 +1,218 @@
{
"db_name": "SQLite",
"query": "\n\t\tINSERT INTO clusters (\n\t\t\tname, folder_name, mc_version, mc_loader, mc_loader_version,\n\t\t\tsetting_profile_name, stage, created_at,\n\t\t\tkind, user_created, description, tags, cover_path\n\t\t)\n\t\tVALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\tRETURNING\n\t\t\tid, name, folder_name, setting_profile_name, mc_version, mc_loader,\n\t\t\tstage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash,\n\t\t\tkind, user_created, description, tags, cover_path\n\t\t",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "id"
}
}
},
{
"name": "name",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "name"
}
}
},
{
"name": "folder_name",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "folder_name"
}
}
},
{
"name": "setting_profile_name",
"ordinal": 3,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "setting_profile_name"
}
}
},
{
"name": "mc_version",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "mc_version"
}
}
},
{
"name": "mc_loader",
"ordinal": 5,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "mc_loader"
}
}
},
{
"name": "stage",
"ordinal": 6,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "stage"
}
}
},
{
"name": "mc_loader_version",
"ordinal": 7,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "mc_loader_version"
}
}
},
{
"name": "created_at",
"ordinal": 8,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "created_at"
}
}
},
{
"name": "last_played",
"ordinal": 9,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "last_played"
}
}
},
{
"name": "overall_played",
"ordinal": 10,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "overall_played"
}
}
},
{
"name": "linked_modpack_hash",
"ordinal": 11,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "linked_modpack_hash"
}
}
},
{
"name": "kind",
"ordinal": 12,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "kind"
}
}
},
{
"name": "user_created",
"ordinal": 13,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "user_created"
}
}
},
{
"name": "description",
"ordinal": 14,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "description"
}
}
},
{
"name": "tags",
"ordinal": 15,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "tags"
}
}
},
{
"name": "cover_path",
"ordinal": 16,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "cover_path"
}
}
}
],
"parameters": {
"Right": 13
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
true,
true,
true,
true,
true,
false,
false,
true,
false,
true
]
},
"hash": "54dea3f4093ceee9dcd5f0ae9bf072ec3615954b60bffa33bff7fd3f9bf14210"
}

View file

@ -0,0 +1,170 @@
{
"db_name": "SQLite",
"query": "\n\t\tINSERT INTO setting_profiles (\n\t\t\tname, java_path, resolution, force_fullscreen, mem_max, launch_args, game_args, launch_env,\n\t\t\thook_pre, hook_wrapper, hook_post, os_extra, browser_update_mode\n\t\t)\n\t\tVALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\tON CONFLICT(name) DO UPDATE SET\n\t\t\tjava_path = excluded.java_path,\n\t\t\tresolution = excluded.resolution,\n\t\t\tforce_fullscreen = excluded.force_fullscreen,\n\t\t\tmem_max = excluded.mem_max,\n\t\t\tlaunch_args = excluded.launch_args,\n\t\t\tgame_args = excluded.game_args,\n\t\t\tlaunch_env = excluded.launch_env,\n\t\t\thook_pre = excluded.hook_pre,\n\t\t\thook_wrapper = excluded.hook_wrapper,\n\t\t\thook_post = excluded.hook_post,\n\t\t\tos_extra = excluded.os_extra,\n\t\t\tbrowser_update_mode = excluded.browser_update_mode\n\t\tRETURNING name, java_path, resolution, force_fullscreen, mem_max, launch_args, game_args, launch_env,\n hook_pre, hook_wrapper, hook_post, os_extra, browser_update_mode\n\t\t",
"describe": {
"columns": [
{
"name": "name",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "setting_profiles",
"name": "name"
}
}
},
{
"name": "java_path",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "setting_profiles",
"name": "java_path"
}
}
},
{
"name": "resolution",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "setting_profiles",
"name": "resolution"
}
}
},
{
"name": "force_fullscreen",
"ordinal": 3,
"type_info": "Integer",
"origin": {
"Table": {
"table": "setting_profiles",
"name": "force_fullscreen"
}
}
},
{
"name": "mem_max",
"ordinal": 4,
"type_info": "Integer",
"origin": {
"Table": {
"table": "setting_profiles",
"name": "mem_max"
}
}
},
{
"name": "launch_args",
"ordinal": 5,
"type_info": "Text",
"origin": {
"Table": {
"table": "setting_profiles",
"name": "launch_args"
}
}
},
{
"name": "game_args",
"ordinal": 6,
"type_info": "Text",
"origin": {
"Table": {
"table": "setting_profiles",
"name": "game_args"
}
}
},
{
"name": "launch_env",
"ordinal": 7,
"type_info": "Text",
"origin": {
"Table": {
"table": "setting_profiles",
"name": "launch_env"
}
}
},
{
"name": "hook_pre",
"ordinal": 8,
"type_info": "Text",
"origin": {
"Table": {
"table": "setting_profiles",
"name": "hook_pre"
}
}
},
{
"name": "hook_wrapper",
"ordinal": 9,
"type_info": "Text",
"origin": {
"Table": {
"table": "setting_profiles",
"name": "hook_wrapper"
}
}
},
{
"name": "hook_post",
"ordinal": 10,
"type_info": "Text",
"origin": {
"Table": {
"table": "setting_profiles",
"name": "hook_post"
}
}
},
{
"name": "os_extra",
"ordinal": 11,
"type_info": "Text",
"origin": {
"Table": {
"table": "setting_profiles",
"name": "os_extra"
}
}
},
{
"name": "browser_update_mode",
"ordinal": 12,
"type_info": "Text",
"origin": {
"Table": {
"table": "setting_profiles",
"name": "browser_update_mode"
}
}
}
],
"parameters": {
"Right": 13
},
"nullable": [
false,
false,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true
]
},
"hash": "598a34a0eb0f4d497cfd9bd77696310d1088e8baec677e07843a21ce6978652e"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n INSERT OR IGNORE INTO cluster_bundle_type_opt_outs (cluster_id, bundle_name, content_type)\n VALUES (?, ?, ?)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "59a29c97c422e8721c29b9517ae834ea97776c57bb129a9d67007cdd979ae841"
}

View file

@ -0,0 +1,218 @@
{
"db_name": "SQLite",
"query": "\n\t\tSELECT\n\t\t\tid, name, folder_name, setting_profile_name, mc_version, mc_loader,\n\t\t\tstage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash,\n\t\t\tkind, user_created, description, tags, cover_path\n\t\tFROM clusters\n\t\tWHERE folder_name = ?\n\t\t",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "id"
}
}
},
{
"name": "name",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "name"
}
}
},
{
"name": "folder_name",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "folder_name"
}
}
},
{
"name": "setting_profile_name",
"ordinal": 3,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "setting_profile_name"
}
}
},
{
"name": "mc_version",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "mc_version"
}
}
},
{
"name": "mc_loader",
"ordinal": 5,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "mc_loader"
}
}
},
{
"name": "stage",
"ordinal": 6,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "stage"
}
}
},
{
"name": "mc_loader_version",
"ordinal": 7,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "mc_loader_version"
}
}
},
{
"name": "created_at",
"ordinal": 8,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "created_at"
}
}
},
{
"name": "last_played",
"ordinal": 9,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "last_played"
}
}
},
{
"name": "overall_played",
"ordinal": 10,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "overall_played"
}
}
},
{
"name": "linked_modpack_hash",
"ordinal": 11,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "linked_modpack_hash"
}
}
},
{
"name": "kind",
"ordinal": 12,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "kind"
}
}
},
{
"name": "user_created",
"ordinal": 13,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "user_created"
}
}
},
{
"name": "description",
"ordinal": 14,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "description"
}
}
},
{
"name": "tags",
"ordinal": 15,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "tags"
}
}
},
{
"name": "cover_path",
"ordinal": 16,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "cover_path"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
true,
false,
false,
false,
true,
true,
true,
true,
true,
false,
false,
true,
false,
true
]
},
"hash": "5e18763a28af3660503e223c35875454b1e3fc4bb10c19d1c286d3e45afdd159"
}

View file

@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "\n SELECT bundle_name, content_type\n FROM cluster_bundle_type_opt_outs\n WHERE cluster_id = ?\n ",
"describe": {
"columns": [
{
"name": "bundle_name",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "cluster_bundle_type_opt_outs",
"name": "bundle_name"
}
}
},
{
"name": "content_type",
"ordinal": 1,
"type_info": "Integer",
"origin": {
"Table": {
"table": "cluster_bundle_type_opt_outs",
"name": "content_type"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false
]
},
"hash": "5f45ecf4e5ac27ad3a7ef4e5f9c2e6466ef51af11c332a2e6089c90759b9871f"
}

View file

@ -0,0 +1,170 @@
{
"db_name": "SQLite",
"query": "\n\t\tSELECT name, java_path, resolution, force_fullscreen, mem_max, launch_args, game_args, launch_env,\n\t\t hook_pre, hook_wrapper, hook_post, os_extra, browser_update_mode\n\t\tFROM setting_profiles\n\t\tWHERE name = ?\n\t\t",
"describe": {
"columns": [
{
"name": "name",
"ordinal": 0,
"type_info": "Text",
"origin": {
"Table": {
"table": "setting_profiles",
"name": "name"
}
}
},
{
"name": "java_path",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "setting_profiles",
"name": "java_path"
}
}
},
{
"name": "resolution",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "setting_profiles",
"name": "resolution"
}
}
},
{
"name": "force_fullscreen",
"ordinal": 3,
"type_info": "Integer",
"origin": {
"Table": {
"table": "setting_profiles",
"name": "force_fullscreen"
}
}
},
{
"name": "mem_max",
"ordinal": 4,
"type_info": "Integer",
"origin": {
"Table": {
"table": "setting_profiles",
"name": "mem_max"
}
}
},
{
"name": "launch_args",
"ordinal": 5,
"type_info": "Text",
"origin": {
"Table": {
"table": "setting_profiles",
"name": "launch_args"
}
}
},
{
"name": "game_args",
"ordinal": 6,
"type_info": "Text",
"origin": {
"Table": {
"table": "setting_profiles",
"name": "game_args"
}
}
},
{
"name": "launch_env",
"ordinal": 7,
"type_info": "Text",
"origin": {
"Table": {
"table": "setting_profiles",
"name": "launch_env"
}
}
},
{
"name": "hook_pre",
"ordinal": 8,
"type_info": "Text",
"origin": {
"Table": {
"table": "setting_profiles",
"name": "hook_pre"
}
}
},
{
"name": "hook_wrapper",
"ordinal": 9,
"type_info": "Text",
"origin": {
"Table": {
"table": "setting_profiles",
"name": "hook_wrapper"
}
}
},
{
"name": "hook_post",
"ordinal": 10,
"type_info": "Text",
"origin": {
"Table": {
"table": "setting_profiles",
"name": "hook_post"
}
}
},
{
"name": "os_extra",
"ordinal": 11,
"type_info": "Text",
"origin": {
"Table": {
"table": "setting_profiles",
"name": "os_extra"
}
}
},
{
"name": "browser_update_mode",
"ordinal": 12,
"type_info": "Text",
"origin": {
"Table": {
"table": "setting_profiles",
"name": "browser_update_mode"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true
]
},
"hash": "86e3ba70da419464c0be3b3971f613c5a112558472cf2dcdeffa5907e3785318"
}

View file

@ -0,0 +1,218 @@
{
"db_name": "SQLite",
"query": "\n\t\tSELECT\n\t\t\tid, name, folder_name, setting_profile_name, mc_version, mc_loader,\n\t\t\tstage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash,\n\t\t\tkind, user_created, description, tags, cover_path\n\t\tFROM clusters\n\t\tWHERE id = ?\n\t\t",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "id"
}
}
},
{
"name": "name",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "name"
}
}
},
{
"name": "folder_name",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "folder_name"
}
}
},
{
"name": "setting_profile_name",
"ordinal": 3,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "setting_profile_name"
}
}
},
{
"name": "mc_version",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "mc_version"
}
}
},
{
"name": "mc_loader",
"ordinal": 5,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "mc_loader"
}
}
},
{
"name": "stage",
"ordinal": 6,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "stage"
}
}
},
{
"name": "mc_loader_version",
"ordinal": 7,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "mc_loader_version"
}
}
},
{
"name": "created_at",
"ordinal": 8,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "created_at"
}
}
},
{
"name": "last_played",
"ordinal": 9,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "last_played"
}
}
},
{
"name": "overall_played",
"ordinal": 10,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "overall_played"
}
}
},
{
"name": "linked_modpack_hash",
"ordinal": 11,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "linked_modpack_hash"
}
}
},
{
"name": "kind",
"ordinal": 12,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "kind"
}
}
},
{
"name": "user_created",
"ordinal": 13,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "user_created"
}
}
},
{
"name": "description",
"ordinal": 14,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "description"
}
}
},
{
"name": "tags",
"ordinal": 15,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "tags"
}
}
},
{
"name": "cover_path",
"ordinal": 16,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "cover_path"
}
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
true,
false,
false,
false,
true,
true,
true,
true,
true,
false,
false,
true,
false,
true
]
},
"hash": "9b769a1467201ee998290c51b2cf51144a34c01975b898ee749b4e2111856119"
}

View file

@ -0,0 +1,218 @@
{
"db_name": "SQLite",
"query": "\n\t\tUPDATE clusters\n\t\tSET name = ?,\n\t\t setting_profile_name = ?,\n\t\t mc_loader_version = ?,\n\t\t linked_modpack_hash = ?,\n\t\t description = ?,\n\t\t tags = ?,\n\t\t cover_path = ?\n\t\tWHERE id = ?\n\t\tRETURNING\n\t\t\tid, name, folder_name, setting_profile_name, mc_version, mc_loader,\n\t\t\tstage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash,\n\t\t\tkind, user_created, description, tags, cover_path\n\t\t",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "id"
}
}
},
{
"name": "name",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "name"
}
}
},
{
"name": "folder_name",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "folder_name"
}
}
},
{
"name": "setting_profile_name",
"ordinal": 3,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "setting_profile_name"
}
}
},
{
"name": "mc_version",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "mc_version"
}
}
},
{
"name": "mc_loader",
"ordinal": 5,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "mc_loader"
}
}
},
{
"name": "stage",
"ordinal": 6,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "stage"
}
}
},
{
"name": "mc_loader_version",
"ordinal": 7,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "mc_loader_version"
}
}
},
{
"name": "created_at",
"ordinal": 8,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "created_at"
}
}
},
{
"name": "last_played",
"ordinal": 9,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "last_played"
}
}
},
{
"name": "overall_played",
"ordinal": 10,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "overall_played"
}
}
},
{
"name": "linked_modpack_hash",
"ordinal": 11,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "linked_modpack_hash"
}
}
},
{
"name": "kind",
"ordinal": 12,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "kind"
}
}
},
{
"name": "user_created",
"ordinal": 13,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "user_created"
}
}
},
{
"name": "description",
"ordinal": 14,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "description"
}
}
},
{
"name": "tags",
"ordinal": 15,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "tags"
}
}
},
{
"name": "cover_path",
"ordinal": 16,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "cover_path"
}
}
}
],
"parameters": {
"Right": 8
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
true,
true,
true,
true,
true,
false,
false,
true,
false,
true
]
},
"hash": "9e4cbbc2c299861132749196c4e248e4309c2449e181b3b9ae16e7f7b4d9e627"
}

View file

@ -0,0 +1,218 @@
{
"db_name": "SQLite",
"query": "\n\t\tUPDATE clusters\n\t\tSET mc_version = ?,\n\t\t mc_loader = ?,\n\t\t mc_loader_version = ?,\n\t\t stage = ?,\n\t\t name = ?,\n\t\t folder_name = ?\n\t\tWHERE id = ?\n\t\tRETURNING\n\t\t\tid, name, folder_name, setting_profile_name, mc_version, mc_loader,\n\t\t\tstage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash,\n\t\t\tkind, user_created, description, tags, cover_path\n\t\t",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "id"
}
}
},
{
"name": "name",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "name"
}
}
},
{
"name": "folder_name",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "folder_name"
}
}
},
{
"name": "setting_profile_name",
"ordinal": 3,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "setting_profile_name"
}
}
},
{
"name": "mc_version",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "mc_version"
}
}
},
{
"name": "mc_loader",
"ordinal": 5,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "mc_loader"
}
}
},
{
"name": "stage",
"ordinal": 6,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "stage"
}
}
},
{
"name": "mc_loader_version",
"ordinal": 7,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "mc_loader_version"
}
}
},
{
"name": "created_at",
"ordinal": 8,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "created_at"
}
}
},
{
"name": "last_played",
"ordinal": 9,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "last_played"
}
}
},
{
"name": "overall_played",
"ordinal": 10,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "overall_played"
}
}
},
{
"name": "linked_modpack_hash",
"ordinal": 11,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "linked_modpack_hash"
}
}
},
{
"name": "kind",
"ordinal": 12,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "kind"
}
}
},
{
"name": "user_created",
"ordinal": 13,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "user_created"
}
}
},
{
"name": "description",
"ordinal": 14,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "description"
}
}
},
{
"name": "tags",
"ordinal": 15,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "tags"
}
}
},
{
"name": "cover_path",
"ordinal": 16,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "cover_path"
}
}
}
],
"parameters": {
"Right": 7
},
"nullable": [
false,
false,
false,
true,
false,
false,
false,
true,
true,
true,
true,
true,
false,
false,
true,
false,
true
]
},
"hash": "aeb2fbb78cfa536d6478878ccafb4f4754dfe057468fe06c22d87c3a062132b7"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n DELETE FROM cluster_bundle_overrides\n WHERE cluster_id = ? AND package_id = ? AND override_type = 'disabled'\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "af0567c14b689a3f53207179ba9ee2e369b99542369fa5401aa4057d0b326b86"
}

View file

@ -0,0 +1,218 @@
{
"db_name": "SQLite",
"query": "\n\t\tSELECT\n\t\t\tid, name, folder_name, setting_profile_name, mc_version, mc_loader,\n\t\t\tstage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash,\n\t\t\tkind, user_created, description, tags, cover_path\n\t\tFROM clusters\n\t\tWHERE mc_version = ? AND mc_loader = ? AND user_created = 0\n\t\tLIMIT 1\n\t\t",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "id"
}
}
},
{
"name": "name",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "name"
}
}
},
{
"name": "folder_name",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "folder_name"
}
}
},
{
"name": "setting_profile_name",
"ordinal": 3,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "setting_profile_name"
}
}
},
{
"name": "mc_version",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "mc_version"
}
}
},
{
"name": "mc_loader",
"ordinal": 5,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "mc_loader"
}
}
},
{
"name": "stage",
"ordinal": 6,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "stage"
}
}
},
{
"name": "mc_loader_version",
"ordinal": 7,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "mc_loader_version"
}
}
},
{
"name": "created_at",
"ordinal": 8,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "created_at"
}
}
},
{
"name": "last_played",
"ordinal": 9,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "last_played"
}
}
},
{
"name": "overall_played",
"ordinal": 10,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "overall_played"
}
}
},
{
"name": "linked_modpack_hash",
"ordinal": 11,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "linked_modpack_hash"
}
}
},
{
"name": "kind",
"ordinal": 12,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "kind"
}
}
},
{
"name": "user_created",
"ordinal": 13,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "user_created"
}
}
},
{
"name": "description",
"ordinal": 14,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "description"
}
}
},
{
"name": "tags",
"ordinal": 15,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "tags"
}
}
},
{
"name": "cover_path",
"ordinal": 16,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "cover_path"
}
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false,
true,
false,
false,
false,
true,
true,
true,
true,
true,
false,
false,
true,
false,
true
]
},
"hash": "bc1502f185c5e73b59057f9caa0a892fbc2fa5dc45e63b391176e61f83a7676d"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n INSERT OR REPLACE INTO cluster_bundle_choices (cluster_id, bundle_name, accepted)\n VALUES (?, ?, ?)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "c06af5530a824d31e6e18a3a52cd57639b74e7e4d2ae30e29a6c49fda9521b4d"
}

View file

@ -0,0 +1,218 @@
{
"db_name": "SQLite",
"query": "\n\t\tUPDATE clusters\n\t\tSET stage = ?\n\t\tWHERE id = ?\n\t\tRETURNING\n\t\t\tid, name, folder_name, setting_profile_name, mc_version, mc_loader,\n\t\t\tstage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash,\n\t\t\tkind, user_created, description, tags, cover_path\n\t\t",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "id"
}
}
},
{
"name": "name",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "name"
}
}
},
{
"name": "folder_name",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "folder_name"
}
}
},
{
"name": "setting_profile_name",
"ordinal": 3,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "setting_profile_name"
}
}
},
{
"name": "mc_version",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "mc_version"
}
}
},
{
"name": "mc_loader",
"ordinal": 5,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "mc_loader"
}
}
},
{
"name": "stage",
"ordinal": 6,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "stage"
}
}
},
{
"name": "mc_loader_version",
"ordinal": 7,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "mc_loader_version"
}
}
},
{
"name": "created_at",
"ordinal": 8,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "created_at"
}
}
},
{
"name": "last_played",
"ordinal": 9,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "last_played"
}
}
},
{
"name": "overall_played",
"ordinal": 10,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "overall_played"
}
}
},
{
"name": "linked_modpack_hash",
"ordinal": 11,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "linked_modpack_hash"
}
}
},
{
"name": "kind",
"ordinal": 12,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "kind"
}
}
},
{
"name": "user_created",
"ordinal": 13,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "user_created"
}
}
},
{
"name": "description",
"ordinal": 14,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "description"
}
}
},
{
"name": "tags",
"ordinal": 15,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "tags"
}
}
},
{
"name": "cover_path",
"ordinal": 16,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "cover_path"
}
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false,
true,
false,
false,
false,
true,
true,
true,
true,
true,
false,
false,
true,
false,
true
]
},
"hash": "c3084a5369554a3ada17a50c5e33f8a6c414a9a213fd35c001c957eeebca3ff0"
}

View file

@ -0,0 +1,218 @@
{
"db_name": "SQLite",
"query": "\n\t\tSELECT\n\t\t\tid, name, folder_name, setting_profile_name, mc_version, mc_loader,\n\t\t\tstage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash,\n\t\t\tkind, user_created, description, tags, cover_path\n\t\tFROM clusters\n\t\tORDER BY last_played IS NULL, last_played DESC, name ASC\n\t\t",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "id"
}
}
},
{
"name": "name",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "name"
}
}
},
{
"name": "folder_name",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "folder_name"
}
}
},
{
"name": "setting_profile_name",
"ordinal": 3,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "setting_profile_name"
}
}
},
{
"name": "mc_version",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "mc_version"
}
}
},
{
"name": "mc_loader",
"ordinal": 5,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "mc_loader"
}
}
},
{
"name": "stage",
"ordinal": 6,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "stage"
}
}
},
{
"name": "mc_loader_version",
"ordinal": 7,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "mc_loader_version"
}
}
},
{
"name": "created_at",
"ordinal": 8,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "created_at"
}
}
},
{
"name": "last_played",
"ordinal": 9,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "last_played"
}
}
},
{
"name": "overall_played",
"ordinal": 10,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "overall_played"
}
}
},
{
"name": "linked_modpack_hash",
"ordinal": 11,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "linked_modpack_hash"
}
}
},
{
"name": "kind",
"ordinal": 12,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "kind"
}
}
},
{
"name": "user_created",
"ordinal": 13,
"type_info": "Integer",
"origin": {
"Table": {
"table": "clusters",
"name": "user_created"
}
}
},
{
"name": "description",
"ordinal": 14,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "description"
}
}
},
{
"name": "tags",
"ordinal": 15,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "tags"
}
}
},
{
"name": "cover_path",
"ordinal": 16,
"type_info": "Text",
"origin": {
"Table": {
"table": "clusters",
"name": "cover_path"
}
}
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
true,
false,
false,
false,
true,
true,
true,
true,
true,
false,
false,
true,
false,
true
]
},
"hash": "d636f4db52b3bb77d9d887500afbff5658afa10db3cf56794b8fa5e12535f3f7"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n DELETE FROM cluster_bundle_type_opt_outs\n WHERE cluster_id = ? AND bundle_name = ? AND content_type = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "dff3f2fe8e50eaa7ffebab7490306bc40e846ad708975991346aab83870e0968"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n\t\tINSERT INTO release_migration_waitlist (\n\t\t\ttarget_cluster_id, provider, project_id, content_type,\n\t\t\tsource_hash, display_name, added_at, expires_at, enabled\n\t\t)\n\t\tVALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\tON CONFLICT(target_cluster_id, provider, project_id) DO UPDATE SET\n\t\t\tcontent_type = excluded.content_type,\n\t\t\tsource_hash = excluded.source_hash,\n\t\t\tdisplay_name = excluded.display_name,\n\t\t\tadded_at = excluded.added_at,\n\t\t\texpires_at = excluded.expires_at,\n\t\t\tenabled = excluded.enabled\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 9
},
"nullable": []
},
"hash": "e12409b04c8a3eea09a4bfc1014bca6fe5fbc1256b3537ed37195ed20075f944"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM release_migration_waitlist WHERE expires_at <= ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "ff07b78c0e97f1f38a6900092391d22e475471578b5ef73927e5427c2c0abc77"
}

25
AGENTS.md Normal file
View file

@ -0,0 +1,25 @@
# OneLauncher-Cracked Development & Release Guidelines
## 1. Release Versioning Policy
- **Suffix Progression**: Every fix, rebuild, or update to an existing release MUST increment the cracked revision suffix monotonically:
- Example: `v2.8.0-c1` -> `v2.8.0-c2` -> `v2.8.0-c3`
- **Tag Immutability**: Never force-push or re-upload assets over an existing release tag. The in-launcher `CrackedVersion` SemVer parser relies on strictly increasing revisions to detect and download updates.
- **Cargo.toml Version**: When bumping the suffix, update the workspace `version = "2.8.0-c<N>"` in the root `Cargo.toml`.
## 2. Remote Synchronization (Dual-Push)
- Primary Forgejo remote: `https://git.oiupoyt.space/oiupoyt/OneLauncher-Cracked.git` (remote: `forgejo`)
- GitHub remote: `https://github.com/oiupoyt/OneLauncher-Cracked.git` (remote: `origin`)
- Always push to both remotes:
```bash
git push origin <branch> --tags
git push forgejo <branch> --tags
```
## 3. Build & Compiler Performance
- **ThinLTO**: Use `lto = "thin"` with `codegen-units = 16` and `debug = false` in `[profile.release]`. Do not use `lto = "fat"` (causes 10m+ single-threaded linking stalls).
- **Fast Linker**: On Linux, use `clang -fuse-ld=lld` configured in `.cargo/config.toml`.
- **Offline Mode & Skin Pipeline**:
- Preserve `account_for_launch` bypass in `oneclient_auth`.
- Always verify `paths::all_skins_dirs()` for multi-directory skin search.
- Keep `--userProperties` argument injection in `oneclient_core/src/game/launch.rs`.
- Ensure TCP socket `flush()` and `shutdown()` in `skin_server.rs` before connection termination.

34
CHANGELOG.md Normal file
View file

@ -0,0 +1,34 @@
## 2.8.0 (2026-10-09)
### Features
- Added safe multiple minecraft instances launching, only dedicated clusters tho by [rozwader](https://github.com/rozwader)
### Fixes
- Added better informations about errors from mclogs. by [rozwader](https://github.com/rozwader)
- Added status bar showing up if a player is logged out or he has a stale refresh token by [rozwader](https://github.com/rozwader)
- Added a filter for choosing modpack loaders by [rozwader](https://github.com/rozwader)
- SkyBlock content installed without asking is removed from 26.3 clusters once, and turning an optional bundle down removes its mods
- Fixed shield.io badges not rendering properly in markdown viewers by [rozwader](https://github.com/rozwader)
## 2.7.0 (2026-10-05)
### Features
- Code blocks are selectable, and the open link confirmation has a copy button by [LynithDev](https://github.com/LynithDev)
- Game settings have a Game Arguments field for passing extra arguments to Minecraft, globally or per cluster by [Wyvest](https://github.com/Wyvest)
- Game logs hide account tokens, and the game output starts with the launch command ready to paste into a terminal by [LynithDev](https://github.com/LynithDev)
- GitHub hosted mods have a "View in browser" option that opens their repository by [LynithDev](https://github.com/LynithDev)
- The changelog page now shows each GitHub release's notes by [LynithDev](https://github.com/LynithDev)
- The mods folder now syncs with the launcher: jars added, renamed or replaced by hand show up in the Mods list, and jars deleted by hand are switched off instead of being put back by [Wyvest](https://github.com/Wyvest)
- Choose which GPU the game renders on by [LynithDev](https://github.com/LynithDev)
### Fixes
- Icons show on Linux systems with a comma decimal locale by [LynithDev](https://github.com/LynithDev)
- The "Uploaded to mclo.gs" notification no longer repeats when switching back to the Logs tab by [LynithDev](https://github.com/LynithDev)
- Open folder and open in browser work again on Linux by [LynithDev](https://github.com/LynithDev)
- Optional bundles are now offered before the first launch of every new version instead of being installed or skipped silently by [Wyvest](https://github.com/Wyvest)
- System information in the settings sidebar shows a tooltip explaining it can be clicked to copy by [LynithDev](https://github.com/LynithDev)

View file

@ -32,7 +32,7 @@ To find an issue that interests you, you can browse through our [existing issues
#### Making Changes Locally
This project is a pure Rust [`Cargo`] workspace (edition 2024). Make sure you have a
recent Rust toolchain (`rustc` **1.85+**) installed before proceeding - the easiest way
recent Rust toolchain (`rustc` **1.97+**) installed before proceeding - the easiest way
is via [rustup].
To make changes locally, follow these steps:
@ -73,6 +73,15 @@ and per-OS prerequisites.
Once you have finished making your changes, create a pull request (PR) to submit them.
- Fill out the template to help reviewers understand your changes and the purpose of your PR.
- If your change is user-facing, add a change file with `knope document-change`, or create `.changeset/<anything>.md` by hand:
```md
---
default: patch
---
# Fixed the thing players noticed
```
Use `patch` for fixes, `minor` for features and `major` for breaking changes. The title becomes your line in the release notes. PRs without one fail the `Changeset Check` unless a maintainer labels them `no changeset`.
- If you are addressing an existing issue, don't forget to [link your PR to the issue].
- Enable the checkbox to [allow maintainer edits] so that the branch can be updated for merging.
- Once you submit your PR, a team member will review your proposal. They may ask questions or request additional information.

2428
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -9,13 +9,14 @@ authors = [
"Polyfrost",
"Polyfrost Contributors",
]
documentation = "https://github.com/Polyfrost/OneLauncher"
documentation = "https://git.oiupoyt.space/oiupoyt/OneLauncher-Cracked"
edition = "2024"
homepage = "https://polyfrost.org/projects/oneclient"
license = "GPL-3.0-only"
readme = "README.md"
repository = "https://github.com/Polyfrost/OneLauncher"
version = "2.4.0"
repository = "https://git.oiupoyt.space/oiupoyt/OneLauncher-Cracked"
rust-version = "1.97"
version = "2.8.0-c1"
[workspace.dependencies]
# core
@ -36,10 +37,10 @@ polyio = {path = "./packages/polyio"}
# core (extra)
discord-rich-presence = {version = "=1.1.0"}
interfrost = {version = "=1.4.0"}
interfrost = {version = "=1.5.0"}
# logging
sentry = {version = "=0.48.5", default-features = false, features = [
sentry = {version = "=0.49.3", default-features = false, features = [
"backtrace",
"contexts",
"debug-images",
@ -48,7 +49,7 @@ sentry = {version = "=0.48.5", default-features = false, features = [
"reqwest",
"rustls",
]}
sentry-tracing = {version = "=0.48.5"}
sentry-tracing = {version = "=0.49.3"}
tracing = {version = "=0.1.44"}
tracing-futures = {version = "=0.2.5"}
tracing-subscriber = {version = "=0.3.23", features = ["env-filter", "fmt", "ansi", "json", "parking_lot", "registry"]}
@ -67,15 +68,15 @@ sqlx = {version = "=0.9.0", default-features = false, features = [
]}
# async
astral-tokio-tar = {version = "=0.6.2"}
async-compression = {version = "=0.4.42", features = ["tokio", "gzip"]}
astral-tokio-tar = {version = "=0.7.0"}
async-compression = {version = "=0.4.48", features = ["tokio", "gzip"]}
async-stream = {version = "=0.3.6"}
async-tempfile = {version = "=0.7.0", features = ["uuid"]}
async-trait = {version = "=0.1.89"}
async_zip = {version = "=0.0.18", features = ["chrono", "tokio", "tokio-fs", "deflate", "deflate64"]}
async-tempfile = {version = "=0.8.0", features = ["uuid"]}
async-trait = {version = "=0.1.92"}
async_zip = {version = "=0.0.19", features = ["tracing", "chrono", "tokio", "tokio-fs", "deflate", "deflate64"]}
futures-lite = {version = "=2.6.1"}
futures-util = {version = "=0.3.32", features = ["tokio-io"]}
reqwest = {version = "=0.13.3", default-features = false, features = [
futures-util = {version = "=0.3.34", features = ["tokio-io"]}
reqwest = {version = "=0.13.5", default-features = false, features = [
"json",
"stream",
"multipart",
@ -90,67 +91,69 @@ reqwest = {version = "=0.13.3", default-features = false, features = [
"system-proxy",
]}
reqwest-websocket = {version = "=0.6.0"}
tokio = {version = "=1.52.3", features = ["full"]}
tokio-stream = {version = "=0.1.18", features = ["full"]}
tokio-util = {version = "=0.7.18", features = ["full"]}
tokio = {version = "=1.53.1", features = ["full"]}
tokio-stream = {version = "=0.1.19", features = ["full"]}
tokio-util = {version = "=0.7.19", features = ["full"]}
# crypto (content hashing)
byteorder = {version = "=1.5.0"}
digest = {version = "=0.10.7", features = ["std"]}
md-5 = {version = "=0.10.6", default-features = false}
p256 = {version = "=0.13.2", features = ["ecdsa", "pkcs8"]}
sha1 = {version = "=0.10.6", default-features = false}
sha2 = {version = "=0.10.9", default-features = false}
digest = {version = "=0.11.3"}
md-5 = {version = "=0.11.0", default-features = false}
p256 = {version = "=0.14.0", features = ["ecdsa", "pkcs8"]}
sha1 = {version = "=0.11.0", default-features = false}
sha2 = {version = "=0.11.0", default-features = false}
# util
arboard = {version = "=3.6.1"}
arc-swap = {version = "=1.7.1"}
base64 = {version = "=0.22.1"}
bytes = {version = "=1.11.1"}
chrono = {version = "=0.4.44", features = ["serde"]}
arboard = {version = "=3.6.1", features = ["wayland-data-control"]}
arc-swap = {version = "=1.9.2"}
base64 = {version = "=0.23.1"}
bytes = {version = "=1.12.1"}
chrono = {version = "=0.4.45", features = ["serde"]}
directories = {version = "=6.0.0"}
dunce = {version = "=1.0.5"}
htmd = {version = "=0.5.4"}
indicatif = {version = "=0.18.4", features = ["tokio"]}
junction = {version = "=2.0.0"}
htmd = {version = "=0.5.5"}
indicatif = {version = "=0.18.6", features = ["tokio"]}
junction = {version = "=2.1.0"}
libc = {version = "=0.2.189"}
notify = {version = "=8.2.0"}
open = {version = "=5.4.4", features = ["shellexecute-on-windows"]}
parking_lot = {version = "=0.12.5"}
pulldown-cmark = {version = "=0.13.4", default-features = false}
rand = {version = "=0.10.1"}
regex = {version = "=1.12.3"}
rand = {version = "=0.10.3"}
regex = {version = "=1.13.1"}
rfd = {version = "=0.17.2"}
rust-embed = {version = "=8.12.0", features = ["include-exclude", "compression", "debug-embed"]}
sanitize-filename = {version = "=0.6.0"}
serde = {version = "=1.0.228"}
serde_json = {version = "=1.0.150"}
serde_repr = {version = "=0.1.20"}
sysinfo = {version = "=0.39.5"}
toml = {version = "=1.1.4", default-features = false, features = ["parse", "serde"]}
trash = {version = "=5.2.6"}
serde = {version = "=1.0.229"}
serde_json = {version = "=1.0.151"}
serde_repr = {version = "=0.1.21"}
sysinfo = {version = "=0.39.6"}
toml = {version = "=1.1.6", default-features = false, features = ["parse", "serde"]}
trash = {version = "=5.2.9"}
percent-encoding = {version = "=2.3.2"}
url = {version = "=2.5.8", features = ["serde"]}
uuid = {version = "=1.23.1", features = ["serde", "v4"]}
uuid = {version = "=1.26.1", features = ["serde", "v4"]}
windows-sys = {version = "=0.61.2", features = ["Win32_Foundation", "Win32_Storage_FileSystem"]}
# util (codegen/macro)
anyhow = {version = "=1.0.102"}
proc-macro2 = {version = "=1.0.106"}
quote = {version = "=1.0.45"}
anyhow = {version = "=1.0.104"}
proc-macro2 = {version = "=1.0.107"}
quote = {version = "=1.0.47"}
strum = {version = "=0.28.0", features = ["derive"]}
syn = {version = "=2.0.117"}
thiserror = {version = "=2.0.18"}
syn = {version = "=3.0.6"}
thiserror = {version = "=2.0.21"}
# ui
freya = {git = "https://github.com/marc2332/freya", branch = "fix/skip-basic-render-driver", features = ["engine", "winit", "router", "remote-asset", "sdk", "query", "radio", "titlebar"]}
freya-core = {git = "https://github.com/marc2332/freya", branch = "fix/skip-basic-render-driver"}
freya-testing = {git = "https://github.com/marc2332/freya", branch = "fix/skip-basic-render-driver"}
freya-skia-safe = "=0.100.0" # required for the SKSL player renderer
freya = {git = "https://github.com/Polyfrost/freya", rev = "12f696f68c4936617b8627c862b39acb3d9cca2b", features = ["engine", "winit", "router", "remote-asset", "sdk-tokio", "query", "radio", "borderless", "titlebar", "tray", "metrics"]}
freya-core = {git = "https://github.com/Polyfrost/freya", rev = "12f696f68c4936617b8627c862b39acb3d9cca2b"}
freya-testing = {git = "https://github.com/Polyfrost/freya", rev = "12f696f68c4936617b8627c862b39acb3d9cca2b"}
freya-skia-safe = "=0.103.2" # required for the SKSL player renderer
image = {version = "=0.25.10", default-features = false, features = ["png", "jpeg"]}
[profile.release]
codegen-units = 1
debug = "line-tables-only"
lto = "fat"
codegen-units = 16
debug = false
lto = "thin"
opt-level = 3
panic = "abort"
split-debuginfo = "packed"
@ -169,3 +172,11 @@ codegen-units = 16
debug = true
lto = false
strip = false
[profile.dev]
opt-level = 0
debug = 0
strip = "debuginfo"
[profile.dev.package."*"]
opt-level = 2

View file

@ -1,73 +1,20 @@
<div align="center">
<img src=".github/media/RepoBanner.png" alt="Repository Banner" />
<img src="https://github.com/user-attachments/assets/e8359baa-23fb-457a-8910-169974487a98" alt="OneLauncher Logo" width="220" style="border-radius: 24px;" />
# OneClient | OneLauncher
The monorepo containing the code for OneLauncher, OneClient, and their core backend.
OneClient is a Minecraft client featuring fully 100% open-source components, offering many packaged and pre-configured mods in one click.
OneLauncher is a WIP Minecraft launcher giving power-users the greatest customization whilst featuring a clean UI.
forked from [OneLauncher](https://github.com/Polyfrost/OneLauncher) allowing for uh offline authentication
</div>
## Installing
## download from [**Releases Page**](https://git.oiupoyt.space/oiupoyt/OneLauncher-Cracked/releases).
You can install the latest release of OneClient from our website: [https://polyfrost.org/projects/oneclient](https://polyfrost.org/projects/oneclient)
as well as our [GitHub releases](https://github.com/Polyfrost/OneLauncher/releases/latest).
yes the actual client performs normally aswell
| Windows (x86_64) | macOS (Intel & Apple Silicon) | Linux (x86_64) |
|------------------|-------------------------------|---------------------------------------------------------|
| Installer 🔄 | DMG 🔄 | AppImage 🔄 |
| | App Bundle 🔄 | DEB |
| | | RPM |
| | | [AUR](https://aur.archlinux.org/packages/oneclient-bin) |
> 🔄 = Has support for autoupdating built-in
> [!WARNING]
> DO NOT use official Microsoft/Mojang accounts with this launcher. This bypasses authentication and is intended for offline play only. Using with official servers may violate Minecraft's Terms of Service.
## Contributing
We welcome contributions! Please read our [contributing guidelines](CONTRIBUTING.md) before getting started.
#### The Trojan:Win32/Wacatac.B!ml detection is a false positive triggered by Microsoft Defender's machine learning heuristic on newly compiled, unsigned Rust NSIS installers. The software contains no malware, telemetry, or malicious payloads.
### Requirements
The project targets **Rust 1.97** or later. You can install Rust via [rustup](https://rustup.rs/).
### Building & Running
```sh
# Run the app
cargo run -p oneclient_app
# Build a release binary
cargo build -p oneclient_app --release
```
### Packaging / Releasing
Installers are produced with [**cargo-packager**](https://github.com/crabnebula-dev/cargo-packager)
(the standalone bundler spun out of the Tauri bundler). Config lives in
[`packages/oneclient_app/Cargo.toml`](./packages/oneclient_app/Cargo.toml) under
`[package.metadata.packager]`.
```sh
cargo install cargo-packager --locked
# Build the binary, then bundle it for the current OS:
cargo build --release -p oneclient_app
cargo packager --release -p oneclient_app --formats <targets>
# Windows: nsis macOS: app,dmg Linux: deb,appimage
```
### Versioning
The workspace shares a single version, defined in the root [`Cargo.toml`](./Cargo.toml) under `[workspace.package]`.
## Code signing
This program uses free code signing provided by [SignPath.io](https://signpath.io?utm_source=foundation&utm_medium=github&utm_campaign=0install), and a certificate by the [SignPath Foundation](https://signpath.org?utm_source=foundation&utm_medium=github&utm_campaign=0install). We thank them very much to their contributions to OSS software!
intended for offline usage only.

View file

@ -18,6 +18,7 @@ depends=(
'wayland'
'dbus'
'gtk3'
'libayatana-appindicator'
)
provides=('oneclient')
conflicts=('oneclient')

View file

@ -17,6 +17,7 @@ depends=(
'wayland'
'dbus'
'gtk3'
'libayatana-appindicator'
)
makedepends=(
'rust'
@ -28,7 +29,10 @@ makedepends=(
)
provides=('oneclient')
conflicts=('oneclient-bin')
options=('!lto')
options=(
'!lto'
'!debug'
)
source=("$pkgname-$pkgver.tar.gz::${url}/archive/refs/tags/oneclient-${pkgver}.tar.gz")
sha256sums=('SKIP')

View file

@ -15,6 +15,8 @@ Source0: %{url}/releases/download/oneclient-%{version}/%{_rpmfile}
ExclusiveArch: x86_64
BuildRequires: bsdtar
Requires: gtk3
Requires: libayatana-appindicator-gtk3
Requires: libxdo
Requires: dbus-libs
Provides: oneclient = %{version}-%{release}

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View file

@ -0,0 +1,108 @@
# OneLauncher-Cracked 2.8.0 Update Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Rebase and upgrade OneLauncher-Cracked onto upstream OneLauncher 2.8.0 (`1d6ec598`), preserving all offline authentication bypasses, the local Yggdrasil skin server with authlib-injector, Forgejo auto-updater, and custom skin changer UI.
**Architecture:** Port 37 cracked subsystem files onto the clean upstream 2.8.0 codebase. Maintain strict isolation for cracked authentication and skin server logic while adopting upstream's new multi-instance management, package manager filters, and Freya improvements.
**Tech Stack:** Rust 2024, Freya (Dioxus + Skia), Tokio, Reqwest, RSA PKCS#1v15, Authlib-Injector, Forgejo API v1.
**Spec:** Direct human instruction: "update to version 2.8.0".
## Global Constraints
- Target version: `2.8.0-c1`.
- Offline authentication must require 0 Microsoft accounts.
- Local Yggdrasil skin server must eliminate multiplayer skin cloning via RSA-signed profile properties.
- Auto-updater must target Forgejo API at `https://git.oiupoyt.space/api/v1/repos/oiupoyt/OneLauncher-Cracked/releases/latest`.
- All workspace crates must pass tests and compilation without regressions.
---
### Task 1: Project Metadata, Packaging & Workflows
**Files:**
- Modify: `Cargo.toml`
- Modify: `README.md`
- Modify: `.gitignore`
- Create: `.forgejo/workflows/release.yml`
- Create: `packages/oneclient_app/distribution/linux/oneclient_app.desktop`
- [ ] **Step 1: Update Cargo.toml metadata and dev profile**
- [ ] **Step 2: Update README.md with warning embed and Forgejo releases link**
- [ ] **Step 3: Add .forgejo release workflow and desktop file**
- [ ] **Step 4: Commit task 1**
---
### Task 2: Port Offline Authentication Bypass (`oneclient_auth`)
**Files:**
- Modify: `packages/oneclient_auth/src/service.rs`
- Modify: `packages/oneclient_auth/src/store.rs`
- Modify: `packages/oneclient_auth/tests/offline.rs`
- [ ] **Step 1: In `service.rs`, remove `OfflineRequiresMicrosoft` check from `account_for_launch`**
- [ ] **Step 2: In `store.rs`, allow offline accounts without Microsoft accounts and auto-select default user**
- [ ] **Step 3: In `tests/offline.rs`, ensure tests assert offline launch succeeds without Microsoft**
- [ ] **Step 4: Run `cargo test -p oneclient_auth` and verify all tests pass**
- [ ] **Step 5: Commit task 2**
---
### Task 3: Port Path Helpers, Assets & Offline Skin Engine (`oneclient_common` & `oneclient_core`)
**Files:**
- Modify: `packages/oneclient_common/src/paths.rs`
- Modify: `packages/oneclient_core/Cargo.toml`
- Copy assets: `packages/oneclient_core/assets/authlib-injector.jar`
- Copy assets: `packages/oneclient_core/assets/offlineskins-fabric.jar`
- Create: `packages/oneclient_core/src/game/skin_server.rs`
- Modify: `packages/oneclient_core/src/game/mod.rs`
- Modify: `packages/oneclient_core/src/game/launch.rs`
- [ ] **Step 1: Add skin paths and authlib-injector path to `oneclient_common/src/paths.rs`**
- [ ] **Step 2: Copy embedded jar assets and add dependencies to `oneclient_core/Cargo.toml`**
- [ ] **Step 3: Port `skin_server.rs` and register module in `game/mod.rs`**
- [ ] **Step 4: In `game/launch.rs`, inject authlib-injector `-javaagent`, `--userProperties`, and offline skin synchronization**
- [ ] **Step 5: Run `cargo test -p oneclient_core` and verify all tests pass**
- [ ] **Step 6: Commit task 3**
---
### Task 4: Port UI Components, Skins Tab & Auto-Updater (`oneclient_app`)
**Files:**
- Modify: `packages/oneclient_app/src/constants.rs`
- Modify: `packages/oneclient_app/src/updater.rs`
- Modify: `packages/oneclient_app/src/components/avatar.rs`
- Modify: `packages/oneclient_app/src/components/player_model.rs`
- Modify: `packages/oneclient_app/src/components/navbar/app_navbar.rs`
- Create: `packages/oneclient_app/src/view/app/skins.rs`
- Create: `packages/oneclient_app/src/hooks/queries/skin.rs`
- Modify: `packages/oneclient_app/src/hooks/queries/mod.rs`
- Modify: `packages/oneclient_app/src/hooks/mod.rs`
- Modify: `packages/oneclient_app/src/view/onboarding/account.rs`
- Modify: `packages/oneclient_app/src/view/app/settings/accounts.rs`
- Modify: `packages/oneclient_app/src/layout/root_layout.rs`
- Modify: `packages/oneclient_app/src/routes.rs`
- [ ] **Step 1: Update `constants.rs` and `updater.rs` with Forgejo releases endpoint and `CrackedVersion` semver parser**
- [ ] **Step 2: Port skin queries in `hooks/queries/skin.rs` and wire into `hooks/mod.rs`**
- [ ] **Step 3: Port `skins.rs` view and register route in `routes.rs` and `root_layout.rs`**
- [ ] **Step 4: Add Skins pill button to `navbar_center` in `app_navbar.rs`**
- [ ] **Step 5: Port avatar persistent cache in `avatar.rs` and player model custom skin loading in `player_model.rs`**
- [ ] **Step 6: Port offline account inputs in onboarding (`onboarding/account.rs`) and settings (`settings/accounts.rs`)**
- [ ] **Step 7: Run `cargo test -p oneclient_app --lib` and verify all tests pass**
- [ ] **Step 8: Commit task 4**
---
### Task 5: Verification, Packaging & Deployment
- [ ] **Step 1: Run full workspace `cargo check --workspace`**
- [ ] **Step 2: Build release binary locally**
- [ ] **Step 3: Package `.AppImage`, `.deb`, and `.tar.gz` for Linux**
- [ ] **Step 4: Merge `update/2.8.0` into `main`**
- [ ] **Step 5: Tag `v2.8.0-c1` and push branch and tag to Forgejo and GitHub**
- [ ] **Step 6: Publish Forgejo Release `v2.8.0-c1` with all binaries attached**

28
knope.toml Normal file
View file

@ -0,0 +1,28 @@
[package]
versioned_files = ["Cargo.toml"]
changelog = "CHANGELOG.md"
[changes]
ignore_conventional_commits = true
[release_notes]
change_templates = [
"- $summary by [$pr_author_login](https://github.com/$pr_author_login)",
"- $summary",
]
[github]
owner = "Polyfrost"
repo = "OneLauncher"
[[workflows]]
name = "prepare-release"
[[workflows.steps]]
type = "PrepareRelease"
[[workflows]]
name = "document-change"
[[workflows.steps]]
type = "CreateChangeFile"

View file

@ -33,6 +33,7 @@ rust-embed.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
futures-util.workspace = true
tokio.workspace = true
uuid.workspace = true
bytes.workspace = true
@ -45,11 +46,16 @@ regex.workspace = true
directories.workspace = true
dunce.workspace = true
percent-encoding.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
reqwest.workspace = true
cargo-packager-updater = "=0.2.3"
rfd = "=0.17.2"
open = "=5.4.0"
# rfd = "=0.17.2"
# open = "=5.4.0"
rfd.workspace = true
open.workspace = true
# clipboard image copy + decode for it
arboard.workspace = true
@ -59,6 +65,8 @@ freya-skia-safe.workspace = true
freya.workspace = true
freya-core.workspace = true
pulldown-cmark.workspace = true
# inlines svg logos embedded in markdown badges as data urls
base64.workspace = true
[dev-dependencies]
freya-testing.workspace = true
@ -66,6 +74,10 @@ freya-testing.workspace = true
[target.'cfg(target_os = "macos")'.dependencies]
mimalloc = "=0.1.52"
[target.'cfg(target_os = "linux")'.dependencies]
libloading = "=0.8.9"
gtk-sys = "=0.18.2"
[target.'cfg(windows)'.dependencies]
windows = { version = "=0.62.2", features = [
"Win32_Foundation",
@ -150,6 +162,8 @@ desktop-template = "distribution/linux/org.polyfrost.oneclient.template"
[package.metadata.packager.appimage]
# desktop entry generated from the deb template above via the linux config
libs = ["libayatana-appindicator3.so.1"]
excluded-libraries = ["libwayland-*"]
[package.metadata.generate-rpm]
auto-req = "builtin"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 958 B

After

Width:  |  Height:  |  Size: 920 B

View file

@ -0,0 +1,3 @@
<svg width="100%" height="100%" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 15V17M12 11V17M16 7V17M6.8 21H17.2C18.88 21 19.72 21 20.36 20.67C20.93 20.39 21.39 19.93 21.67 19.36C22 18.72 22 17.88 22 16.2V7.8C22 6.12 22 5.28 21.67 4.64C21.39 4.07 20.93 3.61 20.36 3.33C19.72 3 18.88 3 17.2 3H6.8C5.12 3 4.28 3 3.64 3.33C3.07 3.61 2.61 4.07 2.33 4.64C2 5.28 2 6.12 2 7.8V16.2C2 17.88 2 18.72 2.33 19.36C2.61 19.93 3.07 20.39 3.64 20.67C4.28 21 5.12 21 6.8 21Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 590 B

View file

@ -0,0 +1 @@
<svg role="img" viewBox="-11.2 -8.5 149 113" xmlns="http://www.w3.org/2000/svg"><path fill="currentColor" d="M81.15,0c-1.2376,2.1973-2.3489,4.4704-3.3591,6.794-9.5975-1.4396-19.3718-1.4396-28.9945,0-.985-2.3236-2.1216-4.5967-3.3591-6.794-9.0166,1.5407-17.8059,4.2431-26.1405,8.0568C2.779,32.5304-1.6914,56.3725.5312,79.8863c9.6732,7.1476,20.5083,12.603,32.0505,16.0884,2.6014-3.4854,4.8998-7.1981,6.8698-11.0623-3.738-1.3891-7.3497-3.1318-10.8098-5.1523.9092-.6567,1.7932-1.3386,2.6519-1.9953,20.281,9.547,43.7696,9.547,64.0758,0,.8587.7072,1.7427,1.3891,2.6519,1.9953-3.4601,2.0457-7.0718,3.7632-10.835,5.1776,1.97,3.8642,4.2683,7.5769,6.8698,11.0623,11.5419-3.4854,22.3769-8.9156,32.0509-16.0631,2.626-27.2771-4.496-50.9172-18.817-71.8548C98.9811,4.2684,90.1918,1.5659,81.1752.0505l-.0252-.0505ZM42.2802,65.4144c-6.2383,0-11.4159-5.6575-11.4159-12.6535s4.9755-12.6788,11.3907-12.6788,11.5169,5.708,11.4159,12.6788c-.101,6.9708-5.026,12.6535-11.3907,12.6535ZM84.3576,65.4144c-6.2637,0-11.3907-5.6575-11.3907-12.6535s4.9755-12.6788,11.3907-12.6788,11.4917,5.708,11.3906,12.6788c-.101,6.9708-5.026,12.6535-11.3906,12.6535Z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View file

@ -0,0 +1 @@
<svg role="img" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path fill="currentColor" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>

After

Width:  |  Height:  |  Size: 678 B

View file

@ -0,0 +1,7 @@
<svg width="80" height="80" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M35.0354 10.6199H30.9125C26.1812 10.6199 21.8092 13.1414 19.4435 17.2347L10.2109 33.2097C9.89473 33.7567 9.62082 34.3208 9.38916 34.8972L22.5061 32.4833C27.8025 31.5085 31.9468 27.3686 32.9225 22.0775L35.0354 10.6199Z" fill="currentColor"/>
<path d="M9.37051 44.7052C9.60608 45.2979 9.8862 45.8775 10.2109 46.4393L19.4435 62.4143C21.8092 66.5076 26.1812 69.0292 30.9125 69.0292H35.0434L32.9225 57.5283C31.9468 52.2373 27.8025 48.0973 22.5061 47.1225L9.37051 44.7052Z" fill="currentColor"/>
<path d="M45.456 69.0292H49.3779C54.1092 69.0292 58.4812 66.5076 60.8469 62.4143L70.0795 46.4393C70.3966 45.8907 70.6712 45.3249 70.9033 44.7467L57.9933 47.1225C52.6968 48.0973 48.5526 52.2373 47.5769 57.5283L45.456 69.0292Z" fill="currentColor"/>
<path d="M70.8845 34.8557C70.6564 34.2937 70.3881 33.7436 70.0795 33.2097L60.8469 17.2347C58.4812 13.1414 54.1092 10.6199 49.3779 10.6199H45.4639L47.5769 22.0775C48.5526 27.3686 52.6968 31.5085 57.9933 32.4833L70.8845 34.8557Z" fill="currentColor"/>
<path d="M40.1206 11.766C40.1379 11.6124 40.3614 11.6124 40.3787 11.766L41.3999 20.8379C42.4408 30.0839 49.7344 37.3875 58.9875 38.4493L69.66 39.6741C69.8134 39.6917 69.8134 39.9142 69.66 39.9318L58.9875 41.1565C49.7345 42.2183 42.4408 49.5219 41.3999 58.768L40.3787 67.8398C40.3614 67.9935 40.1379 67.9935 40.1206 67.8398L39.1009 58.7822C38.0593 49.5296 30.7564 42.2232 21.4959 41.1688L10.6321 39.9318C10.4786 39.9143 10.4786 39.6915 10.6321 39.674L21.4958 38.437C30.7564 37.3826 38.0593 30.0762 39.1009 20.8236L40.1206 11.766Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1,3 @@
<svg width="100%" height="100%" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 21H4.6c-.56 0-.84 0-1.054-.109a1 1 0 0 1-.437-.437C3 20.2401 3 19.9601 3 19.4V3m17 5-3.9189 4.1827c-.1485.1585-.2227.2377-.3123.2787a.5.5 0 0 1-.2528.0433c-.0981-.0089-.1945-.0589-.3873-.159l-3.2574-1.6914c-.1928-.1001-.2892-.1501-.3873-.159a.5.5 0 0 0-.2528.0433c-.0896.041-.1638.1202-.3123.2787L7 15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 512 B

View file

@ -0,0 +1,3 @@
<svg width="100%" height="100%" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 17L21 12M21 12L16 7M21 12H9M9 21H7.8C6.12 21 5.28 21 4.64 20.67C4.07 20.39 3.61 19.93 3.33 19.36C3 18.72 3 17.88 3 16.2V7.8C3 6.12 3 5.28 3.33 4.64C3.61 4.07 4.07 3.61 4.64 3.33C5.28 3 6.12 3 7.8 3H9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 410 B

View file

@ -0,0 +1,3 @@
<svg width="100%" height="100%" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19.2 12.63A7.2 7.2 0 1 1 11.37 4.8A5.6 5.6 0 0 0 19.2 12.63Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 270 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

View file

@ -1,4 +1,4 @@
<svg width="156" height="32" viewBox="0 0 156 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg width="156" height="32" viewBox="0 0.41 156 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.9398 25.6841C9.60192 25.6841 8.3644 25.447 7.22718 24.9728C6.10671 24.4986 5.12003 23.8381 4.26712 22.9913C3.41424 22.1446 2.75365 21.1623 2.28539 20.0446C1.81714 18.9099 1.58301 17.6905 1.58301 16.3865C1.58301 15.0655 1.81714 13.8461 2.28539 12.7284C2.75365 11.6107 3.40587 10.6368 4.24206 9.80701C5.07821 8.96023 6.05654 8.3082 7.17703 7.85093C8.31422 7.37675 9.55174 7.13965 10.8896 7.13965C12.2108 7.13965 13.4316 7.37675 14.5521 7.85093C15.6893 8.3082 16.676 8.96023 17.5122 9.80701C18.365 10.6368 19.0256 11.6191 19.4939 12.7538C19.9621 13.8715 20.1963 15.0909 20.1963 16.4119C20.1963 17.7159 19.9621 18.9353 19.4939 20.07C19.0256 21.1877 18.3734 22.17 17.5372 23.0168C16.7011 23.8466 15.7144 24.4986 14.5772 24.9728C13.4567 25.447 12.2443 25.6841 10.9398 25.6841ZM10.8896 22.0514C11.9432 22.0514 12.863 21.8143 13.649 21.3401C14.4517 20.8659 15.0705 20.2054 15.5053 19.3587C15.9401 18.495 16.1576 17.5042 16.1576 16.3865C16.1576 15.5397 16.0321 14.7776 15.7813 14.1002C15.5304 13.4058 15.1709 12.8131 14.7026 12.3219C14.2344 11.8139 13.6741 11.4328 13.0219 11.1788C12.3864 10.9078 11.6757 10.7723 10.8896 10.7723C9.83606 10.7723 8.90791 11.0094 8.10518 11.4836C7.31917 11.9409 6.70876 12.5929 6.27395 13.4397C5.83913 14.2695 5.62175 15.2518 5.62175 16.3865C5.62175 17.2332 5.74716 18.0038 5.99802 18.6982C6.24886 19.3925 6.60007 19.9937 7.05159 20.5018C7.51986 20.9929 8.08009 21.374 8.73232 21.645C9.38451 21.9159 10.1036 22.0514 10.8896 22.0514ZM24.2015 25.3793V7.4445H26.9609L28.1399 11.0264V25.3793H24.2015ZM36.4181 25.3793L25.9825 11.8393L26.9609 7.4445L37.3963 20.9845L36.4181 25.3793ZM36.4181 25.3793L35.3644 21.7974V7.4445H39.3029V25.3793H36.4181ZM44.2267 25.3793V7.4445H48.1649V25.3793H44.2267ZM47.2368 25.3793V21.9244H56.9448V25.3793H47.2368ZM47.2368 17.8853V14.5574H56.0669V17.8853H47.2368ZM47.2368 10.8739V7.4445H56.8192V10.8739H47.2368Z" fill="currentColor" fill-opacity="0.85"/>
<path d="M69.2499 25.6588C67.9287 25.6588 66.6996 25.4302 65.5623 24.9729C64.4419 24.4987 63.4552 23.8382 62.6023 22.9915C61.7663 22.1447 61.1139 21.1624 60.6456 20.0447C60.1774 18.91 59.9434 17.6906 59.9434 16.3866C59.9434 15.0825 60.1774 13.8716 60.6456 12.7539C61.1139 11.6192 61.7663 10.6369 62.6023 9.80712C63.4386 8.97727 64.4168 8.33371 65.5373 7.87644C66.6746 7.40227 67.9037 7.16516 69.2248 7.16516C70.6296 7.16516 71.8756 7.3938 72.9625 7.85104C74.0663 8.30831 75.0279 8.93493 75.8473 9.7309L73.2134 12.4236C72.7452 11.9156 72.1766 11.5176 71.5076 11.2297C70.8555 10.9418 70.0946 10.7979 69.2248 10.7979C68.4556 10.7979 67.7448 10.9333 67.0927 11.2043C66.4572 11.4583 65.9053 11.8309 65.437 12.322C64.9854 12.8132 64.626 13.4059 64.3583 14.1003C64.1074 14.7946 63.9821 15.5567 63.9821 16.3866C63.9821 17.2334 64.1074 18.0039 64.3583 18.6983C64.626 19.3926 64.9854 19.9854 65.437 20.4765C65.9053 20.9676 66.4572 21.3487 67.0927 21.6197C67.7448 21.8907 68.4556 22.0261 69.2248 22.0261C70.1279 22.0261 70.914 21.8822 71.5828 21.5943C72.2518 21.3064 72.8288 20.9084 73.3137 20.4003L75.9729 23.0931C75.12 23.8891 74.1415 24.5156 73.0377 24.9729C71.9508 25.4302 70.6882 25.6588 69.2499 25.6588ZM79.6466 25.3794V7.44461H83.5851V25.3794H79.6466ZM82.6821 25.3794V21.8737H91.4367V25.3794H82.6821ZM95.2131 25.3794V7.44461H99.1515V25.3794H95.2131ZM104.067 25.3794V7.44461H108.006V25.3794H104.067ZM107.078 25.3794V21.9245H116.786V25.3794H107.078ZM107.078 17.8854V14.5575H115.908V17.8854H107.078ZM107.078 10.874V7.44461H116.66V10.874H107.078ZM121.006 25.3794V7.44461H123.765L124.944 11.0265V25.3794H121.006ZM133.222 25.3794L122.787 11.8394L123.765 7.44461L134.201 20.9846L133.222 25.3794ZM133.222 25.3794L132.169 21.7975V7.44461H136.107V25.3794H133.222ZM145.371 25.3794V7.69862H149.309V25.3794H145.371ZM139.827 10.9249V7.44461H154.853V10.9249H139.827Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 848 B

After

Width:  |  Height:  |  Size: 847 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,11 @@
[Desktop Entry]
Name=OneClient
Comment=Next-generation open source Minecraft launcher
Exec=/usr/bin/oneclient_app
Icon=oneclient_app
Type=Application
Terminal=false
StartupNotify=true
Categories=Game;ActionGame;AdventureGame;Simulation;
Keywords=mc;minecraft;game;
StartupWMClass=oneclient_app

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 840 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View file

@ -47,8 +47,14 @@ mod tests {
#[test]
fn both_spellings_carry_the_folder() {
assert_eq!(parse(&["--launch", "fabric-1-20"]).launch.as_deref(), Some("fabric-1-20"));
assert_eq!(parse(&["--launch=fabric-1-20"]).launch.as_deref(), Some("fabric-1-20"));
assert_eq!(
parse(&["--launch", "fabric-1-20"]).launch.as_deref(),
Some("fabric-1-20")
);
assert_eq!(
parse(&["--launch=fabric-1-20"]).launch.as_deref(),
Some("fabric-1-20")
);
}
#[test]
@ -68,7 +74,12 @@ mod tests {
#[test]
fn unknown_flags_are_ignored_not_fatal() {
assert_eq!(parse(&["--verbose", "--launch", "x", "leftover"]).launch.as_deref(), Some("x"));
assert_eq!(
parse(&["--verbose", "--launch", "x", "leftover"])
.launch
.as_deref(),
Some("x")
);
}
#[test]

View file

@ -6,7 +6,6 @@ use freya::{
use oneclient_auth::MinecraftAccount;
use crate::{
ui::{divider},
Route,
components::{Avatar, Button, Icon, IconType, OverlayPopup},
hooks::{
@ -14,6 +13,7 @@ use crate::{
use_accounts, use_current_account, use_dispatch, use_set_default_account,
},
theme::colors,
ui::divider,
};
#[derive(PartialEq)]
@ -91,7 +91,7 @@ impl Component for AccountPanel {
.opacity(progress)
.margin(Gaps::new((1.0 - progress) * -8.0, 0., 0., 0.))
.background(colors::page_elevated().with_a(220))
.blur(12.)
.backdrop_blur(12.)
.corner_radius(CornerRadius::new_all(12.))
.border(
Border::new()
@ -107,11 +107,19 @@ impl Component for AccountPanel {
Color::from_argb(120, 0, 0, 0),
)))
.child(
label()
.text("Accounts")
.font_size(18.)
.font_weight(FontWeight::MEDIUM)
.a11y_role(AccessibilityRole::TitleBar),
rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(6.)
.child(BackToControlCenter)
.child(
label()
.text("Accounts")
.font_size(18.)
.font_weight(FontWeight::MEDIUM)
.a11y_role(AccessibilityRole::TitleBar),
),
)
.child(divider())
.child(rows)
@ -120,6 +128,42 @@ impl Component for AccountPanel {
}
}
#[derive(PartialEq)]
struct BackToControlCenter;
impl Component for BackToControlCenter {
fn render(&self) -> impl IntoElement {
let dispatch = use_dispatch();
let mut hovered = use_state(|| false);
let back = move |_| {
dispatch.close_account_switcher();
dispatch.open_control_center();
};
rect()
.width(Size::px(28.))
.height(Size::px(28.))
.center()
.corner_radius(CornerRadius::new_all(8.))
.background(Color::RED.with_a(0))
.maybe(*hovered.read(), |el| {
el.background(colors::ghost_overlay_hover())
})
.cursor(CursorIcon::Pointer)
.a11y_role(AccessibilityRole::Button)
.a11y_alt("Back to control center")
.on_pointer_enter(move |_| hovered.set(true))
.on_pointer_leave(move |_| hovered.set(false))
.on_press(back)
.child(
Icon::new(IconType::ArrowLeft)
.size(18.)
.color(colors::fg_secondary()),
)
}
}
struct AccountRow {
account: MinecraftAccount,
active: bool,

View file

@ -4,12 +4,16 @@ use freya::router::RouterContext;
use crate::components::{Button, Icon, IconType};
use oneclient_common::parse_mc_version;
use oneclient_core::clusters::ClusterKind;
use crate::hooks::{settled_or_loading, use_active_cluster_id, use_clusters, use_dispatch, use_game_snapshot, use_launcher, use_version_metadata};
use crate::hooks::{
settled_or_loading, use_active_cluster_id, use_clusters, use_dispatch, use_game_snapshot,
use_launcher, use_version_metadata,
};
use crate::routes::Route;
use crate::theme::colors;
use crate::utils::sort_clusters_for_home;
use crate::view::app::launch_button_state;
use crate::view::app::{launch_button_state, launch_syncing, shared_dir_taken};
#[derive(PartialEq)]
pub struct ActiveClusterPanel;
@ -21,7 +25,6 @@ impl Component for ActiveClusterPanel {
let dispatch = use_dispatch();
let game = use_game_snapshot();
let launcher = use_launcher();
let syncing = launcher.fetching || launcher.syncing_bundles;
let clusters = settled_or_loading(&clusters_query).unwrap_or_default();
@ -72,11 +75,24 @@ impl Component for ActiveClusterPanel {
);
};
let title = format!("{} {}", cluster.mc_version, cluster.mc_loader);
let subtitle = metadata
.map(|m| m.name)
.unwrap_or_else(|| cluster.name.clone());
let version = format!("{} {}", cluster.mc_version, cluster.mc_loader);
let (title, subtitle) = if cluster.user_created && cluster.kind != ClusterKind::OneClient {
(cluster.name.clone(), version)
} else {
let subtitle = match metadata {
Some(m) => format!("OneClient · {}", m.name),
None => "OneClient".to_string(),
};
let title = if cluster.user_created {
cluster.name.clone()
} else {
version
};
(title, subtitle)
};
let cluster_id = cluster.id;
let syncing = launch_syncing(&launcher, cluster.uses_bundles());
let shared_dir_taken = shared_dir_taken(&game, &sorted, cluster_id);
rect()
.vertical()
@ -88,6 +104,9 @@ impl Component for ActiveClusterPanel {
.child(
label()
.text(title)
.width(Size::fill())
.max_lines(1)
.text_overflow(TextOverflow::Ellipsis)
.font_size(56.)
.line_height(1.1)
.font_weight(FontWeight::BOLD)
@ -109,7 +128,7 @@ impl Component for ActiveClusterPanel {
.child(launch_button(
cluster_id,
dispatch,
launch_button_state(&game, cluster_id, syncing),
launch_button_state(&game, cluster_id, syncing, shared_dir_taken),
))
.child(cluster_settings_button(cluster_id)),
)
@ -141,6 +160,7 @@ fn cluster_settings_button(cluster_id: i64) -> impl IntoElement {
Button::new()
.ghost()
.icon()
.tooltip("Cluster settings")
.on_press(move |_| {
let _ = RouterContext::get().push(Route::ClusterOverview { cluster_id });
})

View file

@ -0,0 +1,41 @@
use freya::elements::image::{AspectRatio, ImageCover, ImageHandle, image};
use freya::prelude::*;
use super::local_image::decode;
use crate::AppAssets;
#[derive(PartialEq)]
pub struct AssetImage {
path: &'static str,
size: f32,
}
impl AssetImage {
pub fn new(path: &'static str, size: f32) -> Self {
Self { path, size }
}
}
impl Component for AssetImage {
fn render(&self) -> impl IntoElement {
let path = self.path;
let size = self.size;
let handle: Option<ImageHandle> =
use_hook(move || AppAssets::get_bytes(path).as_ref().and_then(decode));
rect()
.key(path)
.width(Size::px(size))
.height(Size::px(size))
.center()
.maybe_child(handle.map(|handle| {
image(handle)
.width(Size::fill())
.height(Size::fill())
.aspect_ratio(AspectRatio::Min)
.image_cover(ImageCover::Center)
.into_element()
}))
}
}

View file

@ -1,3 +1,6 @@
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use bytes::Bytes;
use freya::elements::image::{ImageHandle, image};
use freya::engine::prelude::{Paint, SkData, SkImage, raster_n32_premul};
@ -10,6 +13,8 @@ const FACE: f32 = 8.;
const HEAD_FACE: (f32, f32) = (8., 8.);
const HEAD_OVERLAY: (f32, f32) = (40., 8.);
static AVATAR_HEAD_CACHE: OnceLock<Mutex<HashMap<usize, ImageHandle>>> = OnceLock::new();
#[derive(PartialEq, Clone)]
pub struct Avatar {
uuid: String,
@ -34,22 +39,13 @@ impl LayoutExt for Avatar {
impl ContainerSizeExt for Avatar {}
impl Component for Avatar {
fn render_key(&self) -> DiffKey {
DiffKey::from(&self.uuid)
}
fn render(&self) -> impl IntoElement {
let (skin_bytes, _is_slim) = use_player_skin(self.uuid.clone());
let mut cache = use_state(|| None::<(usize, ImageHandle)>);
let src_ptr = skin_bytes.as_ptr() as usize;
let cached = cache.read().clone();
let head = match cached {
Some((ptr, holder)) if ptr == src_ptr => Some(holder),
_ => {
let holder = compose_head(&skin_bytes);
if let Some(holder) = &holder {
cache.set(Some((src_ptr, holder.clone())));
}
holder
}
};
let head = get_or_compose_head(&skin_bytes);
rect()
.width(Size::px(AVATAR_SIZE))
@ -67,6 +63,23 @@ impl Component for Avatar {
}
}
fn get_or_compose_head(skin_bytes: &Bytes) -> Option<ImageHandle> {
if skin_bytes.is_empty() {
return None;
}
let src_ptr = skin_bytes.as_ptr() as usize;
let cache = AVATAR_HEAD_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
if let Some(handle) = cache.lock().ok().and_then(|g| g.get(&src_ptr).cloned()) {
return Some(handle);
}
let composed = compose_head(skin_bytes)?;
if let Ok(mut guard) = cache.lock() {
guard.insert(src_ptr, composed.clone());
}
Some(composed)
}
fn compose_head(skin_bytes: &Bytes) -> Option<ImageHandle> {
let data = unsafe { SkData::new_bytes(skin_bytes) };
let skin = SkImage::from_encoded(data)?;

View file

@ -0,0 +1,528 @@
use std::path::{Path, PathBuf};
use freya::prelude::*;
use freya::router::RouterContext;
use notify::{Event, EventKind};
use oneclient_content::modpacks::BlockedFile;
use crate::components::{Button, Icon, IconType, OverlayPopup};
use crate::hooks::{use_dispatch, use_folder_watch, use_notifications_snapshot};
use crate::notifications::BlockedDownloads;
use crate::routes::Route;
use crate::theme::colors;
use crate::ui::border_all_color;
const CARD_BG: Color = Color::from_rgb(26, 34, 41);
const DIALOG_W: f32 = 560.;
const DIALOG_PAD: f32 = 22.;
const LIST_MAX_H: f32 = 300.;
const ROW_H: f32 = 52.;
#[derive(PartialEq)]
pub struct BlockedDownloadsPopup;
impl Component for BlockedDownloadsPopup {
fn render(&self) -> impl IntoElement {
let snapshot = use_notifications_snapshot();
let dispatch = use_dispatch();
let router = RouterContext::get();
use_side_effect_with_deps(&snapshot.open_cluster, move |requested| {
if requested.is_none() {
return;
}
if let Some(cluster_id) = dispatch.take_open_cluster() {
let _ = router.push(Route::ClusterOverview { cluster_id });
}
});
match snapshot.blocked_downloads {
Some(blocked) => BlockedDownloadsDialog { blocked }.into_element(),
None => rect().into_element(),
}
}
}
fn downloads_dir() -> Option<PathBuf> {
directories::UserDirs::new().and_then(|dirs| dirs.download_dir().map(Path::to_path_buf))
}
fn touches_file(_root: &Path, event: &Event) -> bool {
matches!(event.kind, EventKind::Create(_) | EventKind::Modify(_))
}
type ExtraFolder = State<Option<(i64, PathBuf)>>;
fn bump(mut tick: State<u64>) {
let next = *tick.peek() + 1;
tick.set(next);
}
#[derive(PartialEq)]
struct ExtraFolderWatch {
folder: PathBuf,
tick: State<u64>,
key: DiffKey,
}
impl KeyExt for ExtraFolderWatch {
fn write_key(&mut self) -> &mut DiffKey {
&mut self.key
}
}
impl Component for ExtraFolderWatch {
fn render(&self) -> impl IntoElement {
let tick = self.tick;
use_folder_watch(Some(self.folder.clone()), false, touches_file, move || {
bump(tick)
});
rect()
}
fn render_key(&self) -> DiffKey {
self.key.clone().or(self.default_key())
}
}
#[derive(PartialEq)]
struct BlockedDownloadsDialog {
blocked: BlockedDownloads,
}
impl Component for BlockedDownloadsDialog {
fn render(&self) -> impl IntoElement {
let dispatch = use_dispatch();
let downloads = use_hook(downloads_dir);
let tick = use_state(|| 0u64);
let extra: ExtraFolder = use_state(|| None);
use_folder_watch(downloads.clone(), false, touches_file, move || bump(tick));
let cluster_id = self.blocked.cluster_id;
let chosen = extra
.read()
.as_ref()
.filter(|(owner, _)| *owner == cluster_id)
.map(|(_, folder)| folder.clone());
let deps = (
*tick.read(),
cluster_id,
self.blocked.remaining(),
chosen.clone(),
);
{
let dispatch = dispatch.clone();
let downloads = downloads.clone();
use_side_effect_with_deps(&deps, move |(_, cluster_id, files, chosen)| {
if files.is_empty() {
return;
}
let locations: Vec<PathBuf> =
downloads.iter().chain(chosen.iter()).cloned().collect();
if !locations.is_empty() {
dispatch.scan_blocked_downloads(*cluster_id, locations, files.clone());
}
});
}
let close = dispatch.clone();
OverlayPopup::new()
.on_close(move |_| close.close_blocked_downloads())
.child(
rect()
.width(Size::window_percent(100.))
.height(Size::window_percent(100.))
.center()
.child(dialog(
&self.blocked,
downloads.as_deref(),
chosen.as_deref(),
extra,
dispatch,
)),
)
.maybe_child(chosen.map(|folder| {
let key = folder.to_string_lossy().into_owned();
ExtraFolderWatch {
folder,
tick,
key: DiffKey::None,
}
.key(key)
.into_element()
}))
.into_element()
}
}
fn folder_label(path: &Path) -> String {
path.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string())
}
fn watched_text(downloads: Option<&Path>, chosen: Option<&Path>) -> String {
match (downloads, chosen) {
(Some(_), Some(chosen)) => format!("Downloads and {}", chosen.display()),
(Some(downloads), None) => downloads.display().to_string(),
(None, Some(chosen)) => chosen.display().to_string(),
(None, None) => "Nothing yet. Add a folder or use Browse.".to_string(),
}
}
fn pick_extra_folder(cluster_id: i64, start: Option<PathBuf>, mut extra: ExtraFolder) {
spawn(async move {
let mut picker = rfd::AsyncFileDialog::new().set_title("Pick the folder you download into");
if let Some(start) = start {
picker = picker.set_directory(start);
}
if let Some(handle) = picker.pick_folder().await {
extra.set(Some((cluster_id, handle.path().to_path_buf())));
}
});
}
fn folder_row(
cluster_id: i64,
downloads: Option<&Path>,
chosen: Option<&Path>,
extra: ExtraFolder,
) -> impl IntoElement {
let start = chosen
.map(Path::to_path_buf)
.or_else(|| downloads.map(Path::to_path_buf));
let has_extra = chosen.is_some();
rect()
.vertical()
.width(Size::fill())
.spacing(6.)
.child(
rect()
.horizontal()
.width(Size::fill())
.content(Content::Flex)
.cross_align(Alignment::Center)
.padding(Gaps::new_symmetric(8., 12.))
.spacing(10.)
.corner_radius(CornerRadius::new_all(10.))
.background(colors::component_bg())
.border(border_all_color(1., colors::component_border()))
.child(
Icon::new(IconType::FolderCheck)
.size(16.)
.color(colors::fg_secondary()),
)
.child(
rect()
.vertical()
.width(Size::flex(1.))
.spacing(2.)
.child(
label()
.text("Watching")
.font_size(11.)
.color(colors::fg_secondary()),
)
.child(
label()
.text(watched_text(downloads, chosen))
.font_size(12.5)
.max_lines(2)
.color(colors::fg_primary()),
),
)
.child(
Button::new()
.secondary()
.small()
.on_press(move |_| pick_extra_folder(cluster_id, start.clone(), extra))
.text(if has_extra {
"Change folder"
} else {
"Add folder"
}),
)
.maybe_child(has_extra.then(|| {
let mut extra = extra;
Button::new()
.ghost()
.small()
.on_press(move |_| extra.set(None))
.text("Remove")
.into_element()
})),
)
.child(
label()
.text(
"Your browser saves to its own download folder. To save somewhere else, \
set it to ask where to save each file.",
)
.font_size(11.)
.max_lines(2)
.color(colors::fg_secondary()),
)
}
fn dialog(
blocked: &BlockedDownloads,
downloads: Option<&Path>,
chosen: Option<&Path>,
extra: ExtraFolder,
dispatch: crate::Actions,
) -> impl IntoElement {
let count = blocked.files.len();
let added = blocked.added.len().min(count);
let done = added == count;
let plural = crate::utils::plural(count as i64);
let where_to = match (downloads, chosen) {
(_, Some(chosen)) => format!(
"Download each one from its page and it will be picked up from Downloads or {} automatically.",
folder_label(chosen)
),
(Some(_), None) => {
"Download each one from its page and it will be picked up from your Downloads folder automatically."
.to_string()
}
(None, None) => {
"Download each one from its page, then add the folder you saved them to or use Browse."
.to_string()
}
};
let subtitle = if done {
format!(
"All {count} file{plural} are in {}. It is ready to play.",
blocked.cluster_name
)
} else {
format!(
"CurseForge does not let launchers download {count} file{plural} that {} needs. {where_to}",
blocked.cluster_name
)
};
let title = if done {
"Manual downloads added".to_string()
} else if added > 0 {
format!("Some files need a manual download ({added} of {count} added)")
} else {
"Some files need a manual download".to_string()
};
let cluster_id = blocked.cluster_id;
let files = blocked.remaining();
let browse = dispatch.clone();
let close = dispatch.clone();
let browse_start = chosen
.map(Path::to_path_buf)
.or_else(|| downloads.map(Path::to_path_buf));
rect()
.vertical()
.width(Size::px(DIALOG_W))
.height(Size::Inner)
.max_width(Size::window_percent(95.))
.max_height(Size::window_percent(85.))
.overflow(Overflow::Clip)
.corner_radius(CornerRadius::new_all(16.))
.background(CARD_BG)
.border(border_all_color(1., colors::component_border()))
.shadow(Shadow::from((
0.,
18.,
52.,
0.,
Color::from_argb(150, 0, 0, 0),
)))
.child(
rect()
.vertical()
.width(Size::fill())
.height(Size::Inner)
.padding(Gaps::new_all(DIALOG_PAD))
.spacing(14.)
.child(
rect()
.vertical()
.width(Size::fill())
.spacing(3.)
.child(
label()
.text(title)
.font_size(17.)
.font_weight(FontWeight::SEMI_BOLD)
.color(colors::fg_primary()),
)
.child(
label()
.text(subtitle)
.font_size(12.5)
.max_lines(4)
.color(colors::fg_secondary()),
),
)
.maybe_child(
(!done)
.then(|| folder_row(cluster_id, downloads, chosen, extra).into_element()),
)
.child(file_list(blocked))
.child(if done {
let close = close.clone();
rect()
.horizontal()
.width(Size::fill())
.main_align(Alignment::End)
.child(
Button::new()
.primary()
.on_press(move |_| close.close_blocked_downloads())
.child(Icon::new(IconType::Check).size(15.))
.text("Done"),
)
.into_element()
} else {
footer(cluster_id, files, browse_start, browse, close).into_element()
}),
)
}
fn footer(
cluster_id: i64,
files: Vec<BlockedFile>,
browse_start: Option<PathBuf>,
browse: crate::Actions,
close: crate::Actions,
) -> impl IntoElement {
rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.main_align(Alignment::End)
.spacing(8.)
.child(
Button::new()
.ghost()
.on_press(move |_| close.close_blocked_downloads())
.text("Skip for now"),
)
.child(
Button::new()
.primary()
.on_press(move |_| {
let dispatch = browse.clone();
let files = files.clone();
let start = browse_start.clone();
spawn(async move {
let mut picker =
rfd::AsyncFileDialog::new().set_title("Pick the downloaded files");
if let Some(start) = start {
picker = picker.set_directory(start);
}
if let Some(handles) = picker.pick_files().await {
let picked = handles
.iter()
.map(|handle| handle.path().to_path_buf())
.collect();
dispatch.scan_blocked_downloads(cluster_id, picked, files);
}
});
})
.child(Icon::new(IconType::Folder).size(15.))
.text("Browse"),
)
}
fn file_list(blocked: &BlockedDownloads) -> impl IntoElement {
ScrollView::new()
.width(Size::fill())
.height(Size::Inner)
.max_height(Size::px(LIST_MAX_H))
.child(
rect().vertical().width(Size::fill()).spacing(6.).children(
blocked
.files
.iter()
.map(|file| file_row(file, blocked.is_added(file)).into_element()),
),
)
}
fn file_row(file: &BlockedFile, added: bool) -> impl IntoElement {
let page = file.page_url.clone();
let (background, border) = if added {
(colors::success().with_a(28), colors::success().with_a(120))
} else {
(colors::component_bg(), colors::component_border())
};
let trailing = if added {
rect()
.horizontal()
.cross_align(Alignment::Center)
.spacing(6.)
.padding(Gaps::new_symmetric(0., 4.))
.child(
Icon::new(IconType::CheckCircle)
.size(14.)
.color(colors::success()),
)
.child(
label()
.text("Added")
.font_size(12.)
.font_weight(FontWeight::SEMI_BOLD)
.color(colors::success()),
)
.into_element()
} else {
Button::new()
.secondary()
.small()
.enabled(page.is_some())
.on_press(move |_| {
if let Some(url) = &page {
crate::platform::open_url(url);
}
})
.child(Icon::new(IconType::LinkExternal01).size(13.))
.text("Open page")
.into_element()
};
rect()
.key(file.sha1.clone())
.horizontal()
.width(Size::fill())
.height(Size::px(ROW_H))
.content(Content::Flex)
.cross_align(Alignment::Center)
.padding(Gaps::new_symmetric(0., 12.))
.spacing(10.)
.corner_radius(CornerRadius::new_all(10.))
.background(background)
.border(border_all_color(1., border))
.child(
rect()
.vertical()
.width(Size::flex(1.))
.spacing(2.)
.child(
label()
.text(file.project_name.clone())
.font_size(13.)
.font_weight(FontWeight::SEMI_BOLD)
.max_lines(1)
.color(colors::fg_primary()),
)
.child(
label()
.text(file.file_name.clone())
.font_size(11.)
.max_lines(1)
.color(colors::fg_secondary()),
),
)
.child(trailing)
}

View file

@ -0,0 +1,157 @@
use std::collections::HashSet;
use freya::prelude::*;
use crate::components::{Button, Icon, IconType, OverlayPopup, toggle_controlled};
use crate::hooks::{use_dispatch, use_notifications_snapshot};
use crate::theme::colors;
use crate::ui::border_all_color;
const CARD_BG: Color = Color::from_rgb(26, 34, 41);
const DIALOG_W: f32 = 460.;
const LIST_MAX_H: f32 = 306.;
#[derive(PartialEq)]
pub struct BundleChoicesPopup;
impl Component for BundleChoicesPopup {
fn render(&self) -> impl IntoElement {
let snapshot = use_notifications_snapshot();
let dispatch = use_dispatch();
let mut chosen = use_state(HashSet::<String>::new);
let Some(choices) = snapshot.bundle_choices.clone() else {
return rect().into_element();
};
let mut list = rect().vertical().width(Size::fill()).spacing(6.);
for (name, title) in &choices.bundles {
let key = name.clone();
let on_toggle: EventHandler<()> = (move |()| {
let mut next = chosen.read().clone();
if !next.remove(&key) {
next.insert(key.clone());
}
chosen.set(next);
})
.into();
list = list.child(
rect()
.key(name.clone())
.horizontal()
.width(Size::fill())
.content(Content::Flex)
.cross_align(Alignment::Center)
.padding(Gaps::new_symmetric(8., 10.))
.corner_radius(CornerRadius::new_all(8.))
.background(colors::component_bg())
.child(
label()
.text(title.clone())
.font_size(13.)
.max_lines(1)
.width(Size::flex(1.0))
.color(colors::fg_primary()),
)
.child(toggle_controlled(
choices.held.contains(name) != chosen.read().contains(name),
on_toggle,
)),
);
}
let names: Vec<String> = choices
.bundles
.iter()
.map(|(name, _)| name.clone())
.collect();
let held = choices.held.clone();
let later = dispatch.clone();
let close = dispatch.clone();
OverlayPopup::new()
.on_close(move |_| {
chosen.set(HashSet::new());
close.close_bundle_choices(None);
})
.child(
rect()
.width(Size::window_percent(100.))
.height(Size::window_percent(100.))
.center()
.child(
rect()
.vertical()
.width(Size::px(DIALOG_W))
.height(Size::Inner)
.max_width(Size::window_percent(95.))
.max_height(Size::window_percent(85.))
.padding(Gaps::new_all(22.))
.spacing(14.)
.corner_radius(CornerRadius::new_all(16.))
.background(CARD_BG)
.border(border_all_color(1., colors::component_border()))
.child(
label()
.text("Choose your bundles")
.font_size(17.)
.font_weight(FontWeight::SEMI_BOLD)
.color(colors::fg_primary()),
)
.child(
label()
.text(format!(
"{} has optional bundles you have not chosen yet. Turn on the ones you want, turning one off removes its mods.",
choices.cluster_name
))
.font_size(12.5)
.max_lines(4)
.color(colors::fg_secondary()),
)
.child(
ScrollView::new()
.width(Size::fill())
.height(Size::Inner)
.max_height(Size::px(LIST_MAX_H))
.child(list),
)
.child(
rect()
.horizontal()
.width(Size::fill())
.main_align(Alignment::End)
.spacing(8.)
.child(
Button::new()
.ghost()
.on_press(move |_| {
chosen.set(HashSet::new());
later.close_bundle_choices(None);
})
.text("Ask me later"),
)
.child(
Button::new()
.primary()
.on_press(move |_| {
let flipped = chosen.read().clone();
let picked = names
.iter()
.filter(|name| {
held.contains(*name)
!= flipped.contains(*name)
})
.cloned()
.collect();
chosen.set(HashSet::new());
dispatch.close_bundle_choices(Some(picked));
})
.child(Icon::new(IconType::Check).size(15.))
.text("Confirm & launch"),
),
),
),
)
.into_element()
}
}

View file

@ -4,7 +4,7 @@ use std::borrow::Cow;
use freya::prelude::*;
use crate::{
components::{Icon, IconTint, IconType},
components::{Icon, IconTint, IconType, TooltipPlacement, tooltip::use_tooltip_anchor},
theme::colors,
};
@ -53,7 +53,10 @@ pub struct Button {
enabled: bool,
focusable: bool,
alt: Option<Box<str>>,
tooltip: Option<Box<str>>,
tooltip_placement: TooltipPlacement,
on_press: Option<EventHandler<Event<PressEventData>>>,
overflow: Overflow,
elements: Vec<Element>,
key: DiffKey,
@ -111,8 +114,11 @@ impl Button {
enabled: true,
focusable: true,
alt: None,
tooltip: None,
tooltip_placement: TooltipPlacement::default(),
on_press: None,
elements: Vec::new(),
overflow: Overflow::Clip,
key: DiffKey::None,
cursor_icon: CursorIcon::Pointer,
}
@ -152,6 +158,11 @@ impl Button {
self
}
pub fn overflow(mut self, overflow: Overflow) -> Self {
self.overflow = overflow;
self
}
pub fn font_size(mut self, font_size: impl Into<f32>) -> Self {
self.text_style.font_size = Some(font_size.into().into());
self
@ -203,6 +214,20 @@ impl Button {
self
}
pub fn tooltip(mut self, tooltip: impl Into<Box<str>>) -> Self {
let tooltip = tooltip.into();
if self.alt.is_none() {
self.alt = Some(tooltip.clone());
}
self.tooltip = Some(tooltip);
self
}
pub fn tooltip_placement(mut self, placement: TooltipPlacement) -> Self {
self.tooltip_placement = placement;
self
}
pub fn cursor_icon(mut self, cursor_icon: CursorIcon) -> Self {
self.cursor_icon = cursor_icon;
self
@ -233,6 +258,8 @@ impl LayoutExt for Button {
impl ContainerExt for Button {}
impl DecorationExt for Button {}
impl StyleExt for Button {
fn get_style(&mut self) -> &mut StyleState {
&mut self.style
@ -247,6 +274,8 @@ impl Component for Button {
let a11y_id = use_a11y();
let focus = use_focus(a11y_id);
let tooltip = use_tooltip_anchor(self.tooltip.clone(), self.tooltip_placement);
let enabled = use_reactive(&self.enabled);
let cursor_icon = self.cursor_icon;
@ -294,7 +323,7 @@ impl Component for Button {
.a11y_focusable(enabled() && self.focusable)
.a11y_role(AccessibilityRole::Button)
.map(self.alt.clone(), |rect, alt| rect.a11y_alt(alt))
.overflow(Overflow::Clip)
.overflow(self.overflow)
.layout(self.layout.clone())
.text_style(self.text_style.clone())
.children(self.elements.clone());
@ -319,7 +348,7 @@ impl Component for Button {
if enabled() {
rect = rect
.on_pointer_down(move |_| pressing.set(true))
.on_global_pointer_press(move |_| pressing.set(false))
.on_global_pointer_up(move |_| pressing.set(false))
.on_pointer_over(move |_| hovering.set(true))
.on_pointer_out(move |_| {
hovering.set(false);
@ -335,7 +364,7 @@ impl Component for Button {
rect = rect.cursor(CursorIcon::NotAllowed);
}
rect
tooltip.attach(rect)
}
fn render_key(&self) -> DiffKey {
@ -444,9 +473,36 @@ pub fn open_folder_button(folder: std::path::PathBuf) -> Button {
Button::new()
.secondary()
.icon()
.tooltip("Open folder")
.on_press(move |_| {
std::fs::create_dir_all(&folder).ok();
crate::platform::open_path(&folder.to_string_lossy());
})
.child(Icon::new(IconType::Folder).size(16.))
}
pub fn copy_button(text: String, copied_body: &'static str, dispatch: crate::Actions) -> Button {
Button::new()
.secondary()
.on_press(move |_| {
if let Err(err) = freya::text_edit::Clipboard::set(text.clone()) {
tracing::warn!("clipboard copy failed: {err:?}");
dispatch
.notify("Copy failed")
.body("Could not copy to the clipboard.")
.error()
.toast_only()
.send();
} else {
dispatch
.notify("Copied to clipboard")
.body(copied_body)
.info()
.icon(IconType::ClipboardCheck)
.toast_only()
.send();
}
})
.child(Icon::new(IconType::Copy01).size(14.))
.text("Copy")
}

View file

@ -2,7 +2,7 @@ use freya::engine::prelude::{Paint, PaintStyle, PathBuilder, SkColor, SkRect};
use freya::prelude::*;
use crate::theme::colors;
use crate::utils::format_duration_hm;
use crate::utils::format_duration;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ValueUnit {
@ -13,7 +13,7 @@ pub enum ValueUnit {
impl ValueUnit {
pub fn format(self, value: i64) -> String {
match self {
ValueUnit::Duration => format_duration_hm(value),
ValueUnit::Duration => format_duration(value),
ValueUnit::Count => format!("{value}×"),
}
}
@ -93,7 +93,7 @@ impl Component for PieChart {
let values = self.values.clone();
let hit_values = self.values.clone();
let render_cb = RenderCallback::new(move |ctx: &mut CanvasContext| {
let render_cb = RenderCallback::new(move |ctx: &mut FillRenderContext| {
let (w, h) = (ctx.size.width, ctx.size.height);
if w <= 0.0 || h <= 0.0 {
return;
@ -182,8 +182,13 @@ impl Component for PieChart {
.height(Size::fill())
.position(Position::new_absolute())
.interactive(false)
// Childless canvas auto-sizes to 0x0 without an explicit size
.child(canvas(render_cb).width(Size::fill()).height(Size::fill())),
// Childless rect auto-sizes to 0x0 without an explicit size
.child(
rect()
.width(Size::fill())
.height(Size::fill())
.background(render_cb),
),
)
.child(
rect()
@ -264,6 +269,7 @@ fn slice_at(values: &[i64], x: f32, y: f32, size: f32) -> Option<usize> {
pub struct BarChart {
values: Vec<i64>,
labels: Vec<String>,
readout_labels: Option<Vec<String>>,
highlight: Option<usize>,
unit: ValueUnit,
height: f32,
@ -275,6 +281,7 @@ impl BarChart {
Self {
values,
labels,
readout_labels: None,
highlight: None,
unit: ValueUnit::Duration,
height: DEFAULT_HEIGHT,
@ -282,6 +289,11 @@ impl BarChart {
}
}
pub fn readout_labels(mut self, labels: Vec<String>) -> Self {
self.readout_labels = Some(labels);
self
}
pub fn highlight(mut self, highlight: Option<usize>) -> Self {
self.highlight = highlight;
self
@ -304,7 +316,9 @@ impl BarChart {
}
fn readout_name(&self, i: usize) -> String {
self.labels
self.readout_labels
.as_ref()
.unwrap_or(&self.labels)
.get(i)
.cloned()
.filter(|s| !s.is_empty())
@ -343,13 +357,9 @@ impl Component for BarChart {
for (i, &v) in self.values.iter().enumerate() {
let frac = if max > 0 { v as f32 / max as f32 } else { 0.0 };
let h = if v > 0 { (frac * height).max(4.0) } else { 0.0 };
let is_active = Some(i) == focus;
let color = if is_active {
colors::brand()
} else if active.is_some() {
colors::component_bg_hover().with_a(150)
} else {
colors::component_bg_hover()
let color = match active {
Some(a) if a != i => colors::component_bg_hover(),
_ => colors::brand(),
};
bars = bars.child(
@ -363,7 +373,7 @@ impl Component for BarChart {
rect()
.width(Size::fill())
.height(Size::px(h))
.corner_radius(CornerRadius::new(4., 4., 0., 0.))
.corner_radius(CornerRadius::new(4., 4., 0., 0.))
.background(color),
),
);
@ -381,14 +391,13 @@ impl Component for BarChart {
label_row = label_row.child(
rect()
.width(Size::flex(1.0))
.horizontal()
.main_align(Alignment::Center)
.child(
label()
.text(if shown { text.clone() } else { String::new() })
.font_size(10.)
.max_lines(1)
.width(Size::fill())
.text_align(TextAlign::Center)
.color(colors::fg_secondary()),
),
);
@ -455,7 +464,7 @@ fn label_stride(labels: &[String], plot_width: f32, gap: f32) -> usize {
.unwrap_or(0)
.max(1);
// ~6.2px per char at font_size 10
let slot_px = max_chars as f32 * 6.2 + gap.max(8.0) + 8.0;
let slot_px = max_chars as f32 * 6.2 + gap.max(8.0) + 12.0;
let max_labels = (plot_width / slot_px).floor().max(1.0) as usize;
if n <= max_labels {
@ -487,7 +496,7 @@ fn gridlines(height: f32) -> Element {
fn y_axis(max: i64, unit: ValueUnit, height: f32) -> Element {
let fmt = |v: i64| match unit {
ValueUnit::Duration => format_duration_hm(v),
ValueUnit::Duration => format_duration(v),
ValueUnit::Count => v.to_string(),
};

View file

@ -4,7 +4,9 @@ use freya::{prelude::*, router::RouterContext};
use oneclient_content::packages::{CachedPackageMeta, ProviderId};
use crate::components::{Button, Icon, IconType, OverlayPopup, ScrollArea, TabBar, TabItem};
use crate::hooks::{package_meta_batch, use_dispatch, use_notifications_snapshot, use_package_meta_batch};
use crate::hooks::{
package_meta_batch, use_dispatch, use_notifications_snapshot, use_package_meta_batch,
};
use crate::notifications::{ClusterUpdateItem, ClusterUpdateSummary};
use crate::routes::Route;
use crate::theme::colors;
@ -108,7 +110,11 @@ impl Component for ClusterUpdatePopup {
})
.collect();
let mut meta = MetaMap::new();
for provider in ProviderId::REMOTE_PROVIDERS.iter().copied() {
for provider in ProviderId::REMOTE_PROVIDERS
.iter()
.copied()
.chain([ProviderId::Local])
{
let ids: Vec<String> = all_items
.iter()
.filter(|i| i.provider == provider)
@ -355,11 +361,7 @@ fn change_list(
}
/// Multi-cluster stand-in for the footer's "Open cluster" shortcut
fn cluster_header(
group: &ClusterGroup,
first: bool,
dispatch: crate::Actions,
) -> impl IntoElement {
fn cluster_header(group: &ClusterGroup, first: bool, dispatch: crate::Actions) -> impl IntoElement {
let cluster_id = group.cluster_id;
let count = group.names.len();

View file

@ -0,0 +1,107 @@
use std::borrow::Cow;
use freya::prelude::*;
use crate::components::ScrollArea;
use crate::theme::{self, colors};
use crate::ui::border_all_color;
#[derive(PartialEq)]
pub struct CodeBlock {
code: String,
color: Color,
background: Color,
font_family: Cow<'static, str>,
font_size: f32,
max_lines: Option<usize>,
height: Option<f32>,
key: DiffKey,
}
impl KeyExt for CodeBlock {
fn write_key(&mut self) -> &mut DiffKey {
&mut self.key
}
}
impl CodeBlock {
pub fn new(code: impl Into<String>) -> Self {
Self {
code: code.into(),
color: colors::fg_primary(),
background: colors::component_bg(),
font_family: Cow::Borrowed(theme::MONO_FONT),
font_size: 12.,
max_lines: None,
height: None,
key: DiffKey::None,
}
}
pub fn color(mut self, color: Color) -> Self {
self.color = color;
self
}
pub fn background(mut self, background: Color) -> Self {
self.background = background;
self
}
pub fn font_family(mut self, font_family: Cow<'static, str>) -> Self {
self.font_family = font_family;
self
}
pub fn font_size(mut self, font_size: f32) -> Self {
self.font_size = font_size;
self
}
pub fn max_lines(mut self, max_lines: usize) -> Self {
self.max_lines = Some(max_lines);
self
}
/// Fixes the block's height and scrolls the code inside it
pub fn height(mut self, height: f32) -> Self {
self.height = Some(height);
self
}
}
impl Component for CodeBlock {
fn render(&self) -> impl IntoElement {
let text = SelectableText::new()
.span(self.code.clone())
.font_family(self.font_family.clone())
.font_size(self.font_size)
.max_lines(self.max_lines)
.width(Size::fill())
.color(self.color);
let block = rect()
.width(Size::fill())
.padding(Gaps::new_all(12.))
.corner_radius(CornerRadius::new_all(8.))
.background(self.background)
.border(border_all_color(1., colors::component_border()));
match self.height {
Some(height) => block
.height(Size::px(height))
.overflow(Overflow::Clip)
.child(
ScrollArea::new()
.width(Size::fill())
.height(Size::fill())
.child(text),
),
None => block.child(text),
}
}
fn render_key(&self) -> DiffKey {
self.key.clone().or(self.default_key())
}
}

View file

@ -2,6 +2,7 @@ use freya::prelude::*;
use crate::components::{Icon, IconType, OVERLAY_BASE_LEVEL, OverlayPopup};
use crate::theme::colors;
use crate::ui::clamp_to_window;
const MENU_BG: Color = Color::from_rgb(25, 32, 38);
const MENU_BORDER: Color = Color::from_argb(26, 255, 255, 255);
@ -10,8 +11,6 @@ const MENU_DANGER: Color = Color::from_rgb(242, 84, 90);
/// Panel padding and border on both axes, which the measured list sits inside of
const PANEL_INSET: f32 = 14.;
/// Gap the clamped menu keeps from the window edges
const EDGE_MARGIN: f32 = 8.;
enum Entry {
Action {
@ -215,26 +214,6 @@ impl Component for ContextMenu {
}
}
/// `root_size` is physical while the press position is logical, so it has to be
/// scaled down before the two are compared
fn clamp_to_window(x: f32, y: f32, menu_width: f32, menu_height: f32) -> (f32, f32) {
let platform = Platform::get();
let scale = *platform.scale_factor.peek() as f32;
if scale <= 0. {
return (x, y);
}
let window = *platform.root_size.peek();
let clamp = |pos: f32, len: f32, limit: f32| {
pos.clamp(EDGE_MARGIN, (limit - len - EDGE_MARGIN).max(EDGE_MARGIN))
};
(
clamp(x, menu_width, window.width / scale),
clamp(y, menu_height, window.height / scale),
)
}
#[derive(PartialEq)]
struct ContextMenuRow {
icon: IconType,

View file

@ -0,0 +1,626 @@
use freya::{
animation::{AnimNum, Ease, OnCreation, use_animation},
prelude::*,
router::RouterContext,
};
use crate::{
Route,
components::{Avatar, Button, Icon, IconType, OVERLAY_MAX_LEVEL, OverlayPopup},
hooks::{
RemoveAccountKeys, query_error, query_is_loading, try_accounts, try_default_account,
use_accounts, use_control_center_open, use_current_account, use_dispatch,
use_remove_account, use_settings_snapshot,
},
theme::colors,
ui::{border_all_color, divider},
};
const PANEL_WIDTH: f32 = 344.;
#[derive(PartialEq)]
pub struct ControlCenter;
impl Component for ControlCenter {
fn render(&self) -> impl IntoElement {
let open = use_control_center_open();
let dispatch = use_dispatch();
if !open {
return rect().into_element();
}
OverlayPopup::new()
.position(Position::new_global().top(72.).right(40.))
.on_close(move |()| dispatch.close_control_center())
.child(ControlPanel)
.into_element()
}
}
#[derive(PartialEq)]
struct ControlPanel;
impl Component for ControlPanel {
fn render(&self) -> impl IntoElement {
let intro = use_animation(|conf| {
conf.on_creation(OnCreation::Run);
AnimNum::new(0., 1.).time(200).ease(Ease::Out)
});
let progress = intro.read().value();
rect()
.vertical()
.width(Size::px(PANEL_WIDTH))
.padding(Gaps::new_all(12.))
.spacing(10.)
.opacity(progress)
.margin(Gaps::new((1.0 - progress) * -8.0, 0., 0., 0.))
.background(colors::page_elevated().with_a(220))
.backdrop_blur(12.)
.corner_radius(CornerRadius::new_all(14.))
.border(
Border::new()
.fill(colors::component_border())
.width(1.)
.alignment(BorderAlignment::Inner),
)
.shadow(Shadow::from((
0.,
8.,
32.,
0.,
Color::from_argb(120, 0, 0, 0),
)))
.child(AccountHeader)
.child(divider())
.child(
rect()
.vertical()
.width(Size::fill())
.spacing(2.)
.child(NavRow {
icon: IconType::BarChartSquare02,
row_label: "Stats",
target: Route::Stats {},
})
.child(NavRow {
icon: IconType::Settings02,
row_label: "Settings",
target: Route::SettingsLauncher {},
}),
)
.child(section_label("QUICK SETTINGS"))
.child(QuickSettings)
.child(divider())
.child(PanelFooter)
}
}
fn section_label(text: &'static str) -> impl IntoElement {
label()
.text(text)
.font_size(11.)
.letter_spacing(0.8)
.font_weight(FontWeight::SEMI_BOLD)
.color(colors::fg_secondary())
}
#[derive(PartialEq)]
struct AccountHeader;
impl Component for AccountHeader {
fn render(&self) -> impl IntoElement {
let current = use_current_account();
let accounts_query = use_accounts();
let dispatch = use_dispatch();
let mut hovered = use_state(|| false);
let loading = query_is_loading(&current);
let unusable = query_error(&current).is_some();
let account = try_default_account(&current).or_else(|| {
unusable
.then(|| {
try_accounts(&accounts_query)
.unwrap_or_default()
.into_iter()
.next()
})
.flatten()
});
let uuid = account
.as_ref()
.map(|account| account.id.to_string())
.unwrap_or_else(|| uuid::Uuid::nil().to_string());
let username = match account.as_ref() {
Some(account) => account.username.clone(),
None if loading => "Loading…".to_string(),
None => "Not signed in".to_string(),
};
let subtitle = match (account.as_ref(), unusable) {
(_, true) => "Microsoft Account required",
(Some(account), false) if account.is_microsoft() => "Microsoft account",
(Some(_), false) => "Offline account",
(None, false) if loading => "Checking accounts",
(None, false) => "No account selected",
};
let subtitle_color = if unusable {
colors::danger()
} else {
colors::fg_secondary()
};
let signed_out = account.is_none() && !loading;
let action_label = if signed_out { "Sign In" } else { "Switch" };
let open_switcher = move |_| {
dispatch.close_control_center();
if signed_out {
let _ = RouterContext::get().push(Route::SettingsAccounts {});
} else {
dispatch.open_account_switcher();
}
};
rect()
.horizontal()
.width(Size::fill())
.content(Content::Flex)
.cross_align(Alignment::Center)
.spacing(10.)
.padding(Gaps::new_all(4.))
.child(Avatar::new(uuid).width(Size::px(40.)).height(Size::px(40.)))
.child(
rect()
.vertical()
.width(Size::flex(1.0))
.spacing(2.)
.child(
label()
.text(username)
.font_size(16.)
.font_weight(FontWeight::MEDIUM)
.max_lines(1)
.color(colors::fg_primary()),
)
.child(
label()
.text(subtitle)
.font_size(12.)
.max_lines(1)
.color(subtitle_color),
),
)
.child(
rect()
.horizontal()
.cross_align(Alignment::Center)
.spacing(6.)
.padding(Gaps::new_symmetric(6., 8.))
.corner_radius(CornerRadius::new_all(8.))
.background(Color::RED.with_a(0))
.maybe(*hovered.read(), |el| {
el.background(colors::ghost_overlay_hover())
})
.cursor(CursorIcon::Pointer)
.a11y_role(AccessibilityRole::Button)
.on_pointer_enter(move |_| hovered.set(true))
.on_pointer_leave(move |_| hovered.set(false))
.on_press(open_switcher)
.child(
label()
.text(action_label)
.font_size(13.)
.color(colors::fg_secondary()),
)
.child(
Icon::new(IconType::ArrowRight)
.size(14.)
.color(colors::fg_secondary()),
),
)
}
}
#[derive(PartialEq)]
struct NavRow {
icon: IconType,
row_label: &'static str,
target: Route,
}
impl Component for NavRow {
fn render(&self) -> impl IntoElement {
let dispatch = use_dispatch();
let mut hovered = use_state(|| false);
let target = self.target.clone();
let go = move |_| {
dispatch.close_control_center();
let _ = RouterContext::get().push(target.clone());
};
rect()
.horizontal()
.width(Size::fill())
.height(Size::px(42.))
.content(Content::Flex)
.cross_align(Alignment::Center)
.spacing(12.)
.padding(Gaps::new_symmetric(0., 8.))
.corner_radius(CornerRadius::new_all(8.))
.background(Color::RED.with_a(0))
.maybe(*hovered.read(), |el| {
el.background(colors::ghost_overlay_hover())
})
.cursor(CursorIcon::Pointer)
.a11y_role(AccessibilityRole::Button)
.on_pointer_enter(move |_| hovered.set(true))
.on_pointer_leave(move |_| hovered.set(false))
.on_press(go)
.child(Icon::new(self.icon).size(20.).color(colors::fg_secondary()))
.child(
label()
.text(self.row_label)
.font_size(15.)
.width(Size::flex(1.0))
.color(colors::fg_primary()),
)
.child(
Icon::new(IconType::ArrowRight)
.size(16.)
.color(colors::fg_secondary()),
)
}
}
#[derive(PartialEq)]
struct QuickSettings;
impl Component for QuickSettings {
fn render(&self) -> impl IntoElement {
let settings = use_settings_snapshot().settings;
let dispatch = use_dispatch();
let discord_on = settings.discord_enabled;
let toggle_discord = {
let dispatch = dispatch.clone();
move |_| {
dispatch.edit_settings(|s| s.discord_enabled = !s.discord_enabled);
}
};
rect()
.horizontal()
.width(Size::fill())
.content(Content::Flex)
.spacing(8.)
.child(
QuickTile::new(IconType::Discord, "Discord RPC", discord_on)
.on_press(toggle_discord),
)
}
}
struct QuickTile {
icon: IconType,
title: &'static str,
on: bool,
enabled: bool,
on_press: Option<EventHandler<Event<PressEventData>>>,
}
impl PartialEq for QuickTile {
fn eq(&self, other: &Self) -> bool {
self.icon == other.icon
&& self.title == other.title
&& self.on == other.on
&& self.enabled == other.enabled
}
}
impl QuickTile {
fn new(icon: IconType, title: &'static str, on: bool) -> Self {
Self {
icon,
title,
on,
enabled: true,
on_press: None,
}
}
fn on_press(mut self, on_press: impl Into<EventHandler<Event<PressEventData>>>) -> Self {
self.on_press = Some(on_press.into());
self
}
}
impl Component for QuickTile {
fn render(&self) -> impl IntoElement {
let mut hovered = use_state(|| false);
let on = self.on;
let enabled = self.enabled;
let handler = self.on_press.clone();
let background = if !enabled {
colors::component_bg().with_a(120)
} else if on {
if *hovered.read() {
colors::brand_hover()
} else {
colors::brand()
}
} else if *hovered.read() {
colors::component_bg_hover()
} else {
colors::component_bg()
};
let title_color = if enabled {
colors::fg_primary()
} else {
colors::fg_primary_disabled()
};
let sub_color = if !enabled {
colors::fg_primary_disabled()
} else if on {
colors::fg_primary().with_a(200)
} else {
colors::fg_secondary()
};
rect()
.horizontal()
.width(Size::flex(1.0))
.height(Size::px(64.))
.content(Content::Flex)
.cross_align(Alignment::Center)
.spacing(10.)
.padding(Gaps::new_symmetric(0., 12.))
.corner_radius(CornerRadius::new_all(10.))
.background(background)
.maybe(!enabled, |el| el.opacity(0.45))
.maybe(!on || !enabled, |el| {
el.border(
Border::new()
.fill(colors::component_border())
.width(1.)
.alignment(BorderAlignment::Inner),
)
})
.maybe(enabled, |el| {
el.cursor(CursorIcon::Pointer)
.a11y_role(AccessibilityRole::Button)
.on_pointer_enter(move |_| hovered.set(true))
.on_pointer_leave(move |_| hovered.set(false))
})
.map(handler, |el, handler| el.on_press(move |e| handler.call(e)))
.child(Icon::new(self.icon).size(24.).color(title_color))
.child(
rect()
.vertical()
.width(Size::flex(1.0))
.child(
label()
.text(self.title)
.font_size(12.)
.font_weight(FontWeight::MEDIUM)
.max_lines(1)
.color(title_color),
)
.child(
label()
.text(if on { "On" } else { "Off" })
.font_size(11.)
.color(sub_color),
),
)
}
}
#[derive(PartialEq)]
struct PanelFooter;
impl Component for PanelFooter {
fn render(&self) -> impl IntoElement {
let settings = use_settings_snapshot().settings;
let current = use_current_account();
let remove = use_remove_account();
let mut confirming = use_state(|| false);
let folder = settings
.data_dir
.clone()
.or_else(|| oneclient_core::settings::data_dir::default_path().ok());
let account = try_default_account(&current);
let account_id = account.as_ref().map(|account| account.id);
let username = account
.as_ref()
.map(|account| account.username.clone())
.unwrap_or_default();
let confirm_sign_out = move |_| {
if let Some(id) = account_id {
remove.mutate(RemoveAccountKeys { id });
}
confirming.set(false);
};
rect()
.vertical()
.width(Size::fill())
.child(
rect()
.horizontal()
.width(Size::fill())
.content(Content::Flex)
.cross_align(Alignment::Center)
.child(
footer_action(
IconType::Folder,
"Open launcher folder",
colors::fg_primary(),
)
.on_press(move |_| {
if let Some(folder) = folder.clone() {
std::fs::create_dir_all(&folder).ok();
crate::platform::open_path(&folder.to_string_lossy());
}
}),
)
.child(rect().width(Size::flex(1.0)))
.map(account_id, |el, _| {
el.child(
footer_action(
IconType::LogOut01,
"Sign out",
colors::fg_primary(),
)
.on_press(move |_| confirming.set(true)),
)
}),
)
.maybe(*confirming.read(), |el| {
el.child(
OverlayPopup::new()
.overlay_level(OVERLAY_MAX_LEVEL)
.on_close(move |_| confirming.set(false))
.child(
rect()
.width(Size::window_percent(100.))
.height(Size::window_percent(100.))
.center()
.child(
rect()
.vertical()
.width(Size::px(400.))
.max_width(Size::window_percent(90.))
.spacing(14.)
.padding(Gaps::new_all(20.))
.corner_radius(CornerRadius::new_all(14.))
.background(colors::page_elevated())
.border(border_all_color(
1.,
colors::component_border(),
))
.child(
rect()
.horizontal()
.cross_align(Alignment::Center)
.spacing(10.)
.child(
Icon::new(IconType::AlertTriangle)
.size(20.)
.color(colors::code_warn()),
)
.child(
label()
.text("Sign out?")
.font_size(16.)
.font_weight(FontWeight::SEMI_BOLD)
.color(colors::fg_primary()),
),
)
.child(
label()
.text(format!(
"{username} will be removed from OneClient. You will need to sign in again to use this account."
))
.font_size(12.)
.max_lines(4)
.width(Size::fill())
.color(colors::fg_secondary()),
)
.child(
rect()
.horizontal()
.width(Size::fill())
.main_align(Alignment::End)
.spacing(8.)
.child(
Button::new()
.secondary()
.text("Cancel")
.on_press(move |_| {
confirming.set(false)
}),
)
.child(
Button::new()
.danger()
.text("Sign out")
.on_press(confirm_sign_out),
),
),
),
),
)
})
}
}
fn footer_action(icon: IconType, text: &'static str, color: Color) -> FooterAction {
FooterAction {
icon,
text,
color,
on_press: None,
}
}
struct FooterAction {
icon: IconType,
text: &'static str,
color: Color,
on_press: Option<EventHandler<Event<PressEventData>>>,
}
impl PartialEq for FooterAction {
fn eq(&self, other: &Self) -> bool {
self.icon == other.icon && self.text == other.text && self.color == other.color
}
}
impl FooterAction {
fn on_press(mut self, on_press: impl Into<EventHandler<Event<PressEventData>>>) -> Self {
self.on_press = Some(on_press.into());
self
}
}
impl Component for FooterAction {
fn render(&self) -> impl IntoElement {
let mut hovered = use_state(|| false);
let handler = self.on_press.clone();
rect()
.horizontal()
.cross_align(Alignment::Center)
.spacing(8.)
.height(Size::px(34.))
.padding(Gaps::new_symmetric(0., 8.))
.corner_radius(CornerRadius::new_all(8.))
.background(Color::RED.with_a(0))
.maybe(*hovered.read(), |el| {
el.background(colors::ghost_overlay_hover())
})
.cursor(CursorIcon::Pointer)
.a11y_role(AccessibilityRole::Button)
.on_pointer_enter(move |_| hovered.set(true))
.on_pointer_leave(move |_| hovered.set(false))
.map(handler, |el, handler| el.on_press(move |e| handler.call(e)))
.child(Icon::new(self.icon).size(16.).color(self.color))
.child(
label()
.text(self.text)
.font_size(13.)
.max_lines(1)
.color(self.color),
)
}
}

View file

@ -0,0 +1,413 @@
use chrono::{Datelike, Days, Months, NaiveDate};
use freya::prelude::*;
use crate::components::{Icon, IconType, OverlayPopup};
use crate::theme::colors;
use crate::ui::{border_all_color, clamp_to_window};
use crate::utils::format_day;
const CELL: f32 = 30.;
const CELL_GAP: f32 = 2.;
const WEEKS: u32 = 6;
const POPUP_WIDTH: f32 = 7. * CELL + 6. * CELL_GAP + 24. + 2.;
const POPUP_OFFSET: f32 = 4.;
pub type DateRange = (NaiveDate, NaiveDate);
#[derive(PartialEq)]
pub struct DateRangePicker {
range: State<Option<DateRange>>,
bounds: DateRange,
height: f32,
}
impl DateRangePicker {
pub fn new(range: State<Option<DateRange>>, bounds: DateRange) -> Self {
Self {
range,
bounds,
height: 30.,
}
}
pub fn height(mut self, height: f32) -> Self {
self.height = height;
self
}
}
impl Component for DateRangePicker {
fn render(&self) -> impl IntoElement {
let mut range = self.range;
let (min, max) = self.bounds;
let mut open = use_state(|| false);
let mut anchor = use_state(|| Option::<NaiveDate>::None);
let mut month = use_state(move || first_of_month(max));
let mut hovering = use_state(|| false);
let mut trigger = use_state(Area::zero);
let mut panel_height = use_state(|| 0f32);
let is_open = open();
let selected = *range.read();
let pending = *anchor.read();
let shown = *month.read();
let can_prev = shown > first_of_month(min);
let can_next = shown < first_of_month(max);
let pick = move |day: NaiveDate| {
let started = *anchor.peek();
match started {
Some(start) => {
range.set(Some((start.min(day), start.max(day))));
anchor.set(None);
open.set(false);
}
None => anchor.set(Some(day)),
}
};
let trigger_text = match selected {
Some((a, b)) if a == b => format_day(a),
Some((a, b)) => format!("{} – {}", format_day(a), format_day(b)),
None => "Pick dates".to_string(),
};
rect()
.child(
rect()
.horizontal()
.height(Size::px(self.height))
.cross_align(Alignment::Center)
.spacing(6.)
.padding(Gaps::new_symmetric(0., 10.))
.corner_radius(CornerRadius::new_all(9.))
.background(if hovering() {
colors::component_bg_hover()
} else {
colors::component_bg()
})
.border(border_all_color(
1.,
if selected.is_some() {
colors::brand()
} else {
colors::component_border()
},
))
.cursor(CursorIcon::Pointer)
.a11y_role(AccessibilityRole::Button)
.on_pointer_enter(move |_| hovering.set(true))
.on_pointer_leave(move |_| hovering.set(false))
.on_sized(move |e: Event<SizedEventData>| {
trigger.set_if_modified(e.area);
})
.on_press(move |e: Event<PressEventData>| {
e.stop_propagation();
anchor.set_if_modified(None);
open.toggle();
})
.child(
Icon::new(IconType::Calendar)
.size(13.)
.color(colors::fg_secondary()),
)
.child(
label()
.text(trigger_text)
.font_size(12.)
.max_lines(1)
.color(colors::fg_primary()),
),
)
.maybe_child(is_open.then(|| {
let area = *trigger.read();
let height = *panel_height.read();
let (x, y) = clamp_to_window(
area.max_x() - POPUP_WIDTH,
area.max_y() + POPUP_OFFSET,
POPUP_WIDTH,
height,
);
let panel = rect()
.width(Size::px(POPUP_WIDTH))
.vertical()
.spacing(8.)
.padding(12.)
.corner_radius(CornerRadius::new_all(10.))
.border(border_all_color(1., colors::component_border()))
.background(colors::page_elevated())
.on_sized(move |e: Event<SizedEventData>| {
panel_height.set_if_modified(e.area.height());
})
.opacity(if height > 0. { 1. } else { 0. })
.child(month_header(shown, can_prev, can_next, move |step| {
let next = if step < 0 {
shown.checked_sub_months(Months::new(1))
} else {
shown.checked_add_months(Months::new(1))
};
if let Some(next) = next {
month.set(next.clamp(first_of_month(min), first_of_month(max)));
}
}))
.child(weekday_header())
.child(month_grid(shown, min, max, selected, pending, pick))
.child(footer(selected.is_some(), pending, move || {
range.set(None);
anchor.set(None);
open.set(false);
}));
OverlayPopup::new()
.backdrop(false)
.position(Position::new_global().top(y).left(x))
.on_close(move |()| {
anchor.set(None);
open.set(false);
})
.child(panel.into_element())
.into_element()
}))
}
}
fn first_of_month(date: NaiveDate) -> NaiveDate {
date.with_day(1).unwrap_or(date)
}
fn month_header(
shown: NaiveDate,
can_prev: bool,
can_next: bool,
step: impl FnMut(i32) + Clone + 'static,
) -> Element {
let back = step.clone();
rect()
.horizontal()
.content(Content::Flex)
.width(Size::fill())
.cross_align(Alignment::Center)
.child(arrow(IconType::ChevronsLeft, can_prev, back, -1))
.child(
label()
.text(format!("{} {}", month_name(shown.month0()), shown.year()))
.font_size(13.)
.font_weight(FontWeight::SEMI_BOLD)
.width(Size::flex(1.0))
.text_align(TextAlign::Center)
.color(colors::fg_primary()),
)
.child(arrow(IconType::ChevronsRight, can_next, step, 1))
.into_element()
}
fn arrow(
icon: IconType,
enabled: bool,
mut step: impl FnMut(i32) + 'static,
delta: i32,
) -> Element {
rect()
.width(Size::px(24.))
.height(Size::px(24.))
.center()
.corner_radius(CornerRadius::new_all(6.))
.background(colors::component_bg())
.maybe(enabled, |el| {
el.cursor(CursorIcon::Pointer)
.on_press(move |_| step(delta))
})
.child(Icon::new(icon).size(12.).color(if enabled {
colors::fg_secondary()
} else {
colors::fg_secondary().with_a(70)
}))
.into_element()
}
fn weekday_header() -> Element {
let mut row = rect().horizontal().spacing(CELL_GAP);
for day in ["M", "T", "W", "T", "F", "S", "S"] {
row = row.child(
rect().width(Size::px(CELL)).center().child(
label()
.text(day.to_string())
.font_size(10.)
.color(colors::fg_secondary()),
),
);
}
row.into_element()
}
fn month_grid(
shown: NaiveDate,
min: NaiveDate,
max: NaiveDate,
selected: Option<DateRange>,
pending: Option<NaiveDate>,
pick: impl FnMut(NaiveDate) + Clone + 'static,
) -> Element {
let lead = shown.weekday().num_days_from_monday();
let first_cell = shown
.checked_sub_days(Days::new(u64::from(lead)))
.unwrap_or(shown);
let mut grid = rect().vertical().spacing(CELL_GAP);
for week in 0..WEEKS {
let mut row = rect().horizontal().spacing(CELL_GAP);
for weekday in 0..7u32 {
let offset = u64::from(week * 7 + weekday);
let Some(day) = first_cell.checked_add_days(Days::new(offset)) else {
continue;
};
row = row.child(DayCell {
day,
outside: day.month0() != shown.month0(),
enabled: (min..=max).contains(&day),
in_range: selected.is_some_and(|(a, b)| (a..=b).contains(&day)),
edge: selected.is_some_and(|(a, b)| day == a || day == b) || pending == Some(day),
pick: pick.clone(),
});
}
grid = grid.child(row);
}
grid.into_element()
}
struct DayCell<F: FnMut(NaiveDate) + Clone + 'static> {
day: NaiveDate,
outside: bool,
enabled: bool,
in_range: bool,
edge: bool,
pick: F,
}
impl<F: FnMut(NaiveDate) + Clone + 'static> PartialEq for DayCell<F> {
fn eq(&self, other: &Self) -> bool {
self.day == other.day
&& self.outside == other.outside
&& self.enabled == other.enabled
&& self.in_range == other.in_range
&& self.edge == other.edge
}
}
impl<F: FnMut(NaiveDate) + Clone + 'static> Component for DayCell<F> {
fn render(&self) -> impl IntoElement {
let mut hovering = use_state(|| false);
let day = self.day;
let enabled = self.enabled;
let mut pick = self.pick.clone();
let background = if self.edge {
colors::brand()
} else if self.in_range {
colors::brand().with_a(50)
} else if hovering() && enabled {
colors::component_bg_hover()
} else {
Color::TRANSPARENT
};
let color = if self.edge {
colors::fg_primary()
} else if !enabled || self.outside {
colors::fg_secondary().with_a(90)
} else {
colors::fg_primary()
};
rect()
.width(Size::px(CELL))
.height(Size::px(CELL - 2.))
.center()
.corner_radius(CornerRadius::new_all(6.))
.background(background)
.maybe(enabled, |el| {
el.cursor(CursorIcon::Pointer)
.on_pointer_enter(move |_| hovering.set(true))
.on_pointer_leave(move |_| hovering.set(false))
.on_press(move |e: Event<PressEventData>| {
e.stop_propagation();
pick(day);
})
})
.child(
label()
.text(day.day().to_string())
.font_size(11.)
.color(color),
)
}
}
fn footer(
has_range: bool,
pending: Option<NaiveDate>,
mut clear: impl FnMut() + 'static,
) -> Element {
let hint = match pending {
Some(day) => format!("From {} — pick an end date", format_day(day)),
None => "Click a start date, then an end date".to_string(),
};
rect()
.horizontal()
.content(Content::Flex)
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(8.)
.child(
label()
.text(hint)
.font_size(10.)
.max_lines(2)
.width(Size::flex(1.0))
.color(colors::fg_secondary()),
)
.maybe_child(has_range.then(|| {
rect()
.padding(Gaps::new_symmetric(4., 8.))
.corner_radius(CornerRadius::new_all(6.))
.background(colors::component_bg())
.cursor(CursorIcon::Pointer)
.a11y_role(AccessibilityRole::Button)
.on_press(move |e: Event<PressEventData>| {
e.stop_propagation();
clear();
})
.child(
label()
.text("Reset")
.font_size(11.)
.color(colors::fg_secondary()),
)
.into_element()
}))
.into_element()
}
fn month_name(month0: u32) -> &'static str {
const MONTHS: [&str; 12] = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
MONTHS[(month0 as usize).min(11)]
}

View file

@ -14,8 +14,11 @@ pub struct Dropdown {
selected: String,
options: Vec<String>,
on_select: Option<EventHandler<usize>>,
leading: Option<Element>,
width: Size,
height: Size,
outlined: bool,
checked: Option<Vec<bool>>,
key: DiffKey,
}
@ -25,8 +28,11 @@ impl Dropdown {
selected: selected.into(),
options,
on_select: None,
leading: None,
width: Size::px(72.),
height: Size::px(24.),
outlined: false,
checked: None,
key: DiffKey::None,
}
}
@ -43,6 +49,21 @@ impl Dropdown {
self
}
pub fn outlined(mut self) -> Self {
self.outlined = true;
self
}
pub fn checked(mut self, checked: Vec<bool>) -> Self {
self.checked = Some(checked);
self
}
pub fn leading(mut self, leading: impl IntoElement) -> Self {
self.leading = Some(leading.into_element());
self
}
pub fn on_select(mut self, handler: impl Into<EventHandler<usize>>) -> Self {
self.on_select = Some(handler.into());
self
@ -64,17 +85,21 @@ impl Component for Dropdown {
let focus = use_focus(a11y_id);
let mut button_area = use_state(|| None::<Area>);
let mut list_size = use_state(|| None::<Size2D>);
let mut list_area = use_state(|| None::<Area>);
let selected = self.selected.clone();
let leading = self.leading.clone();
let options = self.options.clone();
let on_select = self.on_select.clone();
let outlined = self.outlined;
let checked = self.checked.clone();
let multi = checked.is_some();
let is_open = open();
use_overlay_claim_when(is_open);
if !is_open && list_size().is_some() {
let _ = list_size.take();
if !is_open && list_area().is_some() {
let _ = list_area.take();
}
let trigger_bg = if hovering() {
@ -87,13 +112,13 @@ impl Component for Dropdown {
let list_h =
visible as f32 * OPTION_HEIGHT + visible.saturating_sub(1) as f32 * OPTION_SPACING;
let offset_y = match (button_area(), list_size()) {
let offset_y = match (button_area(), list_area()) {
(Some(button), Some(list)) => {
let root_height = Platform::get().root_size.peek().height;
let space_below = root_height - button.max_y();
let space_above = button.min_y();
if list.height > space_below && list.height <= space_above {
-(button.height() + list.height + 8.)
if list.height() > space_below && list.height() <= space_above {
-(button.height() + list.height() + 8.)
} else {
0.
}
@ -105,8 +130,15 @@ impl Component for Dropdown {
.map(|b| Size::px(b.width()))
.unwrap_or_else(|| self.width.clone());
let on_global_pointer_press = move |_: Event<PointerEventData>| {
open.set_if_modified(false);
let on_global_pointer_up = move |e: Event<PointerEventData>| {
let loc = e.global_location();
let inside = list_area().is_some_and(|area| {
area.translate((0., offset_y).into())
.contains((loc.x as f32, loc.y as f32).into())
});
if !(multi && inside) {
open.set_if_modified(false);
}
};
rect()
@ -123,9 +155,13 @@ impl Component for Dropdown {
.horizontal()
.center()
.spacing(4.)
.padding(Gaps::new_symmetric(0., 8.))
.corner_radius(CornerRadius::new_all(6.))
.padding(Gaps::new_symmetric(0., if outlined { 12. } else { 8. }))
.corner_radius(CornerRadius::new_all(if outlined { 8. } else { 6. }))
.background(trigger_bg)
.maybe(outlined, |el| {
el.main_align(Alignment::SpaceBetween)
.border(border_all_color(1., colors::component_border()))
})
.a11y_id(a11y_id)
.a11y_focusable(true)
.a11y_role(AccessibilityRole::Button)
@ -134,7 +170,7 @@ impl Component for Dropdown {
})
.on_pointer_enter(move |_| hovering.set(true))
.on_pointer_leave(move |_| hovering.set(false))
.on_global_pointer_press(on_global_pointer_press)
.on_global_pointer_up(on_global_pointer_up)
.on_sized(move |e: Event<SizedEventData>| {
button_area.set_if_modified(Some(e.area));
})
@ -143,10 +179,11 @@ impl Component for Dropdown {
e.stop_propagation();
open.toggle();
})
.maybe_child(leading)
.child(
label()
.text(selected)
.font_size(12.)
.font_size(if outlined { 13. } else { 12. })
.color(colors::fg_primary()),
)
.child(
@ -164,7 +201,7 @@ impl Component for Dropdown {
.offset_y(offset_y)
.content(Content::Fit)
.on_sized(move |e: Event<SizedEventData>| {
list_size.set_if_modified(Some(e.area.size));
list_area.set_if_modified(Some(e.area));
})
.child(
rect()
@ -181,11 +218,16 @@ impl Component for Dropdown {
.children(options.into_iter().enumerate().map(
|(idx, option)| {
let on_select = on_select.clone();
DropdownOption::new(option, move |_| {
let checked = checked
.as_ref()
.map(|c| c.get(idx).copied().unwrap_or(false));
DropdownOption::new(option, checked, move |_| {
if let Some(handler) = &on_select {
handler.call(idx);
}
open.set(false);
if !multi {
open.set(false);
}
})
.into_element()
},
@ -203,16 +245,19 @@ impl Component for Dropdown {
struct DropdownOption {
text: String,
checked: Option<bool>,
on_press: EventHandler<Event<PressEventData>>,
}
impl DropdownOption {
pub fn new(
text: impl Into<String>,
checked: Option<bool>,
on_press: impl Into<EventHandler<Event<PressEventData>>>,
) -> Self {
Self {
text: text.into(),
checked,
on_press: on_press.into(),
}
}
@ -220,7 +265,7 @@ impl DropdownOption {
impl PartialEq for DropdownOption {
fn eq(&self, other: &Self) -> bool {
self.text == other.text
self.text == other.text && self.checked == other.checked
}
}
@ -233,7 +278,8 @@ impl Component for DropdownOption {
rect()
.width(Size::fill())
.padding(Gaps::new_symmetric(6., 8.))
.height(Size::px(OPTION_HEIGHT))
.padding(Gaps::new_symmetric(0., 8.))
.corner_radius(CornerRadius::new_all(6.))
.a11y_id(a11y_id)
.a11y_focusable(true)
@ -246,11 +292,27 @@ impl Component for DropdownOption {
.on_pointer_enter(move |_| hovering.set(true))
.on_pointer_leave(move |_| hovering.set(false))
.on_press(self.on_press.clone())
.horizontal()
.content(Content::Flex)
.cross_align(Alignment::Center)
.child(
label()
.text(self.text.clone())
.width(Size::flex(1.))
.font_size(12.)
.color(colors::fg_primary()),
)
.maybe_child(self.checked.map(|checked| {
rect()
.width(Size::px(14.))
.height(Size::px(14.))
.maybe_child(checked.then(|| {
Icon::new(IconType::Check)
.size(14.)
.color(colors::brand())
.into_element()
}))
.into_element()
}))
}
}

View file

@ -1,25 +1,38 @@
use std::path::PathBuf;
use std::sync::LazyLock;
use bytes::Bytes;
use freya::animation::{
AnimNum, Ease, Function, OnChange, OnCreation, OnFinish, use_animation_with_dependencies,
};
use freya::prelude::*;
use oneclient_core::clusters::Cluster;
use oneclient_core::images::DEFAULT_IMAGE_EDGE;
use oneclient_common::domain::GameLoader;
use oneclient_common::{VersionKey, parse_mc_version};
use oneclient_core::clusters::Cluster;
use oneclient_core::images::{DEFAULT_IMAGE_EDGE, PREVIEW_IMAGE_EDGE};
use crate::AppAssets;
use crate::hooks::{loaded_image, use_cached_image, use_version_metadata};
use crate::hooks::{
loaded_image, resolve_art_url, settled_or_loading, use_cached_image, use_local_image,
use_version_art, use_version_metadata,
};
use crate::layout::HOME_BACKGROUND_ASSET;
use crate::theme::colors;
use crate::ui::ImageFallbackExt;
/// The size cluster cards request so it is usually already cached when anything else wants it
pub const ART_PREVIEW_EDGE: u32 = 512;
pub const ART_PREVIEW_EDGE: u32 = PREVIEW_IMAGE_EDGE;
#[derive(PartialEq, Clone)]
pub struct DynamicArt {
major: Option<u32>,
key: Option<VersionKey>,
loader: Option<GameLoader>,
cover: Option<PathBuf>,
cover_picked: bool,
max_edge: u32,
preview_edge: Option<u32>,
url: Option<String>,
skeleton: bool,
}
impl DynamicArt {
@ -28,8 +41,7 @@ impl DynamicArt {
major: Some(major),
key,
loader,
max_edge: DEFAULT_IMAGE_EDGE,
preview_edge: None,
..Self::fallback()
}
}
@ -39,8 +51,15 @@ impl DynamicArt {
major: parsed.as_ref().map(|p| p.major),
key: parsed.and_then(|p| p.key()),
loader: Some(cluster.mc_loader),
max_edge: DEFAULT_IMAGE_EDGE,
preview_edge: None,
cover: cluster.cover_file(),
..Self::fallback()
}
}
pub fn for_url(url: String) -> Self {
Self {
url: Some(url),
..Self::fallback()
}
}
@ -49,11 +68,34 @@ impl DynamicArt {
major: None,
key: None,
loader: None,
cover: None,
cover_picked: false,
max_edge: DEFAULT_IMAGE_EDGE,
preview_edge: None,
url: None,
skeleton: false,
}
}
#[must_use]
pub fn cover(mut self, cover: Option<PathBuf>) -> Self {
self.cover = cover;
self
}
#[must_use]
pub fn picked_cover(mut self, cover: Option<PathBuf>) -> Self {
self.cover = cover;
self.cover_picked = true;
self
}
#[must_use]
pub fn skeleton(mut self, skeleton: bool) -> Self {
self.skeleton = skeleton;
self
}
#[must_use]
pub fn max_edge(mut self, max_edge: u32) -> Self {
self.max_edge = max_edge;
@ -68,26 +110,46 @@ impl DynamicArt {
}
pub fn use_bytes(&self) -> (String, Bytes) {
use_art_bytes(
self.major,
self.key,
self.loader,
self.max_edge,
self.preview_edge,
)
use_art_bytes(self)
}
}
pub fn use_art_bytes(
major: Option<u32>,
key: Option<VersionKey>,
loader: Option<GameLoader>,
max_edge: u32,
preview_edge: Option<u32>,
) -> (String, Bytes) {
let fallback = use_memo(|| AppAssets::get_bytes(HOME_BACKGROUND_ASSET).unwrap_or_default());
pub fn use_art_bytes(art: &DynamicArt) -> (String, Bytes) {
let max_edge = art.max_edge;
use_resolved_art(art)
.unwrap_or_else(|| (format!("{max_edge}|{HOME_BACKGROUND_ASSET}"), bundled_art()))
}
let art_url = use_version_metadata(major, key, loader).and_then(|m| m.art_url);
fn bundled_art() -> Bytes {
static ART: LazyLock<Bytes> =
LazyLock::new(|| AppAssets::get_bytes(HOME_BACKGROUND_ASSET).unwrap_or_default());
ART.clone()
}
fn use_resolved_art(art: &DynamicArt) -> Option<(String, Bytes)> {
let DynamicArt {
major,
key,
loader,
cover,
cover_picked,
max_edge,
preview_edge,
url,
skeleton: _,
} = art.clone();
let cover_query = use_local_image(cover.clone().unwrap_or_default(), max_edge, cover_picked);
let cover_bytes = cover.as_ref().and_then(|path| {
settled_or_loading(&cover_query)
.filter(|bytes: &Bytes| !bytes.is_empty())
.map(|bytes| (format!("{max_edge}|{}", path.display()), bytes))
});
let curated = use_version_metadata(major, key, loader);
let candidates = use_version_art(major, key);
let art_url = url.or_else(|| resolve_art_url(curated.as_ref(), &candidates));
let image_query = use_cached_image(art_url.clone(), max_edge);
@ -105,17 +167,41 @@ pub fn use_art_bytes(
.map(|(url, bytes)| (format!("{edge}|{url}"), bytes))
};
full.or_else(preview).unwrap_or_else(|| {
(
format!("{max_edge}|{HOME_BACKGROUND_ASSET}"),
fallback.read().clone(),
)
})
cover_bytes.or(full).or_else(preview)
}
impl Component for DynamicArt {
fn render(&self) -> impl IntoElement {
let (key, bytes) = self.use_bytes();
let resolved = use_resolved_art(self);
let show_skeleton = self.skeleton && resolved.is_none();
let pulse = use_animation_with_dependencies(&show_skeleton, |conf, show| {
conf.on_change(OnChange::Rerun);
if *show {
conf.on_creation(OnCreation::Run);
conf.on_finish(OnFinish::reverse());
}
AnimNum::new(0.4, 0.9)
.time(800)
.ease(Ease::InOut)
.function(Function::Sine)
});
let pulse_v = pulse.read().value();
if show_skeleton {
return rect()
.width(Size::fill())
.height(Size::fill())
.background(colors::component_bg())
.opacity(pulse_v)
.into_element();
}
let max_edge = self.max_edge;
let (key, bytes) = resolved
.unwrap_or_else(|| (format!("{max_edge}|{HOME_BACKGROUND_ASSET}"), bundled_art()));
ImageViewer::new((key, bytes))
.width(Size::fill())

View file

@ -0,0 +1,146 @@
use freya::prelude::*;
use crate::components::{
Button, Icon, IconType, Markdown, MarkdownStyle, OverlayPopup, ScrollArea,
};
use crate::hooks::{EssentialGuardKind, use_cluster_mutation, use_essential_guard};
use crate::theme::colors;
use crate::ui::border_all_color;
const CARD_BG: Color = Color::from_rgb(26, 34, 41);
const BODY_MAX_H: f32 = 400.;
#[derive(PartialEq)]
pub struct EssentialConfirmOverlay;
impl Component for EssentialConfirmOverlay {
fn render(&self) -> impl IntoElement {
let mut pending = use_essential_guard();
let cluster = use_cluster_mutation();
let Some(request) = pending.read().clone() else {
return rect().into_element();
};
let (title, confirm_label) = match request.kind {
EssentialGuardKind::Disable => (format!("Turn off {}?", request.name), "Turn it off"),
EssentialGuardKind::Remove => (format!("Remove {}?", request.name), "Remove anyway"),
};
let action = request.action.clone();
OverlayPopup::new()
.on_close(move |_| pending.set(None))
.child(
rect()
.width(Size::window_percent(100.))
.height(Size::window_percent(100.))
.center()
.child(
rect()
.vertical()
.width(Size::px(440.))
.max_width(Size::window_percent(90.))
.spacing(14.)
.padding(Gaps::new_all(20.))
.corner_radius(CornerRadius::new_all(14.))
.background(CARD_BG)
.border(border_all_color(1., colors::component_border()))
.child(
rect()
.horizontal()
.cross_align(Alignment::Center)
.spacing(10.)
.child(
Icon::new(IconType::AlertTriangle)
.size(20.)
.color(colors::code_warn()),
)
.child(
label()
.text(title)
.font_size(16.)
.font_weight(FontWeight::SEMI_BOLD)
.color(colors::fg_primary()),
),
)
.child(WarningBody {
body: request.body.clone(),
})
.child(
rect()
.horizontal()
.width(Size::fill())
.main_align(Alignment::End)
.spacing(8.)
.child(
Button::new()
.secondary()
.on_press(move |_| {
cluster.mutate(action.clone());
pending.set(None);
})
.text(confirm_label),
)
.child(
Button::new()
.primary()
.on_press(move |_| pending.set(None))
.text("Keep it"),
),
),
),
)
.into_element()
}
}
#[derive(PartialEq)]
struct WarningBody {
body: String,
}
impl Component for WarningBody {
fn render(&self) -> impl IntoElement {
let mut content_h = use_state(|| 0f32);
let height = content_h.read().min(BODY_MAX_H);
ScrollArea::new()
.width(Size::fill())
.height(Size::px(height))
.scrollbar_gutter(true)
.child(
rect()
.width(Size::fill())
.on_sized(move |e: Event<SizedEventData>| {
let h = e.area.height();
if (*content_h.read() - h).abs() > 0.5 {
content_h.set(h);
}
})
.child(
rect()
.width(Size::fill())
.padding(Gaps::new_all(12.))
.corner_radius(CornerRadius::new_all(8.))
.background(colors::component_bg())
.border(border_all_color(1., colors::component_border()))
.child(Markdown::new(self.body.clone()).width(Size::fill()).style(
MarkdownStyle {
color: Color::WHITE,
color_link: colors::code_info(),
color_code: Color::WHITE,
background_code: CARD_BG,
background_blockquote: CARD_BG,
border_blockquote: colors::component_border(),
background_divider: colors::component_border(),
headings: [16., 14., 13., 12., 12., 12.],
paragraph_size: 12.,
code_font_size: 11.,
..MarkdownStyle::default()
},
)),
),
)
}
}

View file

@ -3,17 +3,24 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::Duration;
use freya::animation::{AnimNum, Ease, Function, OnCreation, use_animation};
use freya::prelude::*;
use freya::router::use_route;
use oneclient_core::clusters::Cluster;
use oneclient_content::modpacks::detect_format;
use oneclient_content::packages::ContentType;
use oneclient_core::clusters::{Cluster, ModpackSource};
use oneclient_db::models::ClusterId;
use crate::Route;
use crate::components::{Button, Dropdown, Icon, IconType, OverlayPopup, ScrollArea};
use crate::hooks::{settled_or_loading, use_active_cluster_id, use_clusters, use_dispatch};
use crate::hooks::{
add_world_datapacks, settled_or_loading, spawn_world_task, try_cluster_worlds,
use_active_cluster_id, use_cluster_worlds, use_clusters, use_datapack_world, use_debounced,
use_dispatch,
};
use crate::launcher::off_ui;
use crate::theme::colors;
use crate::ui::border_all_color;
@ -23,19 +30,29 @@ const FILE_ROW_H: f32 = 34.;
const FILE_LIST_MAX_ROWS: usize = 5;
const TYPE_DROPDOWN_W: f32 = 108.;
/// Worlds are directories and modpacks/datapacks have no view so both are excluded
const IMPORTABLE: [ContentType; 3] = [
/// Worlds are directories and modpacks have no view so both are excluded
const IMPORTABLE: [ContentType; 4] = [
ContentType::Mod,
ContentType::ResourcePack,
ContentType::Shader,
ContentType::DataPack,
];
const SHADER_HINTS: [&str; 5] = ["shader", "bsl", "seus", "complementary", "sildur"];
const DATAPACK_HINTS: [&str; 4] = ["datapack", "data pack", "data_pack", "data-pack"];
fn importable(mod_loader: bool) -> Vec<ContentType> {
IMPORTABLE
.into_iter()
.filter(|ct| mod_loader || !ct.needs_mod_loader())
.collect()
}
fn content_label(content_type: ContentType) -> &'static str {
match content_type {
ContentType::ResourcePack => "Textures",
ContentType::Shader => "Shaders",
ContentType::DataPack => "Data packs",
_ => "Mods",
}
}
@ -51,26 +68,87 @@ fn file_name(path: &Path) -> String {
}
/// Precedence `.jar` is always a mod then the current route then a name sniff
fn infer_content_type(path: &Path, route_type: Option<ContentType>) -> ContentType {
fn infer_content_type(
path: &Path,
route_type: Option<ContentType>,
mod_loader: bool,
) -> Option<ContentType> {
if extension(path).as_deref() == Some("jar") {
return ContentType::Mod;
return mod_loader.then_some(ContentType::Mod);
}
if let Some(route_type) = route_type {
return route_type;
if mod_loader || !route_type.needs_mod_loader() {
return Some(route_type);
}
if route_type == ContentType::Shader {
return None;
}
}
let name = file_name(path).to_lowercase();
if SHADER_HINTS.iter().any(|hint| name.contains(hint)) {
ContentType::Shader
if DATAPACK_HINTS.iter().any(|hint| name.contains(hint)) {
Some(ContentType::DataPack)
} else if mod_loader && SHADER_HINTS.iter().any(|hint| name.contains(hint)) {
Some(ContentType::Shader)
} else {
ContentType::ResourcePack
Some(ContentType::ResourcePack)
}
}
const DROP_SETTLE: Duration = Duration::from_millis(300);
pub fn accept_drop(
paths: &[PathBuf],
mut pending: State<Vec<PathBuf>>,
mut modpacks: State<Vec<PathBuf>>,
) {
for path in paths {
match extension(path).as_deref() {
Some("mrpack") => modpacks.write().push(path.clone()),
Some("zip") => {
let path = path.clone();
spawn(async move {
let probe = path.clone();
let is_modpack =
off_ui(async move { detect_format(&probe).await.is_ok() }).await;
if is_modpack {
modpacks.write().push(path);
} else {
pending.write().push(path);
}
});
}
_ => pending.write().push(path.clone()),
}
}
}
fn use_dropped_modpack_driver(pending: State<Vec<PathBuf>>, modpacks: State<Vec<PathBuf>>) {
let dispatch = use_dispatch();
let settled = use_debounced(
(pending.read().is_empty(), modpacks.read().len()),
DROP_SETTLE,
);
let settled = *settled.read();
use_side_effect_with_deps(&settled, move |&(prompt_closed, queued)| {
if !prompt_closed || queued == 0 || !pending.peek().is_empty() {
return;
}
let mut modpacks = modpacks;
let dropped = std::mem::take(&mut *modpacks.write());
for path in dropped {
dispatch.install_modpack(ModpackSource::File(path));
}
});
}
fn route_target(route: &Route) -> Option<(ClusterId, ContentType)> {
match route {
Route::ClusterMods { cluster_id } => Some((*cluster_id, ContentType::Mod)),
Route::ClusterShaders { cluster_id } => Some((*cluster_id, ContentType::Shader)),
Route::ClusterTextures { cluster_id } => Some((*cluster_id, ContentType::ResourcePack)),
Route::ClusterDataPacks { cluster_id } => Some((*cluster_id, ContentType::DataPack)),
_ => None,
}
}
@ -80,10 +158,12 @@ fn route_target(route: &Route) -> Option<(ClusterId, ContentType)> {
pub struct FileDropOverlay {
pub hovering: State<bool>,
pub pending: State<Vec<PathBuf>>,
pub modpacks: State<Vec<PathBuf>>,
}
impl Component for FileDropOverlay {
fn render(&self) -> impl IntoElement {
use_dropped_modpack_driver(self.pending, self.modpacks);
let files = self.pending.read().clone();
if !files.is_empty() {
@ -191,23 +271,39 @@ impl Component for DropPrompt {
let target = route_target(&route);
let initial_cluster = target.map(|(id, _)| id).or(*active_cluster.peek());
let selected_cluster = use_state(move || initial_cluster);
let remembered_world = use_datapack_world();
// Absent entries fall back to the inferred type covering drops landing mid-prompt
let overrides = use_state(HashMap::<PathBuf, ContentType>::new);
let clusters: Vec<Cluster> = settled_or_loading(&clusters_query).unwrap_or_default();
let body = if clusters.is_empty() {
no_clusters_body(pending)
} else {
prompt_body(
// The remembered cluster may have been deleted while the prompt was open
let cluster_id = selected_cluster
.read()
.filter(|id| clusters.iter().any(|c| c.id == *id))
.or_else(|| clusters.first().map(|c| c.id));
let worlds_query = use_cluster_worlds(cluster_id.unwrap_or_default());
let worlds: Vec<String> = try_cluster_worlds(&worlds_query)
.unwrap_or_default()
.into_iter()
.map(|w| w.folder_name)
.collect();
let body = match cluster_id {
None => no_clusters_body(pending),
Some(cluster_id) => prompt_body(
&files,
&clusters,
cluster_id,
target.map(|(_, ct)| ct),
selected_cluster,
&worlds,
remembered_world,
overrides,
dispatch,
pending,
)
),
};
OverlayPopup::new()
@ -297,40 +393,92 @@ fn no_clusters_body(mut pending: State<Vec<PathBuf>>) -> Element {
.into_element()
}
#[allow(clippy::too_many_arguments)]
fn prompt_body(
files: &[PathBuf],
clusters: &[Cluster],
cluster_id: ClusterId,
route_type: Option<ContentType>,
selected_cluster: State<Option<ClusterId>>,
worlds: &[String],
remembered_world: State<HashMap<ClusterId, String>>,
overrides: State<HashMap<PathBuf, ContentType>>,
dispatch: crate::Actions,
mut pending: State<Vec<PathBuf>>,
) -> Element {
// The remembered cluster may have been deleted while the prompt was open
let cluster_id = selected_cluster
.read()
.filter(|id| clusters.iter().any(|c| c.id == *id))
.unwrap_or(clusters[0].id);
let cluster_idx = clusters
.iter()
.position(|c| c.id == cluster_id)
.unwrap_or_default();
let destination = clusters.get(cluster_idx);
let mod_loader = destination.is_none_or(|c| !c.lacks_mod_loader());
let mut rejected: Vec<PathBuf> = Vec::new();
let resolved: Vec<(PathBuf, ContentType)> = files
.iter()
.map(|path| {
.filter_map(|path| {
let content_type = overrides
.read()
.get(path)
.copied()
.unwrap_or_else(|| infer_content_type(path, route_type));
(path.clone(), content_type)
.filter(|ct| mod_loader || !ct.needs_mod_loader())
.or_else(|| infer_content_type(path, route_type, mod_loader));
match content_type {
Some(content_type) => Some((path.clone(), content_type)),
None => {
rejected.push(path.clone());
None
}
}
})
.collect();
let has_datapacks = resolved.iter().any(|(_, ct)| *ct == ContentType::DataPack);
let world = remembered_world
.read()
.get(&cluster_id)
.filter(|name| worlds.contains(name))
.cloned()
.or_else(|| worlds.first().cloned());
let can_import = !resolved.is_empty() && (!has_datapacks || world.is_some());
let skipped = rejected.len();
let destination_name = destination.map(|c| c.name.clone()).unwrap_or_default();
let import_list = resolved.clone();
let import_world = world.clone();
let import = move |_| {
dispatch.import_local_files(cluster_id, import_list.clone());
if !can_import {
return;
}
let (datapacks, packages): (Vec<_>, Vec<_>) = import_list
.iter()
.cloned()
.partition(|(_, ct)| *ct == ContentType::DataPack);
dispatch.import_local_files(cluster_id, packages);
if let Some(world) = import_world.clone()
&& !datapacks.is_empty()
{
spawn_world_task(
dispatch.clone(),
"Couldn't add data packs",
add_world_datapacks(
cluster_id,
world,
datapacks.into_iter().map(|(path, _)| path).collect(),
),
);
}
if skipped > 0 {
dispatch
.notify("Some files weren't added")
.body(format!(
"{destination_name} has no mod loader, so {skipped} mod or shader file{} {} skipped.",
if skipped == 1 { "" } else { "s" },
if skipped == 1 { "was" } else { "were" },
))
.send();
}
pending.set(Vec::new());
};
@ -347,7 +495,11 @@ fn prompt_body(
"Pick a cluster, then check what each one gets installed as.".to_string(),
))
.child(cluster_field(clusters, cluster_idx, selected_cluster))
.child(file_field(&resolved, overrides))
.maybe_child(
has_datapacks
.then(|| world_field(worlds, world.as_deref(), cluster_id, remembered_world)),
)
.child(file_field(&resolved, &rejected, mod_loader, overrides))
.child(
rect()
.horizontal()
@ -364,6 +516,7 @@ fn prompt_body(
.child(
Button::new()
.primary()
.enabled(can_import)
.on_press(import)
.child(Icon::new(IconType::Plus).size(15.))
.text(match files.len() {
@ -415,14 +568,55 @@ fn cluster_field(
)
}
fn world_field(
worlds: &[String],
current: Option<&str>,
cluster_id: ClusterId,
mut remembered: State<HashMap<ClusterId, String>>,
) -> Element {
let Some(current) = current else {
return field(
"Destination world",
label()
.text("This cluster has no worlds yet. Create one in game, then drop the data packs again.")
.font_size(12.)
.color(colors::fg_secondary()),
)
.into_element();
};
let names = worlds.to_vec();
field(
"Destination world",
Dropdown::new(current.to_string(), worlds.to_vec())
.width(Size::fill())
.height(Size::px(32.))
.on_select(move |idx: usize| {
if let Some(name) = names.get(idx) {
remembered.write().insert(cluster_id, name.clone());
}
}),
)
.into_element()
}
fn file_field(
resolved: &[(PathBuf, ContentType)],
rejected: &[PathBuf],
mod_loader: bool,
overrides: State<HashMap<PathBuf, ContentType>>,
) -> impl IntoElement {
let visible = resolved.len().clamp(1, FILE_LIST_MAX_ROWS);
let visible = (resolved.len() + rejected.len()).clamp(1, FILE_LIST_MAX_ROWS);
let rows = resolved
.iter()
.map(|(path, content_type)| file_row(path, *content_type, overrides).into_element())
.map(|(path, content_type)| {
file_row(path, Some(*content_type), mod_loader, overrides).into_element()
})
.chain(
rejected
.iter()
.map(|path| file_row(path, None, mod_loader, overrides).into_element()),
)
.collect::<Vec<_>>();
field(
@ -444,14 +638,33 @@ fn file_field(
fn file_row(
path: &Path,
content_type: ContentType,
content_type: Option<ContentType>,
mod_loader: bool,
mut overrides: State<HashMap<PathBuf, ContentType>>,
) -> impl IntoElement {
let key = path.to_path_buf();
let options: Vec<String> = IMPORTABLE
let types = importable(mod_loader);
let options: Vec<String> = types
.iter()
.map(|ct| content_label(*ct).to_string())
.collect();
let control = match content_type {
Some(content_type) => Dropdown::new(content_label(content_type), options)
.width(Size::px(TYPE_DROPDOWN_W))
.height(Size::px(24.))
.on_select(move |idx: usize| {
if let Some(content_type) = types.get(idx) {
overrides.write().insert(key.clone(), *content_type);
}
})
.into_element(),
None => label()
.text("Needs a mod loader")
.font_size(12.)
.max_lines(1)
.color(colors::fg_secondary())
.into_element(),
};
rect()
.horizontal()
@ -474,14 +687,5 @@ fn file_row(
.width(Size::flex(1.0))
.color(colors::fg_primary()),
)
.child(
Dropdown::new(content_label(content_type), options)
.width(Size::px(TYPE_DROPDOWN_W))
.height(Size::px(24.))
.on_select(move |idx: usize| {
if let Some(content_type) = IMPORTABLE.get(idx) {
overrides.write().insert(key.clone(), *content_type);
}
}),
)
.child(control)
}

View file

@ -0,0 +1,245 @@
use freya::animation::{AnimNum, Ease, OnCreation, use_animation};
use freya::prelude::*;
use crate::components::{Button, Icon, IconType};
use crate::hooks::use_overlay_claim;
use crate::theme::colors;
const PANEL_W: f32 = 172.;
const BUTTON_W: f32 = 34.;
#[derive(Clone, PartialEq)]
pub struct FilterOption {
text: String,
selected: bool,
on_press: EventHandler<()>,
}
impl FilterOption {
pub fn new(
text: impl Into<String>,
selected: bool,
on_press: impl Into<EventHandler<()>>,
) -> Self {
Self {
text: text.into(),
selected,
on_press: on_press.into(),
}
}
}
#[derive(Clone, PartialEq)]
struct FilterSection {
title: &'static str,
options: Vec<FilterOption>,
}
#[derive(PartialEq)]
pub struct FilterMenu {
active: bool,
sections: Vec<FilterSection>,
}
impl FilterMenu {
pub fn new(active: bool) -> Self {
Self {
active,
sections: Vec::new(),
}
}
pub fn section(
mut self,
title: &'static str,
options: impl IntoIterator<Item = FilterOption>,
) -> Self {
self.sections.push(FilterSection {
title,
options: options.into_iter().collect(),
});
self
}
}
impl Component for FilterMenu {
fn render(&self) -> impl IntoElement {
let mut open = use_state(|| false);
let icon_color = if self.active {
colors::brand()
} else {
colors::fg_secondary()
};
rect()
.width(Size::px(BUTTON_W))
.child(
Button::new()
.secondary()
.icon()
.tooltip("Sort and filter")
.width(Size::px(BUTTON_W))
.height(Size::px(34.))
.on_press(move |e: Event<PressEventData>| {
e.stop_propagation();
open.toggle();
})
.child(Icon::new(IconType::Sliders04).size(16.).color(icon_color)),
)
.maybe_child(open().then(|| {
FilterPanel {
sections: self.sections.clone(),
on_close: (move |()| open.set(false)).into(),
}
.into_element()
}))
}
}
#[derive(PartialEq)]
struct FilterPanel {
sections: Vec<FilterSection>,
on_close: EventHandler<()>,
}
impl Component for FilterPanel {
fn render(&self) -> impl IntoElement {
use_overlay_claim();
let backdrop_close = self.on_close.clone();
let key_close = self.on_close.clone();
let a11y_id = use_a11y();
let fade = use_animation(|conf| {
conf.on_creation(OnCreation::Run);
AnimNum::new(0., 1.).time(160).ease(Ease::Out)
});
let progress = fade.read().value();
let mut panel = rect()
.vertical()
.width(Size::fill())
.spacing(4.)
.padding(Gaps::new_all(8.))
.opacity(progress)
.offset_y((progress - 1.0) * 6.)
.corner_radius(CornerRadius::new_all(10.))
.background(colors::page_elevated().with_a(230))
.backdrop_blur(12.)
.border(crate::ui::border_all_color(1., colors::component_border()))
.shadow(Shadow::from((
0.,
8.,
32.,
0.,
Color::from_argb(120, 0, 0, 0),
)))
.a11y_id(a11y_id)
.a11y_role(AccessibilityRole::Dialog)
.on_global_key_down(move |e: Event<KeyboardEventData>| {
if e.key == Key::Named(NamedKey::Escape) {
key_close.call(());
}
});
for section in &self.sections {
panel = panel.child(section_label(section.title));
for option in &section.options {
panel = panel.child(ChoiceRow {
option: option.clone(),
});
}
}
rect()
.height(Size::px(0.))
.width(Size::px(PANEL_W))
.layer(Layer::Overlay)
.child(
rect()
.layer(Layer::OverlayLevel(10))
.position(Position::new_global().top(0.).left(0.))
.width(Size::window_percent(100.))
.height(Size::window_percent(100.))
.on_press(move |_| backdrop_close.call(())),
)
.child(
rect()
.width(Size::fill())
.layer(Layer::OverlayLevel(12))
.margin(Gaps::new(6., 0., 0., -(PANEL_W - BUTTON_W)))
.child(panel),
)
}
}
fn section_label(text: &'static str) -> impl IntoElement {
label()
.text(text)
.font_size(10.)
.font_weight(FontWeight::SEMI_BOLD)
.color(colors::fg_secondary())
}
#[derive(PartialEq)]
struct ChoiceRow {
option: FilterOption,
}
impl Component for ChoiceRow {
fn render(&self) -> impl IntoElement {
let selected = self.option.selected;
let on_press = self.option.on_press.clone();
let a11y_id = use_a11y();
let focus = use_focus(a11y_id);
let focused = focus().is_focused();
let color = if selected {
colors::fg_primary()
} else {
colors::fg_secondary()
};
let bg = if selected {
colors::component_bg()
} else if focused {
colors::ghost_overlay_hover()
} else {
Color::TRANSPARENT
};
rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(8.)
.padding(Gaps::new_symmetric(5., 8.))
.corner_radius(CornerRadius::new_all(6.))
.background(bg)
.content(Content::Flex)
.a11y_id(a11y_id)
.a11y_focusable(true)
.a11y_role(AccessibilityRole::MenuItemRadio)
.maybe(focused, |el| {
el.border(crate::ui::border_all_color(1., colors::brand()))
})
.cursor(CursorIcon::Pointer)
.on_press(move |_| on_press.call(()))
.child(
label()
.text(self.option.text.clone())
.font_size(12.)
.width(Size::flex(1.0))
.color(color),
)
.maybe_child(selected.then(|| {
Icon::new(IconType::Check)
.size(14.)
.color(colors::brand())
.into_element()
}))
}
}

View file

@ -0,0 +1,282 @@
use freya::prelude::*;
use freya::router::RouterContext;
use oneclient_content::packages::ResolvedAlternative;
use crate::components::{
Button, Icon, IconType, Markdown, MarkdownStyle, OverlayPopup, remote_icon,
};
use crate::hooks::{use_cached_image, use_dispatch, use_installs_snapshot};
use crate::routes::Route;
use crate::state::FlaggedInstallPrompt;
use crate::theme::colors;
use crate::ui::border_all_color;
use crate::view::app::browser::encode_package_id;
const CARD_BG: Color = Color::from_rgb(26, 34, 41);
const DIALOG_W: f32 = 460.;
const ROW_H: f32 = 48.;
const ICON_SIZE: f32 = 32.;
const ROW_GAP: f32 = 6.;
const SECTION_GAP: f32 = 14.;
const CONTENT_MAX_H: f32 = 400.;
const DIALOG_PADDING: f32 = 20.;
#[derive(PartialEq)]
pub struct FlaggedInstallPopup;
impl Component for FlaggedInstallPopup {
fn render(&self) -> impl IntoElement {
let installs = use_installs_snapshot();
let dispatch = use_dispatch();
let Some(prompt) = installs.flagged.clone() else {
return rect().into_element();
};
let close = dispatch.clone();
OverlayPopup::new()
.on_close(move |_| close.dismiss_flagged_install())
.child(
rect()
.width(Size::window_percent(100.))
.height(Size::window_percent(100.))
.center()
.child(dialog(prompt, dispatch)),
)
.into_element()
}
}
fn dialog(prompt: FlaggedInstallPrompt, dispatch: crate::Actions) -> impl IntoElement {
let description = if prompt.alternatives.is_empty() {
format!(
"{} is flagged as a problematic mod. Installing it is not recommended.",
prompt.name
)
} else {
format!(
"{} is flagged as a problematic mod. Installing it is not recommended, consider one of these alternatives instead.",
prompt.name
)
};
let mut body = rect()
.vertical()
.width(Size::px(DIALOG_W))
.height(Size::Inner)
.max_width(Size::window_percent(90.))
.max_height(Size::window_percent(85.))
.overflow(Overflow::Clip)
.spacing(SECTION_GAP)
.padding(Gaps::new_all(DIALOG_PADDING))
.corner_radius(CornerRadius::new_all(14.))
.background(CARD_BG)
.border(border_all_color(1., colors::component_border()))
.child(
rect()
.vertical()
.width(Size::fill())
.spacing(SECTION_GAP)
.child(
rect()
.horizontal()
.cross_align(Alignment::Center)
.spacing(10.)
.child(
Icon::new(IconType::AlertTriangle)
.size(20.)
.color(colors::code_warn()),
)
.child(
label()
.text("Flagged mod")
.font_size(16.)
.font_weight(FontWeight::SEMI_BOLD)
.color(colors::fg_primary()),
),
)
.child(
label()
.text(description)
.font_size(12.)
.width(Size::fill())
.color(colors::fg_secondary()),
),
);
if prompt.explanation.is_some() || !prompt.alternatives.is_empty() {
body = body.child(flagged_content(&prompt));
}
let cancel = dispatch.clone();
let anyway = dispatch;
body.child(
rect()
.horizontal()
.width(Size::fill())
.main_align(Alignment::End)
.spacing(8.)
.child(
Button::new()
.secondary()
.on_press(move |_| cancel.dismiss_flagged_install())
.text("Cancel"),
)
.child(
Button::new()
.danger()
.on_press(move |_| anyway.install_flagged_anyway(prompt.clone()))
.text("Install anyway"),
),
)
}
fn flagged_content(prompt: &FlaggedInstallPrompt) -> impl IntoElement {
let mut content = rect().vertical().width(Size::fill()).spacing(SECTION_GAP);
if let Some(markdown) = &prompt.explanation {
content = content.child(explanation_panel(markdown.clone()));
}
if !prompt.alternatives.is_empty() {
content = content.child(
rect()
.vertical()
.width(Size::fill())
.spacing(ROW_GAP)
.children(prompt.alternatives.iter().map(|alternative| {
AlternativeRow {
alternative: alternative.clone(),
cluster_id: prompt.cluster_id,
mc_version: prompt.mc_version.clone(),
}
.into_element()
})),
);
}
ScrollView::new()
.width(Size::fill())
.height(Size::Inner)
.max_height(Size::px(CONTENT_MAX_H))
.child(content)
}
#[derive(PartialEq)]
struct AlternativeRow {
alternative: ResolvedAlternative,
cluster_id: i64,
mc_version: String,
}
impl Component for AlternativeRow {
fn render(&self) -> impl IntoElement {
let dispatch = use_dispatch();
let icon_query = use_cached_image(self.alternative.icon_url.clone(), 256);
let icon = remote_icon(self.alternative.icon_url.as_deref(), &icon_query, ICON_SIZE);
let version = self
.alternative
.version_number
.clone()
.unwrap_or_else(|| format!("No version for {}", self.mc_version));
alternative_row(
self.alternative.clone(),
version,
self.cluster_id,
icon,
dispatch,
)
}
}
fn alternative_row(
alternative: ResolvedAlternative,
version: String,
cluster_id: i64,
icon: Element,
dispatch: crate::Actions,
) -> impl IntoElement {
let ResolvedAlternative {
provider,
project_id,
name,
..
} = alternative;
rect()
.horizontal()
.content(Content::Flex)
.width(Size::fill())
.height(Size::px(ROW_H))
.cross_align(Alignment::Center)
.spacing(10.)
.padding(Gaps::new_symmetric(0., 8.))
.corner_radius(CornerRadius::new_all(8.))
.background(colors::component_bg())
.border(border_all_color(1., colors::component_border()))
.child(icon)
.child(
rect()
.vertical()
.width(Size::flex(1.))
.spacing(2.)
.child(
label()
.text(name)
.font_size(13.)
.font_weight(FontWeight::SEMI_BOLD)
.max_lines(1)
.color(colors::fg_primary()),
)
.child(
label()
.text(version)
.font_size(11.)
.max_lines(1)
.color(colors::fg_secondary()),
),
)
.child(
Button::new()
.primary()
.small()
.on_press(move |_| {
dispatch.dismiss_flagged_install();
let _ = RouterContext::get().push(Route::BrowserPackage {
cluster_id,
package_type: "mod".to_string(),
package_id: encode_package_id(provider, &project_id),
});
})
.child(Icon::new(IconType::ArrowRight).size(14.))
.text("Go to page"),
)
}
fn explanation_panel(markdown: String) -> impl IntoElement {
rect()
.width(Size::fill())
.padding(Gaps::new_all(12.))
.corner_radius(CornerRadius::new_all(8.))
.background(colors::component_bg())
.border(border_all_color(1., colors::component_border()))
.child(
Markdown::new(markdown)
.width(Size::fill())
.style(MarkdownStyle {
color: colors::fg_primary(),
color_link: colors::code_info(),
color_code: colors::fg_primary(),
background_code: CARD_BG,
background_blockquote: CARD_BG,
border_blockquote: colors::component_border(),
background_divider: colors::component_border(),
headings: [16., 14., 13., 12., 12., 12.],
paragraph_size: 12.,
code_font_size: 11.,
..MarkdownStyle::default()
}),
)
}

View file

@ -79,9 +79,7 @@ impl Component for GenericPromptOverlay {
.pick_folder()
.await
{
dispatch.answer_prompt(
Answer::new(id).with_folder(handle.path()),
);
dispatch.answer_prompt(Answer::new(id).with_folder(handle.path()));
}
});
}

View file

@ -1,27 +0,0 @@
use freya::prelude::*;
use super::Dropdown;
pub const GRID_COLUMNS_MIN: u8 = 1;
pub const GRID_COLUMNS_MAX: u8 = 5;
pub fn resolved_columns(columns: u8) -> usize {
columns.clamp(GRID_COLUMNS_MIN, GRID_COLUMNS_MAX) as usize
}
pub fn grid_columns_picker(mut columns: State<u8>, height: f32) -> impl IntoElement {
let options: Vec<String> = (GRID_COLUMNS_MIN..=GRID_COLUMNS_MAX)
.map(|n| n.to_string())
.collect();
let selected = resolved_columns(*columns.read()).to_string();
Dropdown::new(selected, options)
.width(Size::px(60.))
.height(Size::px(height))
.on_select(move |idx: usize| columns.set(GRID_COLUMNS_MIN + idx as u8))
}
pub fn cell_width(total: f32, cols: usize, gap: f32) -> f32 {
let cols = cols.max(1);
((total - (cols - 1) as f32 * gap) / cols as f32).max(0.)
}

View file

@ -51,7 +51,11 @@ impl Icon {
impl Component for Icon {
fn render(&self) -> impl IntoElement {
let path = self.icon.path();
let bytes = use_memo(move || AppAssets::get_bytes(path).unwrap_or_default());
let load = move || (path, AppAssets::get_bytes(path).unwrap_or_default());
let mut bytes = use_state(load);
if bytes.peek().0 != path {
bytes.set(load());
}
let tint = use_hook(try_consume_context::<IconTint>);
let color = self
@ -59,7 +63,7 @@ impl Component for Icon {
.or_else(|| tint.map(|tint| *tint.0.read()))
.unwrap_or_else(colors::fg_primary);
SvgViewer::new((path, bytes.read().cloned()))
SvgViewer::new((path, bytes.peek().1.clone()))
.show_loader(false)
.width(Size::px(self.size_px))
.height(Size::px(self.size_px))
@ -87,6 +91,7 @@ pub enum IconType {
ArrowLeft,
ArrowRight,
Brush01,
BarChartSquare02,
Bell01,
Calendar,
CheckCircle,
@ -102,6 +107,7 @@ pub enum IconType {
Copy01,
Curseforge,
Database01,
Discord,
DotsGrid,
DotsVertical,
Download01,
@ -113,18 +119,23 @@ pub enum IconType {
Folder,
FolderCheck,
FolderDownload,
Github,
Globe01,
HelpCircle,
IconLogo,
InfoCircle,
Key01,
LayoutTop,
LineChartUp01,
Link03,
LinkExternal01,
Loading02,
LogOut01,
Maximize01,
MessageTextSquare01,
Minus,
Modrinth,
Moon01,
OnboardingAccount,
OnboardingComplete,
OnboardingLanguage,

View file

@ -0,0 +1,155 @@
use freya::prelude::*;
use oneclient_core::clusters::Cluster;
use crate::components::{ART_PREVIEW_EDGE, DynamicArt};
use crate::theme::colors;
use crate::ui::{border_all_color, last_played_label};
use crate::utils::{GridSelection, ReleaseLine, line_art_key, line_title};
const ROW_HEIGHT_PX: f32 = 56.;
const THUMB_PX: f32 = 40.;
pub struct InstanceRow {
key: GridSelection,
title: String,
subtitle: String,
played: String,
art: DynamicArt,
selected: bool,
on_press: EventHandler<Event<PressEventData>>,
}
impl InstanceRow {
pub fn new(
cluster: &Cluster,
selected: bool,
on_press: impl Into<EventHandler<Event<PressEventData>>>,
) -> Self {
Self {
key: GridSelection::Instance(cluster.id),
title: cluster.name.clone(),
subtitle: format!("{} \u{b7} {}", cluster.mc_version, cluster.mc_loader),
played: last_played_label(cluster.last_played),
art: DynamicArt::for_cluster(cluster).max_edge(ART_PREVIEW_EDGE),
selected,
on_press: on_press.into(),
}
}
pub fn for_line(
line: ReleaseLine,
clusters: &[Cluster],
caption: String,
selected: bool,
on_press: impl Into<EventHandler<Event<PressEventData>>>,
) -> Self {
Self {
key: GridSelection::Line(line),
title: line_title(line, clusters),
subtitle: caption,
played: last_played_label(clusters.iter().filter_map(|c| c.last_played).max()),
art: DynamicArt::for_version(line.major, line_art_key(line, clusters), None)
.max_edge(ART_PREVIEW_EDGE),
selected,
on_press: on_press.into(),
}
}
}
impl PartialEq for InstanceRow {
fn eq(&self, other: &Self) -> bool {
self.key == other.key
&& self.title == other.title
&& self.subtitle == other.subtitle
&& self.played == other.played
&& self.art == other.art
&& self.selected == other.selected
}
}
impl Component for InstanceRow {
fn render(&self) -> impl IntoElement {
let mut hovering = use_state(|| false);
let a11y_id = use_a11y();
let focus = use_focus(a11y_id);
let selected = self.selected;
let hovered = *hovering.read();
let focused = focus().is_focused();
let on_press = self.on_press.clone();
let background = if selected {
colors::brand().with_a(36)
} else if hovered {
colors::component_bg_hover()
} else {
colors::component_bg()
};
let border_color = if selected || focused {
colors::brand()
} else if hovered {
colors::component_border_hover()
} else {
colors::component_border()
};
rect()
.key(self.key)
.horizontal()
.width(Size::fill())
.height(Size::px(ROW_HEIGHT_PX))
.padding(Gaps::new(0., 14., 0., 8.))
.spacing(14.)
.cross_align(Alignment::Center)
.content(Content::Flex)
.corner_radius(CornerRadius::new_all(12.))
.background(background)
.border(border_all_color(1., border_color).alignment(BorderAlignment::Inner))
.a11y_id(a11y_id)
.a11y_focusable(true)
.a11y_role(AccessibilityRole::Button)
.on_press(move |e| on_press.call(e))
.on_pointer_enter(move |_| hovering.set(true))
.on_pointer_leave(move |_| hovering.set(false))
.child(
rect()
.width(Size::px(THUMB_PX))
.height(Size::px(THUMB_PX))
.corner_radius(CornerRadius::new_all(8.))
.overflow(Overflow::Clip)
.child(self.art.clone()),
)
.child(
rect()
.vertical()
.width(Size::flex(1.0))
.spacing(3.)
.child(
label()
.text(self.title.clone())
.font_size(14.)
.font_weight(FontWeight::MEDIUM)
.max_lines(1)
.text_overflow(TextOverflow::Ellipsis)
.color(colors::fg_primary()),
)
.child(
label()
.text(self.subtitle.clone())
.font_size(12.)
.max_lines(1)
.text_overflow(TextOverflow::Ellipsis)
.color(colors::fg_secondary()),
),
)
.child(
label()
.text(self.played.clone())
.font_size(12.)
.max_lines(1)
.color(colors::fg_secondary()),
)
}
}

View file

@ -1,6 +1,6 @@
use freya::prelude::*;
use oneclient_java::{JAVA_CHOICE_DOWNLOAD, JAVA_CHOICE_FOLDER, JavaVendor};
use oneclient_events::Answer;
use oneclient_java::{JAVA_CHOICE_DOWNLOAD, JAVA_CHOICE_FOLDER, JavaVendor};
use crate::components::{Button, Icon, IconType, JavaInstallManager, OverlayPopup};
use crate::hooks::{use_dispatch, use_notifications_snapshot};
@ -65,9 +65,8 @@ impl Component for JavaPromptOverlay {
.pick_folder()
.await
{
dispatch.answer_prompt(
Answer::new(JAVA_CHOICE_FOLDER).with_folder(handle.path()),
);
dispatch
.answer_prompt(Answer::new(JAVA_CHOICE_FOLDER).with_folder(handle.path()));
}
});
};
@ -124,9 +123,7 @@ impl Component for JavaPromptOverlay {
.child(
Button::new()
.secondary()
.on_press(move |_| {
cancel.dismiss_prompt()
})
.on_press(move |_| cancel.dismiss_prompt())
.text("Cancel"),
)
.child(

View file

@ -1,7 +1,9 @@
use freya::prelude::*;
use crate::components::{Button, Icon, IconType, OverlayPopup};
use crate::hooks::use_link_confirm;
use crate::components::{
Button, CodeBlock, Icon, IconType, OVERLAY_BASE_LEVEL, OverlayPopup, copy_button,
};
use crate::hooks::{use_dispatch, use_link_confirm};
use crate::theme::colors;
use crate::ui::border_all_color;
@ -13,13 +15,26 @@ pub struct ConfirmLinkOverlay;
impl Component for ConfirmLinkOverlay {
fn render(&self) -> impl IntoElement {
let mut pending = use_link_confirm();
let dispatch = use_dispatch();
let Some(url) = pending.read().clone() else {
return rect().into_element();
};
let open_url = url.clone();
let (title, body) = if is_first_party(&url) {
(
"Leave the launcher?",
"This will open the page in your web browser.",
)
} else {
(
"Open external link?",
"This link was provided by a third party. Only open it if you trust the source.",
)
};
OverlayPopup::new()
.overlay_level(OVERLAY_BASE_LEVEL + 3)
.on_close(move |_| pending.set(None))
.child(
rect()
@ -48,7 +63,7 @@ impl Component for ConfirmLinkOverlay {
)
.child(
label()
.text("Open external link?")
.text(title)
.font_size(16.)
.font_weight(FontWeight::SEMI_BOLD)
.color(colors::fg_primary()),
@ -56,25 +71,14 @@ impl Component for ConfirmLinkOverlay {
)
.child(
label()
.text("This link was provided by a third party. Only open it if you trust the source.")
.text(body)
.font_size(12.)
.color(colors::fg_secondary()),
)
.child(
rect()
.width(Size::fill())
.padding(Gaps::new_all(10.))
.corner_radius(CornerRadius::new_all(8.))
.background(colors::component_bg())
.border(border_all_color(1., colors::component_border()))
.child(
label()
.text(url.clone())
.font_size(12.)
.max_lines(4)
.width(Size::fill())
.color(colors::code_info()),
),
CodeBlock::new(url.clone())
.color(colors::code_info())
.max_lines(4),
)
.child(
rect()
@ -82,6 +86,11 @@ impl Component for ConfirmLinkOverlay {
.width(Size::fill())
.main_align(Alignment::End)
.spacing(8.)
.child(copy_button(
url.clone(),
"Link copied to your clipboard.",
dispatch,
))
.child(
Button::new()
.secondary()
@ -104,3 +113,13 @@ impl Component for ConfirmLinkOverlay {
.into_element()
}
}
fn is_first_party(url: &str) -> bool {
let host = url
.split_once("://")
.map_or(url, |(_, rest)| rest)
.split(['/', '?', '#', ':'])
.next()
.unwrap_or_default();
host == "polyfrost.org" || host.ends_with(".polyfrost.org")
}

View file

@ -17,6 +17,7 @@ pub struct LocalImage {
max_edge: u32,
cover: bool,
skeleton: bool,
picked: bool,
}
impl LocalImage {
@ -26,9 +27,15 @@ impl LocalImage {
max_edge,
cover,
skeleton: false,
picked: false,
}
}
pub fn picked(mut self, picked: bool) -> Self {
self.picked = picked;
self
}
pub fn skeleton(mut self, skeleton: bool) -> Self {
self.skeleton = skeleton;
self
@ -37,9 +44,9 @@ impl LocalImage {
impl Component for LocalImage {
fn render(&self) -> impl IntoElement {
let query = use_local_image(self.path.clone(), self.max_edge);
let bytes: Option<Bytes> = settled_or_loading(&query);
let query = use_local_image(self.path.clone(), self.max_edge, self.picked);
let bytes: Option<Bytes> =
settled_or_loading(&query).filter(|bytes: &Bytes| !bytes.is_empty());
let mut cache = use_state(|| None::<(usize, ImageHandle)>);
let holder = bytes.and_then(|bytes| {

View file

@ -455,7 +455,7 @@ fn scroll_body(args: BodyArgs) -> Element {
.height(Size::px(content_h))
.on_pointer_down(on_body_down)
.on_capture_global_pointer_move(on_global_move)
.on_capture_global_pointer_press(on_global_release)
.on_capture_global_pointer_up(on_global_release)
.on_global_key_down(on_key_down)
.child(log_body(
&lines[..],

View file

@ -3,12 +3,13 @@ use std::borrow::Cow;
use std::rc::Rc;
use freya::elements::image::ImageHandle;
use freya::engine::prelude::{ClipOp, Paint, SkRect};
use freya::engine::prelude::{ClipOp, FontMgr, Paint, Picture, PictureRecorder, SkRect, svg};
use freya::prelude::*;
use freya_core::element::{ClipContext, ElementExt, LayoutContext};
use freya_core::tree::DiffModifies;
use super::super::local_image::decode;
use super::svg_compat::prepare_svg;
use crate::hooks::{loaded_image, use_cached_image};
use crate::theme::colors;
@ -34,24 +35,24 @@ impl Component for MarkdownImage {
let query = use_cached_image(Some(self.url.clone()), MAX_EDGE);
let loaded = loaded_image(Some(&self.url), &query);
let mut cache = use_state(|| None::<(usize, ImageHandle)>);
let holder = loaded.and_then(|(_, bytes)| {
let mut cache = use_state(|| None::<(usize, ImageContent)>);
let content = loaded.and_then(|(_, bytes)| {
let ptr = bytes.as_ptr() as usize;
if let Some((cached_ptr, holder)) = cache.read().clone()
if let Some((cached_ptr, content)) = cache.read().clone()
&& cached_ptr == ptr
{
return Some(holder);
return Some(content);
}
let holder = decode(&bytes)?;
cache.set(Some((ptr, holder.clone())));
let content = decode_any(&bytes)?;
cache.set(Some((ptr, content.clone())));
Some(holder)
Some(content)
});
match holder {
Some(holder) => scaled_image(holder)
match content {
Some(content) => scaled_content(content)
.a11y_role(AccessibilityRole::Image)
.a11y_alt(self.alt.clone())
.into_element(),
@ -64,6 +65,90 @@ impl Component for MarkdownImage {
}
}
#[derive(Clone, PartialEq)]
enum ImageContent {
Bitmap(ImageHandle),
Vector(SvgPicture),
}
/// An svg recorded as drawing commands, so it is drawn straight onto the
/// window's pixel grid instead of being resampled from a bitmap
#[derive(Clone)]
struct SvgPicture {
picture: Picture,
/// Logical size, scaled to physical pixels at layout like any `Size::px`
size: Size2D,
}
impl PartialEq for SvgPicture {
fn eq(&self, other: &Self) -> bool {
self.picture.unique_id() == other.picture.unique_id() && self.size == other.size
}
}
fn decode_any(bytes: &Bytes) -> Option<ImageContent> {
if let Some(handle) = decode(bytes) {
return Some(ImageContent::Bitmap(handle));
}
// Skia's codecs can't read svg, which is what shields.io badges are
if !looks_like_svg(bytes) {
return None;
}
record_svg(bytes).map(ImageContent::Vector)
}
fn looks_like_svg(bytes: &[u8]) -> bool {
let head = &bytes[..bytes.len().min(1024)];
let head = head.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(head);
let start = head
.iter()
.position(|b| !b.is_ascii_whitespace())
.unwrap_or(head.len());
let head = &head[start..];
head.starts_with(b"<svg")
|| (head.starts_with(b"<?xml")
|| head.starts_with(b"<!--")
|| head.starts_with(b"<!DOCTYPE"))
&& head.windows(4).any(|w| w == b"<svg")
}
fn svg_size(root: &svg::Svg) -> Option<Size2D> {
let intrinsic = root.intrinsic_size();
let size = if intrinsic.width > 0. && intrinsic.height > 0. {
Size2D::new(intrinsic.width, intrinsic.height)
} else {
let view_box = root.view_box()?;
Size2D::new(view_box.width(), view_box.height())
};
let valid = size.width.is_finite() && size.height.is_finite();
(valid && size.width > 0. && size.height > 0.).then_some(size)
}
/// Badges are mostly text, so the dom gets the system fonts rather than an empty manager
fn record_svg(bytes: &[u8]) -> Option<SvgPicture> {
let mut dom = svg::Dom::from_bytes(&prepare_svg(bytes), FontMgr::default()).ok()?;
let declared = svg_size(&dom.root())?;
dom.set_container_size((declared.width, declared.height));
// the dom always draws at its declared size, so an oversized svg is scaled down while recording
let shrink = (MAX_EDGE as f32 / declared.width.max(declared.height)).min(1.);
let size = declared * shrink;
let bounds = SkRect::from_wh(size.width, size.height);
let mut recorder = PictureRecorder::new();
let canvas = recorder.begin_recording(bounds, false);
canvas.clip_rect(bounds, None, None);
canvas.scale((shrink, shrink));
dom.render(canvas);
let picture = recorder.finish_recording_as_picture(Some(&bounds))?;
Some(SvgPicture { picture, size })
}
#[derive(Clone)]
pub struct ScaledImage {
key: DiffKey,
@ -74,18 +159,18 @@ pub struct ScaledImage {
pub struct ScaledImageElement {
accessibility: AccessibilityData,
layout: LayoutData,
image_handle: ImageHandle,
content: ImageContent,
sampling_mode: SamplingMode,
corner_radius: CornerRadius,
}
pub fn scaled_image(image_handle: ImageHandle) -> ScaledImage {
fn scaled_content(content: ImageContent) -> ScaledImage {
ScaledImage {
key: DiffKey::None,
element: ScaledImageElement {
accessibility: AccessibilityData::default(),
layout: LayoutData::default(),
image_handle,
content,
sampling_mode: SamplingMode::default(),
corner_radius: CornerRadius::default(),
},
@ -134,6 +219,18 @@ impl From<ScaledImage> for Element {
}
}
impl ImageContent {
/// Size in layout (physical) pixels; bitmaps keep their pixel dimensions
fn size(&self, scale_factor: f32) -> Size2D {
match self {
Self::Bitmap(handle) => {
Size2D::new(handle.image.width() as f32, handle.image.height() as f32)
}
Self::Vector(svg) => svg.size * scale_factor,
}
}
}
impl ElementExt for ScaledImageElement {
fn changed(&self, other: &Rc<dyn ElementExt>) -> bool {
let Some(other) = (other.as_ref() as &dyn Any).downcast_ref::<ScaledImageElement>() else {
@ -157,10 +254,10 @@ impl ElementExt for ScaledImageElement {
diff.insert(DiffModifies::LAYOUT);
}
if self.image_handle != other.image_handle {
if self.content != other.content {
diff.insert(DiffModifies::STYLE);
if self.image_handle.image.dimensions() != other.image_handle.image.dimensions() {
if self.content.size(1.) != other.content.size(1.) {
diff.insert(DiffModifies::LAYOUT);
}
}
@ -192,11 +289,9 @@ impl ElementExt for ScaledImageElement {
}
fn measure(&self, context: LayoutContext) -> Option<(Size2D, Rc<dyn Any>)> {
let image = &self.image_handle.image;
let natural = Size2D::new(image.width() as f32, image.height() as f32);
let natural = self.content.size(context.scale_factor as f32);
let available =
(*context.area_size - context.torin_node.margin.into()).max(Size2D::zero());
let available = (*context.area_size - context.torin_node.margin.into()).max(Size2D::zero());
let ratio = (available.width / natural.width).min(1.);
let ratio = if ratio.is_finite() && ratio > 0. {
@ -239,16 +334,28 @@ impl ElementExt for ScaledImageElement {
.canvas
.clip_rrect(clip_rrect, ClipOp::Intersect, true);
let mut paint = Paint::default();
paint.set_anti_alias(true);
match &self.content {
ImageContent::Bitmap(handle) => {
let mut paint = Paint::default();
paint.set_anti_alias(true);
context.canvas.draw_image_rect_with_sampling_options(
&self.image_handle.image,
None,
rect,
self.sampling_mode.sampling_options(),
&paint,
);
context.canvas.draw_image_rect_with_sampling_options(
&handle.image,
None,
rect,
self.sampling_mode.sampling_options(),
&paint,
);
}
ImageContent::Vector(svg) => {
context.canvas.translate((rect.left, rect.top));
context.canvas.scale((
rect.width() / svg.size.width,
rect.height() / svg.size.height,
));
context.canvas.draw_picture(&svg.picture, None, None);
}
}
context.canvas.restore();
}
@ -256,8 +363,8 @@ impl ElementExt for ScaledImageElement {
#[cfg(test)]
mod tests {
use freya::prelude::Bytes;
use freya::engine::prelude::AlphaType;
use freya::prelude::Bytes;
use freya_testing::prelude::*;
use super::*;
@ -272,7 +379,9 @@ mod tests {
rect()
.width(Size::px(panel.0))
.height(Size::px(panel.1))
.child(scaled_image(handle(image.0, image.1)))
.child(scaled_content(ImageContent::Bitmap(handle(
image.0, image.1,
))))
};
let test = launch_test(app);
@ -292,7 +401,7 @@ mod tests {
rect().width(Size::px(300.)).height(Size::px(400.)).child(
paragraph()
.span(Span::new("before "))
.child(scaled_image(handle(600, 200)))
.child(scaled_content(ImageContent::Bitmap(handle(600, 200))))
.span(Span::new(" after")),
)
};
@ -325,4 +434,175 @@ mod tests {
fn short_panel_does_not_shrink_the_width() {
assert_eq!(measured((300., 60.), (600, 200)), Size2D::new(300., 100.));
}
const BADGE: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" width="88" height="20"><rect width="88" height="20" fill="#007ec6"/><text x="10" y="14" font-family="Verdana" font-size="11">v3.0.1</text></svg>"##;
#[test]
fn sniffs_svg_documents() {
assert!(looks_like_svg(BADGE.as_bytes()));
assert!(looks_like_svg(
b"\xEF\xBB\xBF <?xml version=\"1.0\"?>\n<svg width=\"1\" height=\"1\"/>"
));
assert!(!looks_like_svg(b"\x89PNG\r\n\x1a\n"));
assert!(!looks_like_svg(b"<html><body>not found</body></html>"));
}
#[test]
fn records_a_badge_at_its_declared_size() {
let svg = record_svg(BADGE.as_bytes()).expect("badge records");
assert_eq!(svg.size, Size2D::new(88., 20.));
}
#[test]
fn falls_back_to_the_view_box() {
let svg = record_svg(
br#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 10" width="100%"/>"#,
)
.expect("view box svg records");
assert_eq!(svg.size, Size2D::new(40., 10.));
}
fn vector_size(scale_factor: f64) -> Size2D {
let svg = record_svg(BADGE.as_bytes()).expect("badge records");
let app = move || {
rect()
.width(Size::px(300.))
.height(Size::px(400.))
.child(scaled_content(ImageContent::Vector(svg.clone())))
};
let (test, _) = TestingRunner::new(app, Size2D::new(500., 500.), |_| {}, scale_factor);
test.find(|node, element| {
(element as &dyn Any)
.downcast_ref::<ScaledImageElement>()
.map(|_| node.layout())
})
.expect("image node")
.area
.size
}
#[test]
fn a_vector_follows_the_scale_factor_like_text() {
assert_eq!(vector_size(1.), Size2D::new(88., 20.));
assert_eq!(vector_size(1.5), Size2D::new(132., 30.));
}
#[test]
fn a_vector_at_a_fractional_offset_is_not_resampled() {
// grey ends at 39.5, so after the 10.5 offset the colour edge lands on pixel 50
let svg = record_svg(
br##"<svg xmlns="http://www.w3.org/2000/svg" width="88" height="20"><rect width="39.5" height="20" fill="#555555"/><rect x="39.5" width="48.5" height="20" fill="#007ec6"/></svg>"##,
)
.expect("svg records");
let app = move || {
rect()
.horizontal()
.child(rect().width(Size::px(10.5)).height(Size::px(1.)))
.child(scaled_content(ImageContent::Vector(svg.clone())))
};
let mut test = launch_test(app);
let png = test.render();
let snapshot = image::load_from_memory(png.as_bytes())
.expect("snapshot decodes")
.to_rgb8();
let rgb = |x: u32| {
let [r, g, b] = snapshot.get_pixel(x, 10).0;
(r, g, b)
};
assert_eq!(rgb(49), (0x55, 0x55, 0x55));
assert_eq!(rgb(50), (0x00, 0x7e, 0xc6));
}
/// A grey shields-style badge with a 14px logo slot, rendered at the origin
fn logo_pixel(href: &str) -> (u8, u8, u8) {
let badge = format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><rect width="40" height="20" fill="#555555"/><image x="5" y="3" width="14" height="14" href="{href}"/></svg>"##
);
let svg = record_svg(badge.as_bytes()).expect("badge records");
let app = move || rect().child(scaled_content(ImageContent::Vector(svg.clone())));
let mut test = launch_test(app);
let png = test.render();
let snapshot = image::load_from_memory(png.as_bytes())
.expect("snapshot decodes")
.to_rgb8();
let [r, g, b] = snapshot.get_pixel(12, 10).0;
(r, g, b)
}
fn data_url(mime: &str, bytes: &[u8]) -> String {
use base64::Engine;
format!(
"data:{mime};base64,{}",
base64::engine::general_purpose::STANDARD.encode(bytes)
)
}
#[test]
fn draws_a_png_logo_given_by_href() {
let mut png = Vec::new();
image::RgbImage::from_pixel(4, 4, image::Rgb([255, 0, 0]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.expect("png encodes");
assert_eq!(logo_pixel(&data_url("image/png", &png)), (255, 0, 0));
}
#[test]
fn draws_an_svg_logo_given_as_a_data_url() {
let logo = br##"<svg fill="#5865F2" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M0 0h24v24H0z"/></svg>"##;
assert_eq!(
logo_pixel(&data_url("image/svg+xml", logo)),
(0x58, 0x65, 0xf2)
);
}
/// Replays a recorded svg at its own size on white and reads one pixel
fn picture_pixel(svg: &SvgPicture, x: u32, y: u32) -> (u8, u8, u8) {
use freya::engine::prelude::{EncodedImageFormat, raster_n32_premul};
let size = (svg.size.width.ceil() as i32, svg.size.height.ceil() as i32);
let mut surface = raster_n32_premul(size).expect("surface");
surface.canvas().clear(Color::WHITE);
surface.canvas().draw_picture(&svg.picture, None, None);
let png = surface
.image_snapshot()
.encode(None, EncodedImageFormat::PNG, None)
.expect("png encodes");
let [r, g, b] = image::load_from_memory(png.as_bytes())
.expect("png decodes")
.to_rgb8()
.get_pixel(x, y)
.0;
(r, g, b)
}
#[test]
fn an_oversized_svg_is_scaled_down_rather_than_cropped() {
let svg = record_svg(
br##"<svg xmlns="http://www.w3.org/2000/svg" width="3200" height="100"><rect width="1600" height="100" fill="#ff0000"/><rect x="1600" width="1600" height="100" fill="#0000ff"/></svg>"##,
)
.expect("svg records");
assert_eq!(svg.size, Size2D::new(1600., 50.));
assert_eq!(picture_pixel(&svg, 10, 40), (255, 0, 0));
assert_eq!(picture_pixel(&svg, 1590, 40), (0, 0, 255));
}
#[test]
fn a_logo_reusing_the_badges_ids_leaves_the_badge_intact() {
let logo = br##"<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><defs><clipPath id="r"><rect width="1" height="1"/></clipPath></defs><rect width="24" height="24" fill="#00ff00" clip-path="url(#r)"/></svg>"##;
let badge = format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="80" height="20"><clipPath id="r"><rect width="80" height="20" rx="3"/></clipPath><g clip-path="url(#r)"><rect width="80" height="20" fill="#555555"/></g><image x="5" y="3" width="14" height="14" href="{}"/></svg>"##,
data_url("image/svg+xml", logo)
);
let svg = record_svg(badge.as_bytes()).expect("badge records");
assert_eq!(picture_pixel(&svg, 60, 10), (0x55, 0x55, 0x55));
}
}

View file

@ -6,6 +6,7 @@ use pulldown_cmark::HeadingLevel;
mod image;
mod parse;
mod render;
mod svg_compat;
#[derive(Clone, PartialEq)]
pub struct MarkdownStyle {
@ -37,7 +38,7 @@ impl Default for MarkdownStyle {
paragraph_size: 16.,
code_font_size: 14.,
table_font_size: 14.,
code_font_family: Cow::Borrowed("Jetbrains Mono"),
code_font_family: Cow::Borrowed(crate::theme::MONO_FONT),
}
}
}
@ -115,4 +116,3 @@ impl Component for Markdown {
self.key.clone().or(self.default_key())
}
}

Some files were not shown because too many files have changed in this diff Show more