Compare commits

...

112 Commits

Author SHA1 Message Date
Calum H.
eebd3a407e fix: merge issues 2025-08-05 11:29:37 +01:00
IMB11
68a6f091fa Merge branch 'main' into cal/dependency-injection
Signed-off-by: IMB11 <hendersoncal117@gmail.com>
2025-08-05 11:27:13 +01:00
Emma Alexia
0dee21814d Change "Billing" link on dashboard for admins (#3951)
* Change "Billing" link on dashboard for admins

Requires an archon change before merging

* change order

* steal changes from prospector's old PR

supersedes #3234

Co-authored-by: Prospector <prospectordev@gmail.com>

* lint?

---------

Co-authored-by: Prospector <prospectordev@gmail.com>
2025-08-04 20:13:33 +00:00
Josiah Glosson
0657e4466f Allow direct joining servers on old instances (#4094)
* Implement direct server joining for 1.6.2 through 1.19.4

* Implement direct server joining for versions before 1.6.2

* Ignore methods with a $ in them

* Run intl:extract

* Improve code of MinecraftTransformer

* Support showing last played time for profiles before 1.7

* Reorganize QuickPlayVersion a bit to prepare for singleplayer

* Only inject quick play checking in versions where it's needed

* Optimize agent some and fix error on NeoForge

* Remove some code for quickplay singleplayer support before 1.20, as we can't reasonably support that with an agent

* Invert the default hasServerQuickPlaySupport return value

* Remove Play Anyway button

* Fix "Server couldn't be contacted" on singleplayer worlds

* Fix "Jump back in" section not working
2025-08-04 19:29:20 +00:00
Josiah Glosson
13dbb4c57e Fix most packs showing as "Optimization" on the app homepage (#4119) 2025-08-04 19:21:37 +00:00
IMB11
174d358c99 fix: lint & build 2025-08-02 13:27:21 +01:00
IMB11
04404392d6 Merge branch 'cal/dependency-injection' of https://github.com/modrinth/code into cal/dependency-injection 2025-08-02 12:40:42 +01:00
IMB11
c0f9bc0f48 refactor: move handleError to main notification manager class 2025-08-02 12:40:40 +01:00
IMB11
00bbc8976f Merge branch 'main' into cal/dependency-injection
Signed-off-by: IMB11 <hendersoncal117@gmail.com>
2025-08-02 12:34:32 +01:00
Prospector
99493b9917 Updated changelog 2025-08-01 21:31:22 -04:00
IMB11
72a52eb7b1 fix: improve error message for rate limiting (#4101)
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2025-08-01 21:27:25 +00:00
IMB11
b33e12c71d fix: startup settings not visible on hard page refresh/direct load (#4100)
* fix: startup settings not visible on hard page refresh/direct load

* refactor: const func => named
2025-08-01 21:22:22 +00:00
IMB11
82d86839c7 fix: approve status incorrect (#4104) 2025-08-01 20:24:40 +00:00
coolbot
3a20e15340 Coolbot/moderation updates aug1 (#4103)
* oop, all commas!

* Only show slug stuff when needed.

* Move status alerts to top of message, getting rid of separators.

* redist libs message altered, and now shows on plugins too

* Update versions.ts

remove unnecessary import

Signed-off-by: coolbot <76798835+coolbot100s@users.noreply.github.com>

* Tweak summary formatting msg

* Update license messages to use flink

* reorder link text to match the settings page

* add Description clarity button

---------

Signed-off-by: coolbot <76798835+coolbot100s@users.noreply.github.com>
2025-08-01 20:21:28 +00:00
jade
1c89b84314 fix(moderation): Replace dead modpack link with a valid one in side-types message (#4095) 2025-07-31 17:50:33 +00:00
IMB11
444ace8e54 fix: import and prop issue 2025-07-31 13:28:47 +01:00
IMB11
9e0892778c fix: dont use delete in computed 2025-07-31 13:25:52 +01:00
IMB11
17c08ecd44 fix: sidebar issues 2025-07-31 13:25:21 +01:00
IMB11
d7ea034963 feat: migrate app notifications to DI + frontend styling 2025-07-31 13:20:39 +01:00
IMB11
718a35737a refactor: move org context to new DI setup 2025-07-31 12:33:16 +01:00
IMB11
1602aa9556 fix: compile error + notif binding issue 2025-07-30 15:59:49 +01:00
IMB11
a11c58274c fix: issues 2025-07-30 13:23:29 +01:00
IMB11
43dcc03aed fix: lint 2025-07-30 12:50:19 +01:00
IMB11
3714375c86 feat: migrate frontend notifications to dependency injection based notificaton manager 2025-07-30 12:48:08 +01:00
IMB11
6387fb21c6 feat: Moderation Dashboard Overhaul (#4059)
* feat: Moderation Dashboard Overhaul

* fix: lint issues

* fix: issues

* fix: report layout

* fix: lint

* fix: impl quick replies

* fix: remove test qr

* feat: individual report page + use new backend

* feat: memoize filtering

* feat: apply optimizations to moderation queue

* fix: lint issues

* feat: impl quick reply functionality

* fix: top level await

* fix: dep issue

* fix: dep issue x2

* fix: dep issue

* feat: intl extract

* fix: dev-187

* fix: dev-186 & review project btn

* fix: dev-176

* remove redundant moderation button from user dropdown

* correct a msg and add admin to read filter

---------

Co-authored-by: coolbot100s <76798835+coolbot100s@users.noreply.github.com>
2025-07-29 21:19:25 +00:00
Alejandro González
c7d0839bfb fix(labrinth): retire Sendy for new email newsletter subscriptions (#4073)
* tweak(frontend): do not sign up for the newsletter by default

* fix(labrinth): retire Sendy for new email newsletter subscriptions
2025-07-29 09:51:50 +00:00
Josiah Glosson
175b90be5a Legacy ping support (#4062)
* Detection of protocol versions before 18w47b

* Refactor old_protocol_versions into protocol_version

* Ping servers closer to how a client of an instance's version would ping a server

* Allow pinging legacy servers from a modern profile in the same way a modern client would

* Ping 1.4.2 through 1.5.2 like a Vanilla client in those versions would when in such an instance
2025-07-28 14:44:34 +00:00
coolbot
13103b4950 various moderation fixes and improvements (#4061)
* Typo correction

* show optimization button when present in additional categories

* add more formatted link shortcuts

* Add info text to env info stage

* Only show gallery relevancy button when relevant.

* add unsupported project type message to versions stage

* Fix misuse of slug message.

* Update unsupported_project.md

* lint fix
2025-07-28 12:56:47 +00:00
Alejandro González
8804478221 fix(frontend): hide subscription button in blog before sub status is determined (#4072) 2025-07-27 20:29:21 +00:00
Emma Alexia
b8982a6d17 Hopefully fix collection visibility once and for all (#4070)
* Hopefully fix collection visibility once and for all

Follow up to #3408 and #3864

* Use same unlisted approach for collections as is used for projects
2025-07-27 18:23:49 +00:00
Emma Alexia
ff88724d01 Allow modification of failed charges on admin billing page (#4045)
* Allow modification of failed charges on admin billing page

Allows cancelling a failed subscription and forcing another charge attempt

* use addNotification
2025-07-27 17:30:16 +00:00
Emma Alexia
7dffb352d5 Fix duplicate "Upload icon Select file" on collections (#4069)
* Fix duplicate "Upload icon Select file" on collections

![lol](https://i.imgur.com/NKfvfQD.png)

* fix lint
2025-07-27 17:27:02 +00:00
Emma Alexia
1df6e29aa1 Ensure server status info is always passed to "My servers" page (#4071)
This took an insanely long time to debug and figure out you would not believe
2025-07-27 17:10:52 +00:00
Emma Alexia
5deb4179ad Re-enable the Moderation tab for projects that are approved (#4067)
By request of the moderation team. This would allow easier access
if, e.g., the moderators tell the author of a metadata problem they
need to correct.
2025-07-27 17:07:39 +00:00
Alejandro González
358cf31c87 feat(labrinth): basic offset pagination for moderation reports and projects (#4063) 2025-07-26 12:32:35 +00:00
Prospector
6db1d66591 else if 2025-07-24 10:38:23 -07:00
Prospector
8052fda840 Bump report limit to 1500 2025-07-24 10:37:01 -07:00
IMB11
15892a88d3 fix: handle identified files properly in the checklist (#4004)
* fix: handle identified files from the backend

* fix: allFiles not being emitted after permissions flow completed

* fix: properly handle identified projects

* fix: jade issues

* fix: import

* fix: issue with perm gen msgs

* fix: incomplete error
2025-07-23 08:34:55 +00:00
Alejandro González
32793c50e1 feat(app): better external browser Modrinth login flow (#4033)
* fix(app-frontend): do not emit exceptions when no loaders are available

* refactor(app): simplify Microsoft login code without functional changes

* feat(app): external browser auth flow for Modrinth account login

* chore: address Clippy lint

* chore(app/oauth_utils): simplify `handle_reply` error handling according to review

* chore(app-lib): simplify `Url` usage out of MC auth module
2025-07-22 22:55:18 +00:00
Alejandro González
0e0ca1971a chore(ci): switch back to upstream cache-cargo-install-action (#4047) 2025-07-22 22:43:04 +00:00
Alejandro González
bb9af18eed perf(docker): cache image builds through cache mounts and GHA cache (#4020)
* perf(docker): cache image builds through cache mounts and GHA cache

* tweak(ci/docker): switch to inline registry cache
2025-07-22 22:31:56 +00:00
Alejandro González
d4516d3527 feat(app): configurable Modrinth endpoints through .env files (#4015) 2025-07-21 22:55:57 +00:00
Josiah Glosson
87de47fe5e Use rust-lld linker on MSVC Windows (#4042)
The latest version of MSVC fails when linking labrinth, making now a perfect opportunity to switch over to the rust-lld linker instead.
2025-07-21 22:35:05 +00:00
Emma Alexia
7d76fe1b6a Add more info about last attempts to admin billing dashboard (#4029) 2025-07-21 08:35:36 +00:00
Prospector
ae25a15abd Update changelog 2025-07-19 15:17:39 -07:00
Prospector
0f755b94ce Revert "Author Validation Improvements (#3970)" (#4024)
This reverts commit 44267619b6.
2025-07-19 22:04:47 +00:00
Emma Alexia
bcf46d440b Count failed payments as "open" charges (#4013)
This allows people to cancel failed payments, currently it fails with error "There is no open charge for this subscription"
2025-07-19 14:33:37 +00:00
Josiah Glosson
526561f2de Add --color to intl:extract verification (#4023) 2025-07-19 12:42:17 +00:00
Emma Alexia
a8caa1afc3 Clarify that Modrinth Servers are for Java Edition (#4021) 2025-07-18 18:37:06 +00:00
Emma Alexia
98e9a8473d Fix NeoForge instance importing from MultiMC/Prism (#4016)
Fixes DEV-170
2025-07-18 13:00:11 +00:00
coolbot
936395484e fix: status alerts and version buttons no longer cause a failed to generate error. (#4017)
* add empty message to actions with no message, fixing broken message generation.

* fix typo in 2.2 / description message.
2025-07-18 05:32:31 +00:00
Emma Alexia
0c3e23db96 Improve errors when email is already in use (#4014)
Fixes #1485

Also fixes an issue where email_verified was being set to true regardless of whether the oauth provider provides an email (thus indicating that a null email is verified)
2025-07-18 01:59:48 +00:00
Gwenaël DENIEL
013ba4d86d Update Browse.vue (#4000)
Updated functions refreshSearch and clearSearch to reset the currentPage.value to 1

Signed-off-by: Gwenaël DENIEL <monsieur.potatoes93@gmail.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2025-07-17 07:58:24 +00:00
coolbot
93813c448c Add buttons for tec team, as well as other requested actions (#4012)
* add tec rev related buttons, identity verification button, and fix edge case appearance of links stage.

* lint fix
2025-07-17 07:49:11 +00:00
coolbot
c20b869e62 fix text in license and links stages (#4010)
* fix text in license and links stages, change a license option to conditional

* remove unused project definition

* Switch markdown to use <br />

---------

Co-authored-by: Prospector <prospectordev@gmail.com>
2025-07-17 03:05:00 +00:00
Alejandro González
56c556821b refactor(app-frontend): followup to PR #3999 (#4008) 2025-07-17 00:07:18 +00:00
IMB11
44267619b6 Author Validation Improvements (#3970)
* feat: set up typed nag (validators) system

* feat: start on frontend impl

* fix: shouldShow issues

* feat: continue work

* feat: re add submitting/re-submit nags

* feat: start work implementing validation checks using new nag system

* fix: links page + add more validations

* feat: tags validations

* fix: lint issues

* fix: lint

* fix: issues

* feat: start on i18nifying nags

* feat: impl intl

* fix: minecraft title clause update

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2025-07-16 22:28:42 +00:00
Prospector
90043fe84d Remove tumblr from footer since it's no longer in use (#4001)
* Remove tumblr from footer since it's no longer in use

* remove import

* i18n extract

---------

Co-authored-by: IMB11 <hendersoncal117@gmail.com>
2025-07-16 20:44:56 +00:00
Prospector
a6a98ff63e remove import 2025-07-16 12:35:14 -07:00
AnotherPillow
911652133b fix: report body overflowing container (#3983)
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2025-07-16 19:09:02 +00:00
IMB11
cee1b5f522 fix: use node instance url to fix staging (#4005)
* fix: use node instance url to fix staging

* fix: check if node instance exists first
2025-07-16 18:57:31 +00:00
coolbot
62f5a23fcb Moderation Checklist V1.5 (#3980)
* starting on new checklist implementation

Change default shouldShow behavior for stages.
add new messages and stages.
Change some existing stage logic.
Add placeholder var for the rules.

Co-Authored-By: @coolbot100s

* misc fixes + corrections

* Add clickable link previews to links stage

* Correct mislabeled title message and add new title messages

* Change message formatting, use rules variable, correct wip desc and title 1.8 messages, add tags buttons

* More applications of rules placeholder

* Add new status alerts stage

* change order of statusAlerts

* Update title related messages, add navigation based vars

* Overhaul Links stage and add new messages.

* Set message weights, add some disables

* message.mds now obey lint >:(

* fixed links text message formatting and changed an icon

* Combine title and slug stages

* Add more info to some stages and properly case stage ids

* tweak summary text formatting

* Improved tags stage info and more navigation placeholders

* redo reupload stage, more navigation placeholders, licensing stage improvements, versions stage improvements, status alerts stage improvements

* Allow modpack permissions stage to appear again by adding a dummy button.

* Update modpack permissions guidance

* fix: blog path issues

* fix: lint issues

* fix license stage text formatting

* Improve license stage

* feat: move links into one md file to be cleaner

* Update packages/moderation/data/stages/links.ts

Signed-off-by: IMB11 <hendersoncal117@gmail.com>

---------

Signed-off-by: IMB11 <hendersoncal117@gmail.com>
Co-authored-by: IMB11 <hendersoncal117@gmail.com>
Co-authored-by: IMB11 <calum@modrinth.com>
2025-07-16 18:48:26 +00:00
Silcean
eb595cdc3e Feature/detect skin variant on fileinput (#3999)
* chaged detection algorithm, and added skin variant deteciton on fileinput

* Update skins.ts

removed leftover logs

* removed pnpm lock changes. Simplyfied the transparency check in skin variant detection

* fully reverted lock.yaml. my bad.

---------

Co-authored-by: Bronchiopator <70262842+Bronchiopator@users.noreply.github.com>
2025-07-16 10:43:30 +00:00
Josiah Glosson
572cd065ed Allow joining offline servers from the Worlds tab (#3998)
* Allow joining offline servers from the Worlds tab

* Run intl:extract

* Fix lint
2025-07-15 23:58:04 +00:00
Prospector
76dc8a0897 Update DDoS protection on Modrinth Servers page 2025-07-15 13:51:35 -07:00
Prospector
4723de6269 Update MRS marketing and add copyright policy to footer 2025-07-15 12:36:29 -07:00
Prospector
e15fa35bad Update changelog 2025-07-15 08:15:26 -07:00
IMB11
2cc6bc8ce4 fix: unidentified files not showing in final checklist message (#3992)
* fix: unidentified files not showing in final message

* fix: remove condition
2025-07-14 15:23:15 +00:00
Nitrrine
5d19d31b2c fix(web): prevent gallery item description from overflowing (#3990)
* fix(web): prevent gallery item description from overflowing

* break overflowing text instead of hiding it

Signed-off-by: Nitrrine <43351072+Nitrrine@users.noreply.github.com>

* fix: fix

---------

Signed-off-by: Nitrrine <43351072+Nitrrine@users.noreply.github.com>
2025-07-13 23:36:43 +00:00
IMB11
c1b95ede07 fix: checklist conditional message issues + MD formatting (#3989) 2025-07-13 20:23:06 +00:00
IMB11
058185c7fd Moderation Checklist Fixes (#3986)
* fix: DEV-164

* fix: dev-163

* feat: DEV-162
2025-07-13 18:08:55 +00:00
IMB11
6fb125cf0f fix: keybind issue + view moderation page on final step (#3977)
* fix: keybind issue + view moderation page on final step

* fix: go to moderation page on generate message thing
2025-07-12 21:48:53 +00:00
Alejandro González
a945e9b005 tweak(labrinth): drop last remaining dependency on system OpenSSL (#3982)
We standarized on using `rustls` as a TLS implementation across the
monorepo, which is written in Rust and has better ergonomics,
integration with the Rust ecosystem, and consistent behavior among
platforms. However, the Labrinth Clickhouse client was the last
remaining exception to this, using the native, OS-provided TLS
implementation, which on Linux is OpenSSL and requires developers and
Docker images to install OpenSSL development packages to build Labrinth,
in addition to introducing an additional runtime dependency to Labrinth.

Let's make the process of building Labrinth slightly simpler by
switching such client to `rustls` as well, which results in finally
using the same TLS implementation for everything, a simplified build and
distribution process, less transitive dependencies, and potentially
smaller binaries (since `rustls` was already being pulled in for, e.g.,
the SMTP client).
2025-07-12 13:39:41 +00:00
Josiah Glosson
b943638afb Verify that intl:extract has been run (#3979) 2025-07-11 21:48:07 +00:00
IMB11
207dc0e2bb fix: keybind for collapse (#3971) 2025-07-11 16:41:14 +00:00
IMB11
359fbd4738 feat: moderation improvements (#3881)
* feat: rough draft of tool

* fix: example doc

* feat: multiselect chips

* feat: conditional actions+messaages + utils for handling conditions

* feat: migrate checklist v1 to new format.

* fix: lint issues

* fix: severity util

* feat: README.md

* feat: start implementing new moderation checklist

* feat: message assembly + fix imports

* fix: lint issues

* feat: add input suggestions

* feat: utility cleanup

* fix: icon

* chore: remove debug logging

* chore: remove debug button

* feat: modpack permissions flow into it's own component

* feat: icons + use id in stage selection button

* Support md/plain text in stages.

* fix: checklist not persisting/showing on subpages

* feat: message gen + appr/with/deny buttons

* feat: better notification placement + queue navigation

* fix: default props for futureProjects

* fix: modpack perms message

* fix: issue with future projects props

* fix: tab index + z index fixes

* feat: keybinds

* fix: file approval types

* fix: generate message for non-modpack projects

* feat: add generate message to stages dropdown

* fix: variables not expanding

* feat: requests

* fix: empty message approval

* fix: issues from sync

* chore: add comment for old moderation checklist impl

* fix: git artifacts

* fix: update visibility logic for stages and actions

* fix: cleanup logic for should show

* fix: markdown editor accidental edit
2025-07-11 16:09:04 +00:00
Prospector
f7700acce4 Updated changelog 2025-07-09 22:12:32 -07:00
IMB11
87a3e2d022 fix: white cape (#3959) 2025-07-09 23:31:00 +00:00
Prospector
5d17663040 Don't unnecessarily markdown-ify links pasted into markdown editor (#3958) 2025-07-09 23:11:17 +00:00
ToBinio
cff3c72f94 feat(theseus): add snapPoints for memory sliders (#1275)
* feat: add snapPoints for memory sliders

* fix lint

* Reapply changes

* Hide snap point display when disabled

* fix unused imports

---------

Co-authored-by: Prospector <prospectordev@gmail.com>
2025-07-09 22:59:59 +00:00
Erb3
fadf475f06 docs(frontend): add security.txt (#2252)
* feat: add security.txt

Security.txt is a well-known (pun intended) file among security researchers, so they don't have to go scavenging for your security information. More information is available on [securitytxt.org](https://securitytxt.org/).

I've set the following values:

- The email to contact with issues, `jai@modrinth.com`. This is the email stated in the security policy. If you wish to not include it here due to spam, you should also not have it as a `mailto` link in the security policy.
- Expiry is set to 2030. By this time Modrinth has become the biggest Minecraft mod distributor, and having expanded into other games. By this time they should also have updated this file.
- English is the preferred language
- The file is located at modrinth.com/.well-known/security.txt
- The security policy is at https://modrinth.com/legal/security

The following values have been left unset:

- PGP key, not sure where this would be located, if there is one
- Acknowledgments. Modrinth does currently not have a site for thanks
- Hiring, as it wants security-related positions
- CSAF, a Common Security Advisory Framework ?

* fix(docs): reduce security.txt expiry

This addresses a concern where the security.txt has a long expiration date. Someone could treat this as "use this until then", which we don't want since it's a long time. The specification recommends no longer than one year, as it is to mark as stale.

From the RFC:

> The "Expires" field indicates the date and time after which the data contained in the "security.txt" file is considered stale and should not be used (as per Section 5.3). The value of this field is formatted according to the Internet profiles of [ISO.8601-1] and [ISO.8601-2] as defined in [RFC3339]. It is RECOMMENDED that the value of this field be less than a year into the future to avoid staleness.

Signed-off-by: Erb3 <49862976+Erb3@users.noreply.github.com>

* fix(frontend): extend security.txt expiry

It takes so long to merge the PR :(

Signed-off-by: Erb3 <49862976+Erb3@users.noreply.github.com>

* docs(frontend) careers link in security.txt

Signed-off-by: Erb3 <49862976+Erb3@users.noreply.github.com>

---------

Signed-off-by: Erb3 <49862976+Erb3@users.noreply.github.com>
Co-authored-by: Erb3 <49862976+Erb3@users.noreply.github.com>
2025-07-09 15:51:46 -07:00
tippfehlr
7228499737 fix(theseus-gui): fix sort/group by game version (#1250)
* fix(theseus-gui): fix sort/group by game version

In the Library, game version 1.8.9 is sorted/grouped after 1.20 because
the default sorting sorts 2 < 8
therefore localeCompare(with numeric=true) is needed, it detects 8 < 20
and puts the versions in the correct order.

* lint

---------

Co-authored-by: Prospector <prospectordev@gmail.com>
2025-07-09 22:30:11 +00:00
Prospector
bca467a634 Add coventry europe region support (#3956)
* Add coventry europe region support, rename germany EU location to central europe

* extract messages

* extract messages again
2025-07-09 22:27:59 +00:00
IMB11
cb72d2ac80 Skins improvements/fixes (#3943)
* feat: only initialize batch renderer if needed & head storage

* feat: support webp storage of skin renders if supported (falls back to png if not)

* fix: performance improvements with cache loading+saving

* fix: mirrored skins + remove cape model for embedded cape

* feat: antialiasing

* fix: leg jumping & store fbx's for reference

* fix: lint issues

* fix: lint issues

* feat: tweaks to radial spotlight

* fix: app nav btn colors
2025-07-09 21:41:36 +00:00
Nitrrine
3c79607d1f feat(app): increase logs card height (#3953) 2025-07-09 21:39:51 +00:00
Alejandro González
36ad1f16e4 ci(theseus): assorted tweaks and fixes (#3949)
* ci(theseus-build): ensure only relevant bundle artifacts are uploaded

Tauri leaves behind quite a bit of intermediate garbage in these target
folders, even when building with no build cache.

* ci(theseus-release): fix typo in RPM package URL generation

* ci(theseus-build): generate shorter and more user-friendly commit build versions
2025-07-08 21:02:17 +00:00
Prospector
5d4f334505 Update changelog 2025-07-08 13:57:06 -07:00
Prospector
1fdb5ba748 Add authors to blog posts and shorten some summaries (#3940) 2025-07-08 20:48:27 +00:00
IMB11
26df6f51ef fix: composable used outside ... issue + disable cache (#3947) 2025-07-08 20:09:36 +00:00
Alejandro González
6caf794ae1 dist(docker): add curl package to Labrinth image, some other minor tweaks (#3915)
* dist(docker): add `.dockerignore` as symlink to `.gitignore`

This ensures that no files outside of version control are transferred to
the Docker build context for Labrinth and Daedalus images, which
significantly improves build speed (if a `target` directory is already
present) and build reproducibility.

* chore(dist/docker): simplify out unneeeded statements, move `SQLX_OFFLINE` env var setting to build command itself

The latter approach ensures that developers building the image locally
don't forget to set `SQLX_OFFLINE`, too.

* dist(docker): add `curl` package to Labrinth image
2025-07-08 19:22:15 +00:00
Alejandro González
2692953e31 fix(app): make Party Alex bonus default skin have slim arms (#3945)
This skin was incorrectly declared as having wide arms. Resolves #3941.
2025-07-08 19:03:30 +00:00
Prospector
242fd713ab Update changelog 2025-07-08 11:06:50 -07:00
IMB11
7a12c4d5e2 feat: reimplement error handling improvements w/o polling (#3942)
* Reapply "fix: error handling improvements (#3797)"

This reverts commit e0cde2d6ff.

* fix: polling issues

* fix: circuit breaker logic for spam

* fix: remove polling & ping test node instead

* fix: remove broken url from debugging

* fix: show error information display if node access fails in fs module
2025-07-08 17:40:44 +00:00
Prospector
f256ef43c0 Add x-archon-request header 2025-07-07 22:16:26 -07:00
Prospector
e0cde2d6ff Revert "fix: error handling improvements (#3797)"
This reverts commit 706976439d.
2025-07-07 17:37:43 -07:00
Prospector
e4e77dc0d2 Revert "temp: do not retry MRS requests"
This reverts commit 8ba6467f21.
2025-07-07 17:07:27 -07:00
Prospector
8ba6467f21 temp: do not retry MRS requests 2025-07-07 16:49:17 -07:00
Josiah Glosson
088cb54317 Fix failure when "Test"ing a Java installation (#3935)
* Fix failure when "Test"ing a Java installation

* Fix lint
2025-07-07 19:11:36 +00:00
Josiah Glosson
c47bcf665d Fix MinecraftLaunch failing in the case of a package-private main class on Java 8 (#3932)
I don't know of any mod loaders where this is the case, but better be safe than sorry
2025-07-07 15:42:38 +00:00
Prospector
bc90c27e27 Add ?new to url to give it a new key 2025-07-07 01:18:40 -07:00
Prospector
c1be57773a Update changelog 2025-07-07 01:10:51 -07:00
IMB11
315c68912c fix: use watch for links not mount event (#3929) 2025-07-07 08:01:21 +00:00
Prospector
559d203996 Add a hack to temporarily patch Java 8 not working (#3927) 2025-07-07 00:52:41 -07:00
Prospector
54522518c3 Update changelog + blog post time 2025-07-06 16:37:47 -07:00
Prospector
bacb1561d5 Allow http from asset.localhost and textures.minecraft.net on mac (#3922) 2025-07-06 22:31:55 +00:00
IMB11
b8521f926f feat: skins blogpost (#3904)
* feat: skins blogpost

* fix: clarify changelog note

* Update packages/blog/articles/skins-now-in-modrinth-app.md

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
Signed-off-by: IMB11 <hendersoncal117@gmail.com>

* fix: review issues

* fix: lint

---------

Signed-off-by: IMB11 <hendersoncal117@gmail.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2025-07-06 21:43:36 +00:00
IMB11
b29672f4b4 fix: model issues & move to @modrinth/assets (#3911)
* fix: model issues & move to `@modrinth/assets`

* revert: vscode settings change

* fix: remove unused props
2025-07-06 21:42:55 +00:00
Alejandro González
a32fe6a41f ci: revamp app build workflow, introduce a new one for release deployment (#3921)
* feat(ci): clean up app release build workflow, set app versions to match tag's

* feat(ci): rename Theseus build workflow, add new release workflow

* chore(ci): minor tweaks to `theseus-build` workflow

* chore: update workflow reference in comments
2025-07-06 21:41:52 +00:00
IMB11
0e35135093 refactor: cleanup & fix caching issues on /app page. (#3919) 2025-07-06 21:41:21 +00:00
Josiah Glosson
31ecace083 Fix launching older Forge versions (#3920) 2025-07-06 19:09:49 +00:00
Alejandro González
e5b134f8f4 feat(app): add free official Java Edition skin packs as default skins (#3913) 2025-07-06 10:16:11 +00:00
Ben
139a4863d1 Fix typo for skin name tag settings (#3903)
Signed-off-by: Ben <67504107+bjsho@users.noreply.github.com>
2025-07-05 19:42:20 +00:00
477 changed files with 20077 additions and 5310 deletions

View File

@@ -2,5 +2,8 @@
[target.'cfg(windows)'] [target.'cfg(windows)']
rustflags = ["-C", "link-args=/STACK:16777220", "--cfg", "tokio_unstable"] rustflags = ["-C", "link-args=/STACK:16777220", "--cfg", "tokio_unstable"]
[target.x86_64-pc-windows-msvc]
linker = "rust-lld"
[build] [build]
rustflags = ["--cfg", "tokio_unstable"] rustflags = ["--cfg", "tokio_unstable"]

1
.dockerignore Symbolic link
View File

@@ -0,0 +1 @@
.gitignore

View File

@@ -22,23 +22,26 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Fetch docker metadata - name: Fetch docker metadata
id: docker_meta id: docker_meta
uses: docker/metadata-action@v3 uses: docker/metadata-action@v5
with: with:
images: ghcr.io/modrinth/daedalus images: ghcr.io/modrinth/daedalus
- name: Login to GitHub Images - name: Login to GitHub Images
uses: docker/login-action@v1 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push - name: Build and push
id: docker_build uses: docker/build-push-action@v6
uses: docker/build-push-action@v2
with: with:
file: ./apps/daedalus_client/Dockerfile file: ./apps/daedalus_client/Dockerfile
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker_meta.outputs.tags }} tags: ${{ steps.docker_meta.outputs.tags }}
labels: ${{ steps.docker_meta.outputs.labels }} labels: ${{ steps.docker_meta.outputs.labels }}
cache-from: type=registry,ref=ghcr.io/modrinth/daedalus:main
cache-to: type=inline

View File

@@ -18,30 +18,28 @@ on:
jobs: jobs:
docker: docker:
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults:
run:
working-directory: ./apps/labrinth
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Fetch docker metadata - name: Fetch docker metadata
id: docker_meta id: docker_meta
uses: docker/metadata-action@v3 uses: docker/metadata-action@v5
with: with:
images: ghcr.io/modrinth/labrinth images: ghcr.io/modrinth/labrinth
- name: Login to GitHub Images - name: Login to GitHub Images
uses: docker/login-action@v1 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push - name: Build and push
id: docker_build uses: docker/build-push-action@v6
uses: docker/build-push-action@v2
env:
SQLX_OFFLINE: true
with: with:
file: ./apps/labrinth/Dockerfile file: ./apps/labrinth/Dockerfile
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker_meta.outputs.tags }} tags: ${{ steps.docker_meta.outputs.tags }}
labels: ${{ steps.docker_meta.outputs.labels }} labels: ${{ steps.docker_meta.outputs.labels }}
cache-from: type=registry,ref=ghcr.io/modrinth/labrinth:main
cache-to: type=inline

152
.github/workflows/theseus-build.yml vendored Normal file
View File

@@ -0,0 +1,152 @@
name: Modrinth App build
on:
push:
branches:
- main
tags:
- 'v*'
paths:
- .github/workflows/theseus-build.yml
- 'apps/app/**'
- 'apps/app-frontend/**'
- 'packages/app-lib/**'
- 'packages/app-macros/**'
- 'packages/assets/**'
- 'packages/ui/**'
- 'packages/utils/**'
workflow_dispatch:
inputs:
sign-windows-binaries:
description: Sign Windows binaries
type: boolean
default: true
required: false
jobs:
build:
name: Build
strategy:
fail-fast: false
matrix:
platform: [macos-latest, windows-latest, ubuntu-22.04]
include:
- platform: macos-latest
artifact-target-name: universal-apple-darwin
- platform: windows-latest
artifact-target-name: x86_64-pc-windows-msvc
- platform: ubuntu-22.04
artifact-target-name: x86_64-unknown-linux-gnu
runs-on: ${{ matrix.platform }}
steps:
- name: 📥 Check out code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: 🧰 Setup Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
rustflags: ''
target: ${{ startsWith(matrix.platform, 'macos') && 'x86_64-apple-darwin' || '' }}
- name: 🧰 Install pnpm
uses: pnpm/action-setup@v4
- name: 🧰 Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: pnpm
- name: 🧰 Install Linux build dependencies
if: startsWith(matrix.platform, 'ubuntu')
run: |
sudo apt-get update
sudo apt-get install -yq libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev
- name: 🧰 Setup Dasel
uses: jaxxstorm/action-install-gh-release@v2.1.0
with:
repo: TomWright/dasel
tag: v2.8.1
extension-matching: disable
rename-to: ${{ startsWith(matrix.platform, 'windows') && 'dasel.exe' || 'dasel' }}
chmod: 0755
- name: ⚙️ Set application version and environment
shell: bash
run: |
APP_VERSION="$(git describe --tags --always | sed -E 's/-([0-9]+)-(g[0-9a-fA-F]+)$/-canary+\1.\2/')"
echo "Setting application version to $APP_VERSION"
dasel put -f apps/app/Cargo.toml -t string -v "${APP_VERSION#v}" 'package.version'
dasel put -f packages/app-lib/Cargo.toml -t string -v "${APP_VERSION#v}" 'package.version'
dasel put -f apps/app-frontend/package.json -t string -v "${APP_VERSION#v}" 'version'
cp packages/app-lib/.env.prod packages/app-lib/.env
- name: 💨 Setup Turbo cache
uses: rharkor/caching-for-turbo@v1.8
- name: 🧰 Install dependencies
run: pnpm install
- name: ✍️ Set up Windows code signing
if: startsWith(matrix.platform, 'windows')
shell: bash
run: |
if [ '${{ startsWith(github.ref, 'refs/tags/v') || inputs.sign-windows-binaries }}' = 'true' ]; then
choco install jsign --ignore-dependencies # GitHub runners come with a global Java installation already
else
dasel delete -f apps/app/tauri-release.conf.json 'bundle.windows.signCommand'
fi
- name: 🔨 Build macOS app
run: pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-release.conf.json
if: startsWith(matrix.platform, 'macos')
env:
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
- name: 🔨 Build Linux app
run: pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json
if: startsWith(matrix.platform, 'ubuntu')
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
- name: 🔨 Build Windows app
run: |
[System.Convert]::FromBase64String("$env:DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64") | Set-Content -Path signer-client-cert.p12 -AsByteStream
$env:DIGICERT_ONE_SIGNER_CREDENTIALS = "$env:DIGICERT_ONE_SIGNER_API_KEY|$PWD\signer-client-cert.p12|$env:DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD"
$env:JAVA_HOME = "$env:JAVA_HOME_11_X64"
pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json --verbose --bundles 'nsis,updater'
Remove-Item -Path signer-client-cert.p12 -ErrorAction SilentlyContinue
if: startsWith(matrix.platform, 'windows')
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
DIGICERT_ONE_SIGNER_API_KEY: ${{ secrets.DIGICERT_ONE_SIGNER_API_KEY }}
DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64: ${{ secrets.DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64 }}
DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD: ${{ secrets.DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD }}
- name: 📤 Upload app bundles
uses: actions/upload-artifact@v4
with:
name: App bundle (${{ matrix.artifact-target-name }})
path: |
target/release/bundle/appimage/Modrinth App_*.AppImage*
target/release/bundle/deb/Modrinth App_*.deb*
target/release/bundle/rpm/Modrinth App-*.rpm*
target/universal-apple-darwin/release/bundle/macos/Modrinth App.app.tar.gz*
target/universal-apple-darwin/release/bundle/dmg/Modrinth App_*.dmg*
target/release/bundle/nsis/Modrinth App_*-setup.exe*
target/release/bundle/nsis/Modrinth App_*-setup.nsis.zip*

View File

@@ -1,186 +1,118 @@
name: 'Modrinth App build' name: Modrinth App release
on: on:
push:
branches:
- main
tags:
- 'v*'
paths:
- .github/workflows/theseus-release.yml
- 'apps/app/**'
- 'apps/app-frontend/**'
- 'packages/app-lib/**'
- 'packages/app-macros/**'
- 'packages/assets/**'
- 'packages/ui/**'
- 'packages/utils/**'
workflow_dispatch: workflow_dispatch:
inputs: inputs:
sign-windows-binaries: version-tag:
description: Sign Windows binaries description: Version tag to release to the wide public
type: boolean type: string
default: true required: true
required: false release-notes:
description: Release notes to include in the Tauri version manifest
default: A new release of the Modrinth App is available!
type: string
required: true
jobs: jobs:
build: release:
strategy: name: Release Modrinth App
fail-fast: false runs-on: ubuntu-latest
matrix:
platform: [macos-latest, windows-latest, ubuntu-22.04]
runs-on: ${{ matrix.platform }} env:
LINUX_X64_BUNDLE_ARTIFACT_NAME: App bundle (x86_64-unknown-linux-gnu)
WINDOWS_X64_BUNDLE_ARTIFACT_NAME: App bundle (x86_64-pc-windows-msvc)
MACOS_UNIVERSAL_BUNDLE_ARTIFACT_NAME: App bundle (universal-apple-darwin)
LAUNCHER_FILES_BUCKET_BASE_URL: https://launcher-files.modrinth.com
steps: steps:
- uses: actions/checkout@v4 - name: 📥 Download Modrinth App artifacts
uses: dawidd6/action-download-artifact@v11
- name: Rust setup (mac)
if: startsWith(matrix.platform, 'macos')
uses: actions-rust-lang/setup-rust-toolchain@v1
with: with:
rustflags: '' workflow: theseus-build.yml
target: x86_64-apple-darwin workflow_conclusion: success
event: push
branch: ${{ inputs.version-tag }}
use_unzip: true
- name: Rust setup - name: 🛠️ Generate version manifest
if: "!startsWith(matrix.platform, 'macos')"
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
rustflags: ''
- name: Setup rust cache
uses: actions/cache@v4
with:
path: |
target/**
!target/*/release/bundle/*/*.dmg
!target/*/release/bundle/*/*.app.tar.gz
!target/*/release/bundle/*/*.app.tar.gz.sig
!target/release/bundle/*/*.dmg
!target/release/bundle/*/*.app.tar.gz
!target/release/bundle/*/*.app.tar.gz.sig
!target/release/bundle/appimage/*.AppImage
!target/release/bundle/appimage/*.AppImage.tar.gz
!target/release/bundle/appimage/*.AppImage.tar.gz.sig
!target/release/bundle/deb/*.deb
!target/release/bundle/rpm/*.rpm
!target/release/bundle/msi/*.msi
!target/release/bundle/msi/*.msi.zip
!target/release/bundle/msi/*.msi.zip.sig
!target/release/bundle/nsis/*.exe
!target/release/bundle/nsis/*.nsis.zip
!target/release/bundle/nsis/*.nsis.zip.sig
key: ${{ runner.os }}-rust-target-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-rust-target-
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Install pnpm via corepack
shell: bash
run: |
corepack enable
corepack prepare --activate
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: install dependencies (ubuntu only)
if: startsWith(matrix.platform, 'ubuntu')
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev pkg-config libayatana-appindicator3-dev librsvg2-dev
- name: Install code signing client (Windows only)
if: startsWith(matrix.platform, 'windows')
run: choco install jsign --ignore-dependencies # GitHub runners come with a global Java installation already
- name: Install frontend dependencies
run: pnpm install
- name: Disable Windows code signing for non-final release builds
if: ${{ startsWith(matrix.platform, 'windows') && !startsWith(github.ref, 'refs/tags/v') && !inputs.sign-windows-binaries }}
run: |
jq 'del(.bundle.windows.signCommand)' apps/app/tauri-release.conf.json > apps/app/tauri-release.conf.json.new
Move-Item -Path apps/app/tauri-release.conf.json.new -Destination apps/app/tauri-release.conf.json -Force
- name: build app (macos)
run: pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-release.conf.json
if: startsWith(matrix.platform, 'macos')
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION_TAG: ${{ inputs.version-tag }}
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }} RELEASE_NOTES: ${{ inputs.release-notes }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
- name: build app (Linux)
run: pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json
if: startsWith(matrix.platform, 'ubuntu')
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
- name: build app (Windows)
run: | run: |
[System.Convert]::FromBase64String("$env:DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64") | Set-Content -Path signer-client-cert.p12 -AsByteStream # Reference: https://tauri.app/plugin/updater/#server-support
$env:DIGICERT_ONE_SIGNER_CREDENTIALS = "$env:DIGICERT_ONE_SIGNER_API_KEY|$PWD\signer-client-cert.p12|$env:DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD" jq -nc \
$env:JAVA_HOME = "$env:JAVA_HOME_11_X64" --arg versionTag "${VERSION_TAG#v}" \
pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json --verbose --bundles 'nsis,updater' --arg releaseNotes "$RELEASE_NOTES" \
Remove-Item -Path signer-client-cert.p12 --rawfile macOsAarch64UpdateArtifactSignature "${MACOS_UNIVERSAL_BUNDLE_ARTIFACT_NAME}/universal-apple-darwin/release/bundle/macos/Modrinth App.app.tar.gz.sig" \
if: startsWith(matrix.platform, 'windows') --rawfile macOsX64UpdateArtifactSignature "${MACOS_UNIVERSAL_BUNDLE_ARTIFACT_NAME}/universal-apple-darwin/release/bundle/macos/Modrinth App.app.tar.gz.sig" \
--rawfile linuxX64UpdateArtifactSignature "${LINUX_X64_BUNDLE_ARTIFACT_NAME}/release/bundle/appimage/Modrinth App_${VERSION_TAG#v}_amd64.AppImage.tar.gz.sig" \
--rawfile windowsX64UpdateArtifactSignature "${WINDOWS_X64_BUNDLE_ARTIFACT_NAME}/release/bundle/nsis/Modrinth App_${VERSION_TAG#v}_x64-setup.nsis.zip.sig" \
'{
"version": $versionTag,
"notes": $releaseNotes,
"pub_date": now | todateiso8601,
"platforms": {
"darwin-aarch64": {
"signature": $macOsAarch64UpdateArtifactSignature,
"url": @uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/macos/\("Modrinth App.app.tar.gz")",
"install_urls": [@uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/macos/\("Modrinth App_" + $versionTag + "_universal.dmg")"]
},
"darwin-x86_64": {
"signature": $macOsX64UpdateArtifactSignature,
"url": @uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/macos/\("Modrinth App.app.tar.gz")",
"install_urls": [@uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/macos/\("Modrinth App_" + $versionTag + "_universal.dmg")"]
},
"linux-x86_64": {
"signature": $linuxX64UpdateArtifactSignature,
"url": @uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/linux/\("Modrinth App_" + $versionTag + "_amd64.AppImage.tar.gz")",
"install_urls": [
@uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/linux/\("Modrinth App_" + $versionTag + "_amd64.deb")",
@uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/linux/\("Modrinth App_" + $versionTag + "_amd64.AppImage")",
@uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/linux/\("Modrinth App-" + $versionTag + "-1.x86_64.rpm")"
]
},
"windows-x86_64": {
"signature": $windowsX64UpdateArtifactSignature,
"url": @uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/windows/\("Modrinth App_" + $versionTag + "_x64-setup.nsis.zip")",
"install_urls": [@uri "${{ env.LAUNCHER_FILES_BUCKET_BASE_URL }}/versions/\($versionTag)/windows/\("Modrinth App_" + $versionTag + "_x64-setup.exe")"]
}
}
}' > updates.json
echo "Generated manifest for version ${VERSION_TAG}:"
cat updates.json
- name: 📤 Upload release artifacts
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION_TAG: ${{ inputs.version-tag }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }} AWS_ACCESS_KEY_ID: ${{ secrets.LAUNCHER_FILES_BUCKET_ACCESS_KEY_ID }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }} AWS_SECRET_ACCESS_KEY: ${{ secrets.LAUNCHER_FILES_BUCKET_SECRET_ACCESS_KEY }}
DIGICERT_ONE_SIGNER_API_KEY: ${{ secrets.DIGICERT_ONE_SIGNER_API_KEY }} AWS_BUCKET: ${{ secrets.LAUNCHER_FILES_BUCKET_NAME }}
DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64: ${{ secrets.DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64 }} AWS_REGION: ${{ secrets.LAUNCHER_FILES_BUCKET_REGION }}
DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD: ${{ secrets.DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD }} AWS_ENDPOINT_URL: ${{ secrets.LAUNCHER_FILES_BUCKET_ENDPOINT_URL }}
AWS_PAGER: ''
# Work around incompatible checksum behavior with some S3-like object storage providers,
# such as Cloudflare R2. See:
# - https://developers.cloudflare.com/r2/examples/aws/aws-cli/
# - https://developers.cloudflare.com/r2/examples/aws/aws-sdk-java/
AWS_REQUEST_CHECKSUM_CALCULATION: when_required
AWS_RESPONSE_CHECKSUM_VALIDATION: when_required
run: |
for macosBundleType in 'macos' 'dmg'; do
aws s3 cp --recursive \
"${MACOS_UNIVERSAL_BUNDLE_ARTIFACT_NAME}/universal-apple-darwin/release/bundle/${macosBundleType}" \
"s3://${AWS_BUCKET}/versions/${VERSION_TAG#v}/macos"
done
- name: upload ${{ matrix.platform }} for linuxBundleType in 'appimage' 'deb' 'rpm'; do
uses: actions/upload-artifact@v4 aws s3 cp --recursive \
with: "${LINUX_X64_BUNDLE_ARTIFACT_NAME}/release/bundle/${linuxBundleType}" \
name: ${{ matrix.platform }} "s3://${AWS_BUCKET}/versions/${VERSION_TAG#v}/linux"
path: | done
target/*/release/bundle/*/*.dmg
target/*/release/bundle/*/*.app.tar.gz
target/*/release/bundle/*/*.app.tar.gz.sig
target/release/bundle/*/*.dmg
target/release/bundle/*/*.app.tar.gz
target/release/bundle/*/*.app.tar.gz.sig
target/release/bundle/*/*.AppImage for windowsBundleType in 'nsis'; do
target/release/bundle/*/*.AppImage.tar.gz aws s3 cp --recursive \
target/release/bundle/*/*.AppImage.tar.gz.sig "${WINDOWS_X64_BUNDLE_ARTIFACT_NAME}/release/bundle/${windowsBundleType}" \
target/release/bundle/*/*.deb "s3://${AWS_BUCKET}/versions/${VERSION_TAG#v}/windows"
target/release/bundle/*/*.rpm done
target/release/bundle/msi/*.msi aws s3 cp updates.json "s3://${AWS_BUCKET}"
target/release/bundle/msi/*.msi.zip
target/release/bundle/msi/*.msi.zip.sig
target/release/bundle/nsis/*.exe
target/release/bundle/nsis/*.nsis.zip
target/release/bundle/nsis/*.nsis.zip.sig

View File

@@ -52,7 +52,7 @@ jobs:
# cargo-binstall does not have pre-built binaries for sqlx-cli, so we fall # cargo-binstall does not have pre-built binaries for sqlx-cli, so we fall
# back to a cached cargo install # back to a cached cargo install
- name: 🧰 Setup cargo-sqlx - name: 🧰 Setup cargo-sqlx
uses: AlexTMjugador/cache-cargo-install-action@feat/features-support uses: taiki-e/cache-cargo-install-action@v2
with: with:
tool: sqlx-cli tool: sqlx-cli
locked: false locked: false
@@ -74,5 +74,14 @@ jobs:
cp .env.local .env cp .env.local .env
sqlx database setup sqlx database setup
- name: ⚙️ Set app environment
working-directory: packages/app-lib
run: cp .env.staging .env
- name: 🔍 Lint and test - name: 🔍 Lint and test
run: pnpm run ci run: pnpm run ci
- name: 🔍 Verify intl:extract has been run
run: |
pnpm intl:extract
git diff --exit-code --color */*/src/locales/en-US/index.json

3
.idea/code.iml generated
View File

@@ -10,11 +10,10 @@
<sourceFolder url="file://$MODULE_DIR$/apps/labrinth/src" isTestSource="false" /> <sourceFolder url="file://$MODULE_DIR$/apps/labrinth/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/apps/labrinth/tests" isTestSource="true" /> <sourceFolder url="file://$MODULE_DIR$/apps/labrinth/tests" isTestSource="true" />
<sourceFolder url="file://$MODULE_DIR$/packages/app-lib/src" isTestSource="false" /> <sourceFolder url="file://$MODULE_DIR$/packages/app-lib/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/rust-common/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/ariadne/src" isTestSource="false" /> <sourceFolder url="file://$MODULE_DIR$/packages/ariadne/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/target" /> <excludeFolder url="file://$MODULE_DIR$/target" />
</content> </content>
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />
</component> </component>
</module> </module>

View File

@@ -4,6 +4,7 @@
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"], "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"],
"editor.detectIndentation": true, "editor.detectIndentation": true,
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit" "source.fixAll.eslint": "explicit",
"source.organizeImports": "always",
} }
} }

164
Cargo.lock generated
View File

@@ -1706,7 +1706,7 @@ dependencies = [
"bitflags 2.9.1", "bitflags 2.9.1",
"core-foundation 0.10.0", "core-foundation 0.10.0",
"core-graphics-types", "core-graphics-types",
"foreign-types 0.5.0", "foreign-types",
"libc", "libc",
] ]
@@ -2699,15 +2699,6 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared 0.1.1",
]
[[package]] [[package]]
name = "foreign-types" name = "foreign-types"
version = "0.5.0" version = "0.5.0"
@@ -2715,7 +2706,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [ dependencies = [
"foreign-types-macros", "foreign-types-macros",
"foreign-types-shared 0.3.1", "foreign-types-shared",
] ]
[[package]] [[package]]
@@ -2729,12 +2720,6 @@ dependencies = [
"syn 2.0.101", "syn 2.0.101",
] ]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]] [[package]]
name = "foreign-types-shared" name = "foreign-types-shared"
version = "0.3.1" version = "0.3.1"
@@ -3678,11 +3663,10 @@ dependencies = [
[[package]] [[package]]
name = "hyper-rustls" name = "hyper-rustls"
version = "0.27.5" version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d191583f3da1305256f22463b9bb0471acad48a4e534a5218b9963e9c1f59b2" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [ dependencies = [
"futures-util",
"http 1.3.1", "http 1.3.1",
"hyper 1.6.0", "hyper 1.6.0",
"hyper-util", "hyper-util",
@@ -3692,7 +3676,7 @@ dependencies = [
"tokio", "tokio",
"tokio-rustls 0.26.2", "tokio-rustls 0.26.2",
"tower-service", "tower-service",
"webpki-roots 0.26.11", "webpki-roots 1.0.0",
] ]
[[package]] [[package]]
@@ -3708,22 +3692,6 @@ dependencies = [
"tower-service", "tower-service",
] ]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper 1.6.0",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]] [[package]]
name = "hyper-util" name = "hyper-util"
version = "0.1.14" version = "0.1.14"
@@ -4389,7 +4357,7 @@ dependencies = [
"futures-util", "futures-util",
"hex", "hex",
"hmac", "hmac",
"hyper-tls", "hyper-rustls 0.27.7",
"hyper-util", "hyper-util",
"iana-time-zone", "iana-time-zone",
"image", "image",
@@ -4986,23 +4954,6 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "native-tls"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework 2.11.1",
"security-framework-sys",
"tempfile",
]
[[package]] [[package]]
name = "ndk" name = "ndk"
version = "0.9.0" version = "0.9.0"
@@ -5577,50 +5528,12 @@ dependencies = [
"pathdiff", "pathdiff",
] ]
[[package]]
name = "openssl"
version = "0.10.72"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da"
dependencies = [
"bitflags 2.9.1",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.101",
]
[[package]] [[package]]
name = "openssl-probe" name = "openssl-probe"
version = "0.1.6" version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-sys"
version = "0.9.108"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e145e1651e858e820e4860f7b9c5e169bc1d8ce1c86043be79fa7b7634821847"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]] [[package]]
name = "option-ext" name = "option-ext"
version = "0.2.0" version = "0.2.0"
@@ -5818,6 +5731,17 @@ dependencies = [
"phf_shared 0.11.3", "phf_shared 0.11.3",
] ]
[[package]]
name = "phf"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
dependencies = [
"phf_macros 0.12.1",
"phf_shared 0.12.1",
"serde",
]
[[package]] [[package]]
name = "phf_codegen" name = "phf_codegen"
version = "0.8.0" version = "0.8.0"
@@ -5868,6 +5792,16 @@ dependencies = [
"rand 0.8.5", "rand 0.8.5",
] ]
[[package]]
name = "phf_generator"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2cbb1126afed61dd6368748dae63b1ee7dc480191c6262a3b4ff1e29d86a6c5b"
dependencies = [
"fastrand 2.3.0",
"phf_shared 0.12.1",
]
[[package]] [[package]]
name = "phf_macros" name = "phf_macros"
version = "0.10.0" version = "0.10.0"
@@ -5895,6 +5829,19 @@ dependencies = [
"syn 2.0.101", "syn 2.0.101",
] ]
[[package]]
name = "phf_macros"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d713258393a82f091ead52047ca779d37e5766226d009de21696c4e667044368"
dependencies = [
"phf_generator 0.12.1",
"phf_shared 0.12.1",
"proc-macro2",
"quote",
"syn 2.0.101",
]
[[package]] [[package]]
name = "phf_shared" name = "phf_shared"
version = "0.8.0" version = "0.8.0"
@@ -5922,6 +5869,15 @@ dependencies = [
"siphasher 1.0.1", "siphasher 1.0.1",
] ]
[[package]]
name = "phf_shared"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
dependencies = [
"siphasher 1.0.1",
]
[[package]] [[package]]
name = "pin-project" name = "pin-project"
version = "1.1.10" version = "1.1.10"
@@ -6850,7 +6806,7 @@ dependencies = [
"http-body 1.0.1", "http-body 1.0.1",
"http-body-util", "http-body-util",
"hyper 1.6.0", "hyper 1.6.0",
"hyper-rustls 0.27.5", "hyper-rustls 0.27.7",
"hyper-util", "hyper-util",
"js-sys", "js-sys",
"log", "log",
@@ -7980,7 +7936,7 @@ dependencies = [
"bytemuck", "bytemuck",
"cfg_aliases", "cfg_aliases",
"core-graphics", "core-graphics",
"foreign-types 0.5.0", "foreign-types",
"js-sys", "js-sys",
"log", "log",
"objc2 0.5.2", "objc2 0.5.2",
@@ -8999,7 +8955,7 @@ dependencies = [
[[package]] [[package]]
name = "theseus" name = "theseus"
version = "0.10.1" version = "1.0.0-local"
dependencies = [ dependencies = [
"ariadne", "ariadne",
"async-compression", "async-compression",
@@ -9017,6 +8973,7 @@ dependencies = [
"data-url", "data-url",
"dirs", "dirs",
"discord-rich-presence", "discord-rich-presence",
"dotenvy",
"dunce", "dunce",
"either", "either",
"encoding_rs", "encoding_rs",
@@ -9032,6 +8989,7 @@ dependencies = [
"notify-debouncer-mini", "notify-debouncer-mini",
"p256", "p256",
"paste", "paste",
"phf 0.12.1",
"png", "png",
"quartz_nbt", "quartz_nbt",
"quick-xml 0.37.5", "quick-xml 0.37.5",
@@ -9064,13 +9022,15 @@ dependencies = [
[[package]] [[package]]
name = "theseus_gui" name = "theseus_gui"
version = "0.10.1" version = "1.0.0-local"
dependencies = [ dependencies = [
"chrono", "chrono",
"daedalus", "daedalus",
"dashmap", "dashmap",
"either", "either",
"enumset", "enumset",
"hyper 1.6.0",
"hyper-util",
"native-dialog", "native-dialog",
"paste", "paste",
"serde", "serde",
@@ -9292,16 +9252,6 @@ dependencies = [
"syn 2.0.101", "syn 2.0.101",
] ]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]] [[package]]
name = "tokio-rustls" name = "tokio-rustls"
version = "0.24.1" version = "0.24.1"

View File

@@ -67,7 +67,13 @@ heck = "0.5.0"
hex = "0.4.3" hex = "0.4.3"
hickory-resolver = "0.25.2" hickory-resolver = "0.25.2"
hmac = "0.12.1" hmac = "0.12.1"
hyper-tls = "0.6.0" hyper = "1.6.0"
hyper-rustls = { version = "0.27.7", default-features = false, features = [
"http1",
"native-tokio",
"ring",
"tls12",
] }
hyper-util = "0.1.14" hyper-util = "0.1.14"
iana-time-zone = "0.1.63" iana-time-zone = "0.1.63"
image = { version = "0.25.6", default-features = false, features = ["rayon"] } image = { version = "0.25.6", default-features = false, features = ["rayon"] }
@@ -93,6 +99,7 @@ notify = { version = "8.0.0", default-features = false }
notify-debouncer-mini = { version = "0.6.0", default-features = false } notify-debouncer-mini = { version = "0.6.0", default-features = false }
p256 = "0.13.2" p256 = "0.13.2"
paste = "1.0.15" paste = "1.0.15"
phf = { version = "0.12.1", features = ["macros"] }
png = "0.17.16" png = "0.17.16"
prometheus = "0.14.0" prometheus = "0.14.0"
quartz_nbt = "0.2.9" quartz_nbt = "0.2.9"

View File

@@ -1,7 +1,7 @@
{ {
"name": "@modrinth/app-frontend", "name": "@modrinth/app-frontend",
"private": true, "private": true,
"version": "0.10.1", "version": "1.0.0-local",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -9,7 +9,7 @@
"tsc:check": "vue-tsc --noEmit", "tsc:check": "vue-tsc --noEmit",
"lint": "eslint . && prettier --check .", "lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write .", "fix": "eslint . --fix && prettier --write .",
"intl:extract": "formatjs extract \"{,src/components,src/composables,src/helpers,src/pages,src/store}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore '**/*.d.ts' --ignore 'node_modules' --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace", "intl:extract": "formatjs extract \"src/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore \"**/*.d.ts\" --ignore node_modules --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
"test": "vue-tsc --noEmit" "test": "vue-tsc --noEmit"
}, },
"dependencies": { "dependencies": {

View File

@@ -1,6 +1,35 @@
<script setup> <script setup>
import { computed, onMounted, onUnmounted, ref, watch, provide } from 'vue' import ModrinthAppLogo from '@/assets/modrinth_app.svg?component'
import { RouterView, useRoute, useRouter } from 'vue-router' import ModrinthLoadingIndicator from '@/components/LoadingIndicatorBar.vue'
import AccountsCard from '@/components/ui/AccountsCard.vue'
import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
import ErrorModal from '@/components/ui/ErrorModal.vue'
import FriendsList from '@/components/ui/friends/FriendsList.vue'
import IncompatibilityWarningModal from '@/components/ui/install_flow/IncompatibilityWarningModal.vue'
import InstallConfirmModal from '@/components/ui/install_flow/InstallConfirmModal.vue'
import ModInstallModal from '@/components/ui/install_flow/ModInstallModal.vue'
import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue'
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue'
import NavButton from '@/components/ui/NavButton.vue'
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
import RunningAppBar from '@/components/ui/RunningAppBar.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'
import URLConfirmModal from '@/components/ui/URLConfirmModal.vue'
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
import { hide_ads_window, init_ads_window } from '@/helpers/ads.js'
import { debugAnalytics, initAnalytics, optOutAnalytics, trackEvent } from '@/helpers/analytics'
import { get_user } from '@/helpers/cache.js'
import { command_listener, warning_listener } from '@/helpers/events.js'
import { useFetch } from '@/helpers/fetch.js'
import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.js'
import { get } from '@/helpers/settings.ts'
import { get_opening_command, initialize_state } from '@/helpers/state'
import { getOS, isDev, restartApp } from '@/helpers/utils.js'
import { useError } from '@/store/error.js'
import { useInstall } from '@/store/install.js'
import { useLoading, useTheming } from '@/store/state'
import { import {
ArrowBigUpDashIcon, ArrowBigUpDashIcon,
ChangeSkinIcon, ChangeSkinIcon,
@@ -13,67 +42,44 @@ import {
LogOutIcon, LogOutIcon,
MaximizeIcon, MaximizeIcon,
MinimizeIcon, MinimizeIcon,
NewspaperIcon,
PlusIcon, PlusIcon,
RestoreIcon, RestoreIcon,
RightArrowIcon, RightArrowIcon,
SettingsIcon, SettingsIcon,
WorldIcon, WorldIcon,
XIcon, XIcon,
NewspaperIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { import {
Avatar, Avatar,
Button, Button,
ButtonStyled, ButtonStyled,
Notifications,
OverflowMenu,
NewsArticleCard, NewsArticleCard,
NotificationPanel,
OverflowMenu,
provideNotificationManager,
} from '@modrinth/ui' } from '@modrinth/ui'
import { useLoading, useTheming } from '@/store/state'
import ModrinthAppLogo from '@/assets/modrinth_app.svg?component'
import AccountsCard from '@/components/ui/AccountsCard.vue'
import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue'
import { get } from '@/helpers/settings.ts'
import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
import RunningAppBar from '@/components/ui/RunningAppBar.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'
import ErrorModal from '@/components/ui/ErrorModal.vue'
import ModrinthLoadingIndicator from '@/components/LoadingIndicatorBar.vue'
import { handleError, useNotifications } from '@/store/notifications.js'
import { command_listener, warning_listener } from '@/helpers/events.js'
import { type } from '@tauri-apps/plugin-os'
import { getOS, isDev, restartApp } from '@/helpers/utils.js'
import { debugAnalytics, initAnalytics, optOutAnalytics, trackEvent } from '@/helpers/analytics'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { getVersion } from '@tauri-apps/api/app'
import URLConfirmModal from '@/components/ui/URLConfirmModal.vue'
import { create_profile_and_install_from_file } from './helpers/pack'
import { useError } from '@/store/error.js'
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
import ModInstallModal from '@/components/ui/install_flow/ModInstallModal.vue'
import IncompatibilityWarningModal from '@/components/ui/install_flow/IncompatibilityWarningModal.vue'
import InstallConfirmModal from '@/components/ui/install_flow/InstallConfirmModal.vue'
import { useInstall } from '@/store/install.js'
import { invoke } from '@tauri-apps/api/core'
import { get_opening_command, initialize_state } from '@/helpers/state'
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
import { renderString } from '@modrinth/utils' import { renderString } from '@modrinth/utils'
import { useFetch } from '@/helpers/fetch.js' import { getVersion } from '@tauri-apps/api/app'
import { check } from '@tauri-apps/plugin-updater' import { invoke } from '@tauri-apps/api/core'
import NavButton from '@/components/ui/NavButton.vue' import { getCurrentWindow } from '@tauri-apps/api/window'
import { get as getCreds, login, logout } from '@/helpers/mr_auth.js'
import { get_user } from '@/helpers/cache.js'
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
import { hide_ads_window, init_ads_window } from '@/helpers/ads.js'
import FriendsList from '@/components/ui/friends/FriendsList.vue'
import { openUrl } from '@tauri-apps/plugin-opener' import { openUrl } from '@tauri-apps/plugin-opener'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue' import { type } from '@tauri-apps/plugin-os'
import { get_available_capes, get_available_skins } from './helpers/skins' import { check } from '@tauri-apps/plugin-updater'
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
import { computed, onMounted, onUnmounted, provide, ref, watch } from 'vue'
import { RouterView, useRoute, useRouter } from 'vue-router'
import { create_profile_and_install_from_file } from './helpers/pack'
import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer' import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer'
import { get_available_capes, get_available_skins } from './helpers/skins'
import { AppNotificationManager } from './providers/app-notifications'
const themeStore = useTheming() const themeStore = useTheming()
const notificationManager = new AppNotificationManager()
provideNotificationManager(notificationManager)
const { handleError, addNotification } = notificationManager
const news = ref([]) const news = ref([])
const urlModal = ref(null) const urlModal = ref(null)
@@ -166,7 +172,7 @@ async function setupApp() {
} }
await warning_listener((e) => await warning_listener((e) =>
notificationsWrapper.value.addNotification({ addNotification({
title: 'Warning', title: 'Warning',
text: e.message, text: e.message,
type: 'warn', type: 'warn',
@@ -250,9 +256,6 @@ const route = useRoute()
const loading = useLoading() const loading = useLoading()
loading.setEnabled(false) loading.setEnabled(false)
const notifications = useNotifications()
const notificationsWrapper = ref()
const error = useError() const error = useError()
const errorModal = ref() const errorModal = ref()
@@ -263,6 +266,8 @@ const incompatibilityWarningModal = ref()
const credentials = ref() const credentials = ref()
const modrinthLoginFlowWaitModal = ref()
async function fetchCredentials() { async function fetchCredentials() {
const creds = await getCreds().catch(handleError) const creds = await getCreds().catch(handleError)
if (creds && creds.user_id) { if (creds && creds.user_id) {
@@ -272,8 +277,24 @@ async function fetchCredentials() {
} }
async function signIn() { async function signIn() {
await login().catch(handleError) modrinthLoginFlowWaitModal.value.show()
await fetchCredentials()
try {
await login()
await fetchCredentials()
} catch (error) {
if (
typeof error === 'object' &&
typeof error['message'] === 'string' &&
error.message.includes('Login canceled')
) {
// Not really an error due to being a result of user interaction, show nothing
} else {
handleError(error)
}
} finally {
modrinthLoginFlowWaitModal.value.hide()
}
} }
async function logOut() { async function logOut() {
@@ -316,8 +337,6 @@ watch(
onMounted(() => { onMounted(() => {
invoke('show_window') invoke('show_window')
notifications.setNotifs(notificationsWrapper.value)
error.setErrorModal(errorModal.value) error.setErrorModal(errorModal.value)
install.setIncompatibilityWarningModal(incompatibilityWarningModal) install.setIncompatibilityWarningModal(incompatibilityWarningModal)
@@ -402,6 +421,9 @@ function handleAuxClick(e) {
<Suspense> <Suspense>
<AppSettingsModal ref="settingsModal" /> <AppSettingsModal ref="settingsModal" />
</Suspense> </Suspense>
<Suspense>
<AuthGrantFlowWaitModal ref="modrinthLoginFlowWaitModal" @flow-cancel="cancelLogin" />
</Suspense>
<Suspense> <Suspense>
<InstanceCreationModal ref="installationModal" /> <InstanceCreationModal ref="installationModal" />
</Suspense> </Suspense>
@@ -485,13 +507,13 @@ function handleAuxClick(e) {
<ModrinthAppLogo class="h-full w-auto text-contrast pointer-events-none" /> <ModrinthAppLogo class="h-full w-auto text-contrast pointer-events-none" />
<div class="flex items-center gap-1 ml-3"> <div class="flex items-center gap-1 ml-3">
<button <button
class="cursor-pointer p-0 m-0 border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all" class="cursor-pointer p-0 m-0 text-contrast border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
@click="router.back()" @click="router.back()"
> >
<LeftArrowIcon /> <LeftArrowIcon />
</button> </button>
<button <button
class="cursor-pointer p-0 m-0 border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all" class="cursor-pointer p-0 m-0 text-contrast border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
@click="router.forward()" @click="router.forward()"
> >
<RightArrowIcon /> <RightArrowIcon />
@@ -635,7 +657,7 @@ function handleAuxClick(e) {
</div> </div>
</div> </div>
<URLConfirmModal ref="urlModal" /> <URLConfirmModal ref="urlModal" />
<Notifications ref="notificationsWrapper" sidebar /> <NotificationPanel has-sidebar />
<ErrorModal ref="errorModal" /> <ErrorModal ref="errorModal" />
<ModInstallModal ref="modInstallModal" /> <ModInstallModal ref="modInstallModal" />
<IncompatibilityWarningModal ref="incompatibilityWarningModal" /> <IncompatibilityWarningModal ref="incompatibilityWarningModal" />

View File

@@ -1 +0,0 @@
{"asset":{"version":"2.0","generator":"Blockbench 4.12.4 glTF exporter"},"scenes":[{"nodes":[1],"name":"blockbench_export"}],"scene":0,"nodes":[{"rotation":[0,0,0.19509032201612825,0.9807852804032304],"translation":[0.15625,1,0],"name":"Cape","mesh":0},{"children":[0]}],"bufferViews":[{"buffer":0,"byteOffset":0,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":288,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":576,"byteLength":192,"target":34962,"byteStride":8},{"buffer":0,"byteOffset":768,"byteLength":72,"target":34963}],"buffers":[{"byteLength":840,"uri":"data:application/octet-stream;base64,AAAAPQAAAAAAAKA+AAAAPQAAAAAAAKC+AAAAPQAAgL8AAKA+AAAAPQAAgL8AAKC+AAAAvQAAAAAAAKC+AAAAvQAAAAAAAKA+AAAAvQAAgL8AAKC+AAAAvQAAgL8AAKA+AAAAvQAAAAAAAKC+AAAAPQAAAAAAAKC+AAAAvQAAAAAAAKA+AAAAPQAAAAAAAKA+AAAAvQAAgL8AAKA+AAAAPQAAgL8AAKA+AAAAvQAAgL8AAKC+AAAAPQAAgL8AAKC+AAAAvQAAAAAAAKA+AAAAPQAAAAAAAKA+AAAAvQAAgL8AAKA+AAAAPQAAgL8AAKA+AAAAPQAAAAAAAKC+AAAAvQAAAAAAAKC+AAAAPQAAgL8AAKC+AAAAvQAAgL8AAKC+AACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AACAPAAAgD0AADA+AACAPQAAgDwAAAg/AAAwPgAACD8AAEA+AACAPQAAsD4AAIA9AABAPgAACD8AALA+AAAIPwAAgDwAAAA9AACAPAAAgD0AADA+AAAAPQAAMD4AAIA9AAAwPgAAAD0AAKg+AAAAPQAAMD4AAAAAAACoPgAAAAAAAEA+AACAPQAAMD4AAIA9AABAPgAACD8AADA+AAAIPwAAAAAAAIA9AACAPAAAgD0AAAAAAAAIPwAAgDwAAAg/AAACAAEAAgADAAEABAAGAAUABgAHAAUACAAKAAkACgALAAkADAAOAA0ADgAPAA0AEAASABEAEgATABEAFAAWABUAFgAXABUA"}],"accessors":[{"bufferView":0,"componentType":5126,"count":24,"max":[0.03125,0,0.3125],"min":[-0.03125,-1,-0.3125],"type":"VEC3"},{"bufferView":1,"componentType":5126,"count":24,"max":[1,1,1],"min":[-1,-1,-1],"type":"VEC3"},{"bufferView":2,"componentType":5126,"count":24,"max":[0.34375,0.53125],"min":[0,0],"type":"VEC2"},{"bufferView":3,"componentType":5123,"count":36,"max":[23],"min":[0],"type":"SCALAR"}],"materials":[{"pbrMetallicRoughness":{"metallicFactor":0,"roughnessFactor":1,"baseColorTexture":{"index":0}},"alphaMode":"MASK","alphaCutoff":0.05,"doubleSided":true}],"textures":[{"sampler":0,"source":0,"name":"cape.png"}],"samplers":[{"magFilter":9728,"minFilter":9728,"wrapS":33071,"wrapT":33071}],"images":[{"mimeType":"image/png","uri":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAAAgCAYAAACinX6EAAAAAXNSR0IArs4c6QAABCRJREFUaEPtlktoE1EUhm/sIzFpYsBgHyn2oRZt0C5ESxWFYutKNyKIWBBR6kpBd3Ur2J0FRdCiuKmIIG4qLnzgQqTUXSlVqdoXTU0lljRpYtOHI/8dznBnOm1mphMwJWeTzJ0795zznf/cex2MMXam2S/ht38siJ8V1lgd5mOBARerc3rZnmK37rwvCyk2nE6wezMRh+4EncH9B1ql+fkU64rPsaBz5bqh4T7Daxn1Kc5zVNeEePJIci0Aq71bzenY6JChwAnA0OBHQ/OtJLnWNwqAy61R1tO3k891ueRKoDKwtqbv7MGbgCnfRgHQoqG9hyX4hc/yiho+/HNqVPFtdj2jwTpQgd/RKT7/0ZUku/o4qAJw50KYXbzr4e+3BioYzc3kwGzAUCLWFw2+UBiCb3bNTDHiPQeAP3CGNmg/6VcSBpDu3hhvDQpOCwABwrQKsRIsqYAChy/EQAXwlPiZ3a3igNPkXIyTjr8o5L7PPdzGf59c+sV/faeWeIIIAHPJ8M3kc7l1K09LKghWAIgqoPZ7djPFTlxb4D6yAgBOQfntrUVWVeRk44tplXJorOVGkVIJTBCTpw9ECFYBIEkyMfmsAXh3u1qi5MlxbbGX/x1ZSCjBAAwgfPr6h49R5UVavk0FXC2wju5p07s6iqEF0PtqSlEf+bKzDRwdgaDU7AkoySJ5Oo/D6ZRq/H0yyuJ/lxkSheG/FgCNm7kL0BoEAG32sqtY1YIHd2/mGzTMVgD3y2slqjgW9xY6ma9AThAARIMiBtOpjADwTWc0bEkB+FZsSTxTW0KBsGPXx0yvrUpEeHAAQIM7wBJLcu8DwHaP/H8i6VSND6SiSjBlRW4WWUypVIBbIgzjVgB0tpdKqLReS0KVPTMTfH0ra2cEgAmAAEd+l1z52LybqwBQYAQAyZPh6gtDW4jjuC4fHx8wXSm0JDbeI95SRYHiFZlUaWVtPQiKAugl5E8ASAUYiy8vc0C474uGasPE5PHc4g0wK/f4obom6UNimol7kTZwQLANwOuqBokqDEeQf4lfvvnNxZJcBTBAGZplWQcQ3tcgwY9oWlXinRW4ugoAgNAWWe6ocn1QvgyRTUb4RZFVljlY/3hSBYCqb6cCZo8ekuATVRZPI/gQW8FWAI1VHganVgDQUajdA6y2gAgAcZFBjTBSpG0AcAqc3VVmCMDTbxGWZvIRCaMNkJ7pFMCzVQD4liCQ8kRFUlvaBkCvL+wYw2ZmNUgCgLajFhRPJlv3ADuS1VtjPQCk823S574ffN/x1dQqy8dHR5SN2Spcbaymz2mjwNYDAD4IQn3TDpVLQIAqNjwAANRrAdoI/3sAOF7Xe1khCNQGVH1bL0JGJb1R52VtD8gVYHkAuVKpbMWZV0C2yObKunkF5EqlshVnXgHZIpsr6/4DlbxcPydnT74AAAAASUVORK5CYII="}],"meshes":[{"primitives":[{"mode":4,"attributes":{"POSITION":0,"NORMAL":1,"TEXCOORD_0":2},"indices":3,"material":0}]}]}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,24 +1,25 @@
<script setup> <script setup>
import ContextMenu from '@/components/ui/ContextMenu.vue'
import Instance from '@/components/ui/Instance.vue' import Instance from '@/components/ui/Instance.vue'
import { computed, ref } from 'vue' import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import { duplicate, remove } from '@/helpers/profile.js'
import { import {
ClipboardCopyIcon, ClipboardCopyIcon,
EyeIcon,
FolderOpenIcon, FolderOpenIcon,
PlayIcon, PlayIcon,
PlusIcon, PlusIcon,
TrashIcon,
StopCircleIcon,
EyeIcon,
SearchIcon, SearchIcon,
StopCircleIcon,
TrashIcon,
XIcon, XIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { Button, DropdownSelect } from '@modrinth/ui' import { Button, DropdownSelect, injectNotificationManager } from '@modrinth/ui'
import { formatCategoryHeader } from '@modrinth/utils' import { formatCategoryHeader } from '@modrinth/utils'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { duplicate, remove } from '@/helpers/profile.js' import { computed, ref } from 'vue'
import { handleError } from '@/store/notifications.js'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue' const { handleError } = injectNotificationManager()
const props = defineProps({ const props = defineProps({
instances: { instances: {
@@ -136,7 +137,7 @@ const filteredResults = computed(() => {
if (sortBy.value === 'Game version') { if (sortBy.value === 'Game version') {
instances.sort((a, b) => { instances.sort((a, b) => {
return a.game_version.localeCompare(b.game_version) return a.game_version.localeCompare(b.game_version, undefined, { numeric: true })
}) })
} }
@@ -213,6 +214,17 @@ const filteredResults = computed(() => {
instanceMap.set(entry[0], entry[1]) instanceMap.set(entry[0], entry[1])
}) })
} }
// default sorting would do 1.20.4 < 1.8.9 because 2 < 8
// localeCompare with numeric=true puts 1.8.9 < 1.20.4 because 8 < 20
if (group.value === 'Game version') {
const sortedEntries = [...instanceMap.entries()].sort((a, b) => {
return a[0].localeCompare(b[0], undefined, { numeric: true })
})
instanceMap.clear()
sortedEntries.forEach((entry) => {
instanceMap.set(entry[0], entry[1])
})
}
return instanceMap return instanceMap
}) })

View File

@@ -1,31 +1,32 @@
<script setup> <script setup>
import {
ClipboardCopyIcon,
FolderOpenIcon,
PlayIcon,
PlusIcon,
TrashIcon,
DownloadIcon,
GlobeIcon,
StopCircleIcon,
ExternalIcon,
EyeIcon,
} from '@modrinth/assets'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import Instance from '@/components/ui/Instance.vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import ContextMenu from '@/components/ui/ContextMenu.vue' import ContextMenu from '@/components/ui/ContextMenu.vue'
import Instance from '@/components/ui/Instance.vue'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import ProjectCard from '@/components/ui/ProjectCard.vue' import ProjectCard from '@/components/ui/ProjectCard.vue'
import { get_by_profile_path } from '@/helpers/process.js'
import { handleError } from '@/store/notifications.js'
import { duplicate, kill, remove, run } from '@/helpers/profile.js'
import { useRouter } from 'vue-router'
import { showProfileInFolder } from '@/helpers/utils.js'
import { trackEvent } from '@/helpers/analytics' import { trackEvent } from '@/helpers/analytics'
import { get_by_profile_path } from '@/helpers/process.js'
import { duplicate, kill, remove, run } from '@/helpers/profile.js'
import { showProfileInFolder } from '@/helpers/utils.js'
import { handleSevereError } from '@/store/error.js' import { handleSevereError } from '@/store/error.js'
import { install as installVersion } from '@/store/install.js' import { install as installVersion } from '@/store/install.js'
import {
ClipboardCopyIcon,
DownloadIcon,
ExternalIcon,
EyeIcon,
FolderOpenIcon,
GlobeIcon,
PlayIcon,
PlusIcon,
StopCircleIcon,
TrashIcon,
} from '@modrinth/assets'
import { HeadingLink, injectNotificationManager } from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener' import { openUrl } from '@tauri-apps/plugin-opener'
import { HeadingLink } from '@modrinth/ui' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
const { handleError } = injectNotificationManager()
const router = useRouter() const router = useRouter()

View File

@@ -73,22 +73,23 @@
</template> </template>
<script setup> <script setup>
import { DropdownIcon, PlusIcon, TrashIcon, LogInIcon, SpinnerIcon } from '@modrinth/assets' import { trackEvent } from '@/helpers/analytics'
import { Avatar, Button, Card } from '@modrinth/ui'
import { ref, computed, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
import { import {
users, get_default_user,
login as login_flow,
remove_user, remove_user,
set_default_user, set_default_user,
login as login_flow, users,
get_default_user,
} from '@/helpers/auth' } from '@/helpers/auth'
import { handleError } from '@/store/state.js'
import { trackEvent } from '@/helpers/analytics'
import { process_listener } from '@/helpers/events' import { process_listener } from '@/helpers/events'
import { handleSevereError } from '@/store/error.js'
import { get_available_skins } from '@/helpers/skins'
import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts' import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
import { get_available_skins } from '@/helpers/skins'
import { handleSevereError } from '@/store/error.js'
import { DropdownIcon, LogInIcon, PlusIcon, SpinnerIcon, TrashIcon } from '@modrinth/assets'
import { Avatar, Button, Card, injectNotificationManager } from '@modrinth/ui'
import { computed, onBeforeUnmount, onMounted, onUnmounted, ref } from 'vue'
const { handleError } = injectNotificationManager()
defineProps({ defineProps({
mode: { mode: {

View File

@@ -1,11 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { DropdownIcon, PlusIcon, FolderOpenIcon } from '@modrinth/assets'
import { ButtonStyled, OverflowMenu } from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { add_project_from_path } from '@/helpers/profile.js' import { add_project_from_path } from '@/helpers/profile.js'
import { handleError } from '@/store/notifications.js' import { DropdownIcon, FolderOpenIcon, PlusIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, OverflowMenu } from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
const { handleError } = injectNotificationManager()
const props = defineProps({ const props = defineProps({
instance: { instance: {
type: Object, type: Object,

View File

@@ -1,23 +1,24 @@
<script setup> <script setup>
import { ChatIcon } from '@/assets/icons'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
import { install } from '@/helpers/profile.js'
import { cancel_directory_change } from '@/helpers/settings.ts'
import { handleSevereError } from '@/store/error.js'
import { import {
CheckIcon, CheckIcon,
CopyIcon,
DropdownIcon, DropdownIcon,
XIcon,
HammerIcon, HammerIcon,
LogInIcon, LogInIcon,
UpdatedIcon, UpdatedIcon,
CopyIcon, XIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { ChatIcon } from '@/assets/icons' import { ButtonStyled, Collapsible, injectNotificationManager } from '@modrinth/ui'
import { ButtonStyled, Collapsible } from '@modrinth/ui' import { computed, ref } from 'vue'
import { ref, computed } from 'vue'
import { login as login_flow, set_default_user } from '@/helpers/auth.js' const { handleError } = injectNotificationManager()
import { handleError } from '@/store/notifications.js'
import { handleSevereError } from '@/store/error.js'
import { cancel_directory_change } from '@/helpers/settings.ts'
import { install } from '@/helpers/profile.js'
import { trackEvent } from '@/helpers/analytics'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
const errorModal = ref() const errorModal = ref()
const error = ref() const error = ref()

View File

@@ -1,12 +1,13 @@
<script setup> <script setup>
import { XIcon, PlusIcon } from '@modrinth/assets'
import { Button, Checkbox } from '@modrinth/ui'
import { PackageIcon, VersionIcon } from '@/assets/icons' import { PackageIcon, VersionIcon } from '@/assets/icons'
import { ref } from 'vue'
import { export_profile_mrpack, get_pack_export_candidates } from '@/helpers/profile.js'
import { open } from '@tauri-apps/plugin-dialog'
import { handleError } from '@/store/notifications.js'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { export_profile_mrpack, get_pack_export_candidates } from '@/helpers/profile.js'
import { PlusIcon, XIcon } from '@modrinth/assets'
import { Button, Checkbox, injectNotificationManager } from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { ref } from 'vue'
const { handleError } = injectNotificationManager()
const props = defineProps({ const props = defineProps({
instance: { instance: {

View File

@@ -1,6 +1,10 @@
<script setup> <script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue' import { trackEvent } from '@/helpers/analytics'
import { useRouter } from 'vue-router' import { process_listener } from '@/helpers/events'
import { get_by_profile_path } from '@/helpers/process'
import { finish_install, kill, run } from '@/helpers/profile'
import { showProfileInFolder } from '@/helpers/utils.js'
import { handleSevereError } from '@/store/error.js'
import { import {
DownloadIcon, DownloadIcon,
GameIcon, GameIcon,
@@ -9,17 +13,13 @@ import {
StopCircleIcon, StopCircleIcon,
TimerIcon, TimerIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { Avatar, ButtonStyled, useRelativeTime } from '@modrinth/ui' import { Avatar, ButtonStyled, injectNotificationManager, useRelativeTime } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core' import { convertFileSrc } from '@tauri-apps/api/core'
import { finish_install, kill, run } from '@/helpers/profile'
import { get_by_profile_path } from '@/helpers/process'
import { process_listener } from '@/helpers/events'
import { handleError } from '@/store/state.js'
import { showProfileInFolder } from '@/helpers/utils.js'
import { handleSevereError } from '@/store/error.js'
import { trackEvent } from '@/helpers/analytics'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
const { handleError } = injectNotificationManager()
const formatRelativeTime = useRelativeTime() const formatRelativeTime = useRelativeTime()
const props = defineProps({ const props = defineProps({

View File

@@ -198,6 +198,17 @@
<script setup> <script setup>
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import ProgressBar from '@/components/ui/ProgressBar.vue'
import { trackEvent } from '@/helpers/analytics'
import {
get_default_launcher_path,
get_importable_instances,
import_instance,
} from '@/helpers/import.js'
import { get_game_versions, get_loader_versions } from '@/helpers/metadata'
import { create_profile_and_install_from_file } from '@/helpers/pack.js'
import { create } from '@/helpers/profile'
import { get_loaders } from '@/helpers/tags'
import { import {
CodeIcon, CodeIcon,
FolderOpenIcon, FolderOpenIcon,
@@ -208,24 +219,14 @@ import {
UploadIcon, UploadIcon,
XIcon, XIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { Avatar, Button, Checkbox, Chips } from '@modrinth/ui' import { Avatar, Button, Checkbox, Chips, injectNotificationManager } from '@modrinth/ui'
import { computed, onUnmounted, ref, shallowRef } from 'vue'
import { get_loaders } from '@/helpers/tags'
import { create } from '@/helpers/profile'
import { open } from '@tauri-apps/plugin-dialog'
import { convertFileSrc } from '@tauri-apps/api/core' import { convertFileSrc } from '@tauri-apps/api/core'
import { get_game_versions, get_loader_versions } from '@/helpers/metadata'
import { handleError } from '@/store/notifications.js'
import Multiselect from 'vue-multiselect'
import { trackEvent } from '@/helpers/analytics'
import { create_profile_and_install_from_file } from '@/helpers/pack.js'
import {
get_default_launcher_path,
get_importable_instances,
import_instance,
} from '@/helpers/import.js'
import ProgressBar from '@/components/ui/ProgressBar.vue'
import { getCurrentWebview } from '@tauri-apps/api/webview' import { getCurrentWebview } from '@tauri-apps/api/webview'
import { open } from '@tauri-apps/plugin-dialog'
import { computed, onUnmounted, ref, shallowRef } from 'vue'
import Multiselect from 'vue-multiselect'
const { handleError } = injectNotificationManager()
const profile_name = ref('') const profile_name = ref('')
const game_version = ref('') const game_version = ref('')
@@ -305,12 +306,16 @@ const [
get_game_versions().then(shallowRef).catch(handleError), get_game_versions().then(shallowRef).catch(handleError),
get_loaders() get_loaders()
.then((value) => .then((value) =>
value ref(
.filter((item) => item.supported_project_types.includes('modpack')) value
.map((item) => item.name.toLowerCase()), .filter((item) => item.supported_project_types.includes('modpack'))
.map((item) => item.name.toLowerCase()),
),
) )
.then(ref) .catch((err) => {
.catch(handleError), handleError(err)
return ref([])
}),
]) ])
loaders.value.unshift('vanilla') loaders.value.unshift('vanilla')

View File

@@ -35,13 +35,14 @@
</ModalWrapper> </ModalWrapper>
</template> </template>
<script setup> <script setup>
import { PlusIcon, CheckIcon, XIcon } from '@modrinth/assets'
import { Button } from '@modrinth/ui'
import { ref } from 'vue'
import { find_filtered_jres } from '@/helpers/jre.js'
import { handleError } from '@/store/notifications.js'
import { trackEvent } from '@/helpers/analytics'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { find_filtered_jres } from '@/helpers/jre.js'
import { CheckIcon, PlusIcon, XIcon } from '@modrinth/assets'
import { Button, injectNotificationManager } from '@modrinth/ui'
import { ref } from 'vue'
const { handleError } = injectNotificationManager()
const chosenInstallOptions = ref([]) const chosenInstallOptions = ref([])
const detectJavaModal = ref(null) const detectJavaModal = ref(null)

View File

@@ -52,21 +52,22 @@
</template> </template>
<script setup> <script setup>
import {
SearchIcon,
PlayIcon,
CheckIcon,
XIcon,
FolderSearchIcon,
DownloadIcon,
} from '@modrinth/assets'
import { Button } from '@modrinth/ui'
import { auto_install_java, find_filtered_jres, get_jre, test_jre } from '@/helpers/jre.js'
import { ref } from 'vue'
import { open } from '@tauri-apps/plugin-dialog'
import JavaDetectionModal from '@/components/ui/JavaDetectionModal.vue' import JavaDetectionModal from '@/components/ui/JavaDetectionModal.vue'
import { handleError } from '@/store/state.js'
import { trackEvent } from '@/helpers/analytics' import { trackEvent } from '@/helpers/analytics'
import { auto_install_java, find_filtered_jres, get_jre, test_jre } from '@/helpers/jre.js'
import {
CheckIcon,
DownloadIcon,
FolderSearchIcon,
PlayIcon,
SearchIcon,
XIcon,
} from '@modrinth/assets'
import { Button, injectNotificationManager } from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { ref } from 'vue'
const { handleError } = injectNotificationManager()
const props = defineProps({ const props = defineProps({
version: { version: {
@@ -108,7 +109,6 @@ async function testJava() {
testingJava.value = true testingJava.value = true
testingJavaSuccess.value = await test_jre( testingJavaSuccess.value = await test_jre(
props.modelValue ? props.modelValue.path : '', props.modelValue ? props.modelValue.path : '',
1,
props.version, props.version,
) )
testingJava.value = false testingJava.value = false

View File

@@ -21,14 +21,11 @@ const props = defineProps({
}) })
const featuredCategory = computed(() => { const featuredCategory = computed(() => {
if (props.project.categories.includes('optimization')) { if (props.project.display_categories.includes('optimization')) {
return 'optimization' return 'optimization'
} }
if (props.project.categories.length > 0) { return props.project.display_categories[0] ?? props.project.categories[0]
return props.project.categories[0]
}
return undefined
}) })
const toColor = computed(() => { const toColor = computed(() => {

View File

@@ -1,13 +1,14 @@
<script setup> <script setup>
import NavButton from '@/components/ui/NavButton.vue'
import { profile_listener } from '@/helpers/events.js'
import { list } from '@/helpers/profile' import { list } from '@/helpers/profile'
import { handleError } from '@/store/notifications' import { SpinnerIcon } from '@modrinth/assets'
import { Avatar, injectNotificationManager } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { onUnmounted, ref } from 'vue' import { onUnmounted, ref } from 'vue'
import { profile_listener } from '@/helpers/events.js'
import NavButton from '@/components/ui/NavButton.vue' const { handleError } = injectNotificationManager()
import { Avatar } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { SpinnerIcon } from '@modrinth/assets'
const recentInstances = ref([]) const recentInstances = ref([])
const getInstances = async () => { const getInstances = async () => {

View File

@@ -94,23 +94,24 @@
</template> </template>
<script setup> <script setup>
import ProgressBar from '@/components/ui/ProgressBar.vue'
import { trackEvent } from '@/helpers/analytics'
import { loading_listener, process_listener } from '@/helpers/events'
import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/process'
import { get_many } from '@/helpers/profile.js'
import { progress_bars_list } from '@/helpers/state.js'
import { import {
DownloadIcon, DownloadIcon,
DropdownIcon,
StopCircleIcon, StopCircleIcon,
TerminalSquareIcon, TerminalSquareIcon,
DropdownIcon,
UnplugIcon, UnplugIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { Button, ButtonStyled, Card } from '@modrinth/ui' import { Button, ButtonStyled, Card, injectNotificationManager } from '@modrinth/ui'
import { onBeforeUnmount, onMounted, ref } from 'vue' import { onBeforeUnmount, onMounted, ref } from 'vue'
import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/process'
import { loading_listener, process_listener } from '@/helpers/events'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { progress_bars_list } from '@/helpers/state.js'
import ProgressBar from '@/components/ui/ProgressBar.vue' const { handleError } = injectNotificationManager()
import { handleError } from '@/store/notifications.js'
import { get_many } from '@/helpers/profile.js'
import { trackEvent } from '@/helpers/analytics'
const router = useRouter() const router = useRouter()
const card = ref(null) const card = ref(null)

View File

@@ -1,12 +1,13 @@
<script setup> <script setup>
import { Button } from '@modrinth/ui'
import { ref } from 'vue'
import SearchCard from '@/components/ui/SearchCard.vue'
import { get_categories } from '@/helpers/tags.js'
import { handleError } from '@/store/notifications.js'
import { get_version, get_project } from '@/helpers/cache.js'
import { install as installVersion } from '@/store/install.js'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import SearchCard from '@/components/ui/SearchCard.vue'
import { get_project, get_version } from '@/helpers/cache.js'
import { get_categories } from '@/helpers/tags.js'
import { install as installVersion } from '@/store/install.js'
import { Button, injectNotificationManager } from '@modrinth/ui'
import { ref } from 'vue'
const { handleError } = injectNotificationManager()
const confirmModal = ref(null) const confirmModal = ref(null)
const project = ref(null) const project = ref(null)

View File

@@ -1,23 +1,29 @@
<script setup lang="ts"> <script setup lang="ts">
import { Avatar, ButtonStyled, OverflowMenu, useRelativeTime } from '@modrinth/ui' import ContextMenu from '@/components/ui/ContextMenu.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { get_user_many } from '@/helpers/cache'
import { friend_listener } from '@/helpers/events'
import { add_friend, friend_statuses, friends, remove_friend } from '@/helpers/friends'
import { import {
UserPlusIcon,
MoreVerticalIcon,
MailIcon, MailIcon,
MoreVerticalIcon,
SettingsIcon, SettingsIcon,
TrashIcon, TrashIcon,
UserPlusIcon,
XIcon, XIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { ref, onUnmounted, watch, computed } from 'vue' import {
import { friend_listener } from '@/helpers/events' Avatar,
import { friends, friend_statuses, add_friend, remove_friend } from '@/helpers/friends' ButtonStyled,
import { get_user_many } from '@/helpers/cache' injectNotificationManager,
import { handleError } from '@/store/notifications.js' OverflowMenu,
import ContextMenu from '@/components/ui/ContextMenu.vue' useRelativeTime,
} from '@modrinth/ui'
import type { Dayjs } from 'dayjs' import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import { computed, onUnmounted, ref, watch } from 'vue'
const { handleError } = injectNotificationManager()
const formatRelativeTime = useRelativeTime() const formatRelativeTime = useRelativeTime()
const props = defineProps<{ const props = defineProps<{

View File

@@ -57,15 +57,16 @@
<script setup> <script setup>
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { XIcon, DownloadIcon } from '@modrinth/assets'
import { Button } from '@modrinth/ui'
import { formatCategory } from '@modrinth/utils'
import { add_project_from_version as installMod } from '@/helpers/profile'
import { ref } from 'vue'
import { handleError } from '@/store/state.js'
import { trackEvent } from '@/helpers/analytics' import { trackEvent } from '@/helpers/analytics'
import { add_project_from_version as installMod } from '@/helpers/profile'
import { DownloadIcon, XIcon } from '@modrinth/assets'
import { Button, injectNotificationManager } from '@modrinth/ui'
import { formatCategory } from '@modrinth/utils'
import { ref } from 'vue'
import Multiselect from 'vue-multiselect' import Multiselect from 'vue-multiselect'
const { handleError } = injectNotificationManager()
const instance = ref(null) const instance = ref(null)
const project = ref(null) const project = ref(null)
const versions = ref(null) const versions = ref(null)

View File

@@ -1,11 +1,12 @@
<script setup> <script setup>
import { DownloadIcon, XIcon } from '@modrinth/assets'
import { Button } from '@modrinth/ui'
import { create_profile_and_install as pack_install } from '@/helpers/pack'
import { ref } from 'vue'
import { trackEvent } from '@/helpers/analytics'
import { handleError } from '@/store/state.js'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { create_profile_and_install as pack_install } from '@/helpers/pack'
import { DownloadIcon, XIcon } from '@modrinth/assets'
import { Button, injectNotificationManager } from '@modrinth/ui'
import { ref } from 'vue'
const { handleError } = injectNotificationManager()
const versionId = ref() const versionId = ref()
const project = ref() const project = ref()

View File

@@ -1,29 +1,29 @@
<script setup> <script setup>
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { import {
check_installed,
create,
get,
add_project_from_version as installMod,
list,
} from '@/helpers/profile'
import { installVersionDependencies } from '@/store/install.js'
import {
CheckIcon,
DownloadIcon, DownloadIcon,
PlusIcon, PlusIcon,
RightArrowIcon,
UploadIcon, UploadIcon,
XIcon, XIcon,
RightArrowIcon,
CheckIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { Avatar, Button, Card } from '@modrinth/ui' import { Avatar, Button, Card, injectNotificationManager } from '@modrinth/ui'
import { computed, ref } from 'vue'
import {
add_project_from_version as installMod,
check_installed,
get,
list,
create,
} from '@/helpers/profile'
import { open } from '@tauri-apps/plugin-dialog'
import { installVersionDependencies } from '@/store/install.js'
import { handleError } from '@/store/notifications.js'
import { useRouter } from 'vue-router'
import { convertFileSrc } from '@tauri-apps/api/core' import { convertFileSrc } from '@tauri-apps/api/core'
import { trackEvent } from '@/helpers/analytics' import { open } from '@tauri-apps/plugin-dialog'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
const { handleError } = injectNotificationManager()
const router = useRouter() const router = useRouter()
const versions = ref() const versions = ref()

View File

@@ -1,17 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import { convertFileSrc } from '@tauri-apps/api/core' import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import { SpinnerIcon, TrashIcon, UploadIcon, PlusIcon, EditIcon, CopyIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled, OverflowMenu, Checkbox } from '@modrinth/ui'
import { computed, ref, type Ref, watch } from 'vue'
import { duplicate, edit, edit_icon, list, remove } from '@/helpers/profile'
import { handleError } from '@/store/notifications'
import { trackEvent } from '@/helpers/analytics' import { trackEvent } from '@/helpers/analytics'
import { duplicate, edit, edit_icon, list, remove } from '@/helpers/profile'
import { CopyIcon, EditIcon, PlusIcon, SpinnerIcon, TrashIcon, UploadIcon } from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
Checkbox,
injectNotificationManager,
OverflowMenu,
} from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { open } from '@tauri-apps/plugin-dialog' import { open } from '@tauri-apps/plugin-dialog'
import { defineMessages, useVIntl } from '@vintl/vintl' import { defineMessages, useVIntl } from '@vintl/vintl'
import { computed, ref, type Ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue' import type { GameInstance, InstanceSettingsTabProps } from '../../../helpers/types'
import type { InstanceSettingsTabProps, GameInstance } from '../../../helpers/types'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const router = useRouter() const router = useRouter()

View File

@@ -1,12 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { Checkbox } from '@modrinth/ui'
import { computed, ref, watch } from 'vue'
import { handleError } from '@/store/notifications'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { get } from '@/helpers/settings.ts'
import { edit } from '@/helpers/profile' import { edit } from '@/helpers/profile'
import type { InstanceSettingsTabProps, AppSettings, Hooks } from '../../../helpers/types' import { get } from '@/helpers/settings.ts'
import { Checkbox, injectNotificationManager } from '@modrinth/ui'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { computed, ref, watch } from 'vue'
import type { AppSettings, Hooks, InstanceSettingsTabProps } from '../../../helpers/types'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const props = defineProps<InstanceSettingsTabProps>() const props = defineProps<InstanceSettingsTabProps>()

View File

@@ -1,23 +1,30 @@
<script setup lang="ts"> <script setup lang="ts">
import { import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
TransferIcon, import ModpackVersionModal from '@/components/ui/ModpackVersionModal.vue'
IssuesIcon,
HammerIcon,
DownloadIcon,
WrenchIcon,
UndoIcon,
SpinnerIcon,
UnplugIcon,
UnlinkIcon,
} from '@modrinth/assets'
import { Avatar, Checkbox, Chips, ButtonStyled, TeleportDropdownMenu } from '@modrinth/ui'
import { computed, type ComputedRef, type Ref, ref, shallowRef, watch } from 'vue'
import { edit, install, update_repair_modrinth } from '@/helpers/profile'
import { handleError } from '@/store/notifications'
import { trackEvent } from '@/helpers/analytics' import { trackEvent } from '@/helpers/analytics'
import { defineMessages, useVIntl } from '@vintl/vintl' import { get_project, get_version_many } from '@/helpers/cache'
import { get_loader_versions } from '@/helpers/metadata' import { get_loader_versions } from '@/helpers/metadata'
import { edit, install, update_repair_modrinth } from '@/helpers/profile'
import { get_game_versions, get_loaders } from '@/helpers/tags' import { get_game_versions, get_loaders } from '@/helpers/tags'
import {
DownloadIcon,
HammerIcon,
IssuesIcon,
SpinnerIcon,
TransferIcon,
UndoIcon,
UnlinkIcon,
UnplugIcon,
WrenchIcon,
} from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
Checkbox,
Chips,
TeleportDropdownMenu,
injectNotificationManager,
} from '@modrinth/ui'
import { import {
formatCategory, formatCategory,
type GameVersionTag, type GameVersionTag,
@@ -25,16 +32,16 @@ import {
type Project, type Project,
type Version, type Version,
} from '@modrinth/utils' } from '@modrinth/utils'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue' import { defineMessages, useVIntl } from '@vintl/vintl'
import { get_project, get_version_many } from '@/helpers/cache'
import ModpackVersionModal from '@/components/ui/ModpackVersionModal.vue'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { computed, ref, shallowRef, watch, type ComputedRef, type Ref } from 'vue'
import type { import type {
InstanceSettingsTabProps, InstanceSettingsTabProps,
ManifestLoaderVersion,
Manifest, Manifest,
ManifestLoaderVersion,
} from '../../../helpers/types' } from '../../../helpers/types'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const repairConfirmModal = ref() const repairConfirmModal = ref()

View File

@@ -1,15 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { Checkbox, Slider } from '@modrinth/ui'
import { CheckCircleIcon, XCircleIcon } from '@modrinth/assets'
import { computed, readonly, ref, watch } from 'vue'
import { edit, get_optimal_jre_key } from '@/helpers/profile'
import { handleError } from '@/store/notifications'
import { defineMessages, useVIntl } from '@vintl/vintl'
import JavaSelector from '@/components/ui/JavaSelector.vue' import JavaSelector from '@/components/ui/JavaSelector.vue'
import { get_max_memory } from '@/helpers/jre' import useMemorySlider from '@/composables/useMemorySlider'
import { edit, get_optimal_jre_key } from '@/helpers/profile'
import { get } from '@/helpers/settings.ts' import { get } from '@/helpers/settings.ts'
import type { InstanceSettingsTabProps, AppSettings, MemorySettings } from '../../../helpers/types' import { CheckCircleIcon, XCircleIcon } from '@modrinth/assets'
import { Checkbox, injectNotificationManager, Slider } from '@modrinth/ui'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { computed, readonly, ref, watch } from 'vue'
import type { AppSettings, InstanceSettingsTabProps, MemorySettings } from '../../../helpers/types'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const props = defineProps<InstanceSettingsTabProps>() const props = defineProps<InstanceSettingsTabProps>()
@@ -34,7 +34,7 @@ const envVars = ref(
const overrideMemorySettings = ref(!!props.instance.memory) const overrideMemorySettings = ref(!!props.instance.memory)
const memory = ref(props.instance.memory ?? globalSettings.memory) const memory = ref(props.instance.memory ?? globalSettings.memory)
const maxMemory = Math.floor((await get_max_memory().catch(handleError)) / 1024) const { maxMemory, snapPoints } = await useMemorySlider()
const editProfileObject = computed(() => { const editProfileObject = computed(() => {
const editProfile: { const editProfile: {
@@ -156,6 +156,8 @@ const messages = defineMessages({
:min="512" :min="512"
:max="maxMemory" :max="maxMemory"
:step="64" :step="64"
:snap-points="snapPoints"
:snap-range="512"
unit="MB" unit="MB"
/> />
<h2 id="project-name" class="mt-4 mb-1 text-lg font-extrabold text-contrast block"> <h2 id="project-name" class="mt-4 mb-1 text-lg font-extrabold text-contrast block">

View File

@@ -1,12 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { Checkbox, Toggle } from '@modrinth/ui'
import { computed, ref, type Ref, watch } from 'vue'
import { handleError } from '@/store/notifications'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { get } from '@/helpers/settings.ts'
import { edit } from '@/helpers/profile' import { edit } from '@/helpers/profile'
import { get } from '@/helpers/settings.ts'
import { Checkbox, injectNotificationManager, Toggle } from '@modrinth/ui'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { computed, ref, type Ref, watch } from 'vue'
import type { AppSettings, InstanceSettingsTabProps } from '../../../helpers/types' import type { AppSettings, InstanceSettingsTabProps } from '../../../helpers/types'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const props = defineProps<InstanceSettingsTabProps>() const props = defineProps<InstanceSettingsTabProps>()

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
import { LogInIcon, SpinnerIcon } from '@modrinth/assets'
import { ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
defineProps({
onFlowCancel: {
type: Function,
default() {
return async () => {}
},
},
})
const modal = ref()
function show() {
modal.value.show()
}
function hide() {
modal.value.hide()
}
defineExpose({ show, hide })
</script>
<template>
<ModalWrapper ref="modal" @hide="onFlowCancel">
<template #title>
<span class="items-center gap-2 text-lg font-extrabold text-contrast">
<LogInIcon /> Sign in
</span>
</template>
<div class="flex justify-center gap-2">
<SpinnerIcon class="w-12 h-12 animate-spin" />
</div>
<p class="text-sm text-secondary">
Please sign in at the browser window that just opened to continue.
</p>
</ModalWrapper>
</template>

View File

@@ -59,7 +59,7 @@ watch(
<div class="mt-4 flex items-center justify-between"> <div class="mt-4 flex items-center justify-between">
<div> <div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Hide nametag</h2> <h2 class="m-0 text-lg font-extrabold text-contrast">Hide nametag</h2>
<p class="m-0 mt-1">Disables the nametag above your player on the skins page. page.</p> <p class="m-0 mt-1">Disables the nametag above your player on the skins page.</p>
</div> </div>
<Toggle id="hide-nametag-skins-page" v-model="settings.hide_nametag_skins_page" /> <Toggle id="hide-nametag-skins-page" v-model="settings.hide_nametag_skins_page" />
</div> </div>

View File

@@ -1,9 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { get, set } from '@/helpers/settings.ts' import { get, set } from '@/helpers/settings.ts'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { get_max_memory } from '@/helpers/jre'
import { handleError } from '@/store/notifications'
import { Slider, Toggle } from '@modrinth/ui' import { Slider, Toggle } from '@modrinth/ui'
import useMemorySlider from '@/composables/useMemorySlider'
const fetchSettings = await get() const fetchSettings = await get()
fetchSettings.launchArgs = fetchSettings.extra_launch_args.join(' ') fetchSettings.launchArgs = fetchSettings.extra_launch_args.join(' ')
@@ -11,7 +10,7 @@ fetchSettings.envVars = fetchSettings.custom_env_vars.map((x) => x.join('=')).jo
const settings = ref(fetchSettings) const settings = ref(fetchSettings)
const maxMemory = ref(Math.floor((await get_max_memory().catch(handleError)) / 1024)) const { maxMemory, snapPoints } = await useMemorySlider()
watch( watch(
settings, settings,
@@ -107,6 +106,8 @@ watch(
:min="512" :min="512"
:max="maxMemory" :max="maxMemory"
:step="64" :step="64"
:snap-points="snapPoints"
:snap-range="512"
unit="MB" unit="MB"
/> />

View File

@@ -1,8 +1,10 @@
<script setup> <script setup>
import { ref } from 'vue'
import { get_java_versions, set_java_version } from '@/helpers/jre'
import { handleError } from '@/store/notifications'
import JavaSelector from '@/components/ui/JavaSelector.vue' import JavaSelector from '@/components/ui/JavaSelector.vue'
import { get_java_versions, set_java_version } from '@/helpers/jre'
import { injectNotificationManager } from '@modrinth/ui'
import { ref } from 'vue'
const { handleError } = injectNotificationManager()
const javaVersions = ref(await get_java_versions().catch(handleError)) const javaVersions = ref(await get_java_versions().catch(handleError))
async function updateJavaVersion(version) { async function updateJavaVersion(version) {

View File

@@ -1,13 +1,13 @@
<script setup> <script setup>
import { Button, Slider } from '@modrinth/ui'
import { ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.ts'
import { purge_cache_types } from '@/helpers/cache.js'
import { handleError } from '@/store/notifications.js'
import { BoxIcon, FolderSearchIcon, TrashIcon } from '@modrinth/assets'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue' import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import { purge_cache_types } from '@/helpers/cache.js'
import { get, set } from '@/helpers/settings.ts'
import { BoxIcon, FolderSearchIcon, TrashIcon } from '@modrinth/assets'
import { Button, Slider, injectNotificationManager } from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog' import { open } from '@tauri-apps/plugin-dialog'
import { ref, watch } from 'vue'
const { handleError } = injectNotificationManager()
const settings = ref(await get()) const settings = ref(await get())
watch( watch(

View File

@@ -1,8 +1,3 @@
<script lang="ts">
import capeModelUrl from '@/assets/models/cape.gltf?url'
import wideModelUrl from '@/assets/models/classic_player.gltf?url'
import slimModelUrl from '@/assets/models/slim_player.gltf?url'
</script>
<template> <template>
<UploadSkinModal ref="uploadModal" /> <UploadSkinModal ref="uploadModal" />
<ModalWrapper ref="modal" @on-hide="resetState"> <ModalWrapper ref="modal" @on-hide="resetState">
@@ -16,9 +11,6 @@ import slimModelUrl from '@/assets/models/slim_player.gltf?url'
<div class="max-h-[25rem] w-[16rem] min-w-[16rem] overflow-hidden relative"> <div class="max-h-[25rem] w-[16rem] min-w-[16rem] overflow-hidden relative">
<div class="absolute top-[-4rem] left-0 h-[32rem] w-[16rem] flex-shrink-0"> <div class="absolute top-[-4rem] left-0 h-[32rem] w-[16rem] flex-shrink-0">
<SkinPreviewRenderer <SkinPreviewRenderer
:slim-model-src="slimModelUrl"
:wide-model-src="wideModelUrl"
:cape-model-src="capeModelUrl"
:variant="variant" :variant="variant"
:texture-src="previewSkin || ''" :texture-src="previewSkin || ''"
:cape-src="selectedCapeTexture" :cape-src="selectedCapeTexture"
@@ -108,36 +100,39 @@ import slimModelUrl from '@/assets/models/slim_player.gltf?url'
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, useTemplateRef } from 'vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import SelectCapeModal from '@/components/ui/skin/SelectCapeModal.vue' import SelectCapeModal from '@/components/ui/skin/SelectCapeModal.vue'
import { import UploadSkinModal from '@/components/ui/skin/UploadSkinModal.vue'
SkinPreviewRenderer,
Button,
RadioButtons,
CapeButton,
CapeLikeTextButton,
ButtonStyled,
} from '@modrinth/ui'
import { import {
add_and_equip_custom_skin, add_and_equip_custom_skin,
determineModelType,
get_normalized_skin_texture,
remove_custom_skin, remove_custom_skin,
unequip_skin, unequip_skin,
type Skin,
type Cape, type Cape,
type Skin,
type SkinModel, type SkinModel,
get_normalized_skin_texture,
} from '@/helpers/skins.ts' } from '@/helpers/skins.ts'
import { handleError } from '@/store/notifications'
import { import {
UploadIcon,
CheckIcon, CheckIcon,
SaveIcon,
XIcon,
ChevronRightIcon, ChevronRightIcon,
SaveIcon,
SpinnerIcon, SpinnerIcon,
UploadIcon,
XIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import {
import UploadSkinModal from '@/components/ui/skin/UploadSkinModal.vue' Button,
ButtonStyled,
CapeButton,
CapeLikeTextButton,
injectNotificationManager,
RadioButtons,
SkinPreviewRenderer,
} from '@modrinth/ui'
import { computed, ref, useTemplateRef, watch } from 'vue'
const { handleError } = injectNotificationManager()
const modal = useTemplateRef('modal') const modal = useTemplateRef('modal')
const selectCapeModal = useTemplateRef('selectCapeModal') const selectCapeModal = useTemplateRef('selectCapeModal')
@@ -261,7 +256,7 @@ async function showNew(e: MouseEvent, skinTextureUrl: string) {
mode.value = 'new' mode.value = 'new'
currentSkin.value = null currentSkin.value = null
uploadedTextureUrl.value = skinTextureUrl uploadedTextureUrl.value = skinTextureUrl
variant.value = 'CLASSIC' variant.value = await determineModelType(skinTextureUrl)
selectedCape.value = undefined selectedCape.value = undefined
visibleCapeList.value = [] visibleCapeList.value = []
initVisibleCapeList() initVisibleCapeList()

View File

@@ -10,9 +10,6 @@ import {
} from '@modrinth/ui' } from '@modrinth/ui'
import { CheckIcon, XIcon } from '@modrinth/assets' import { CheckIcon, XIcon } from '@modrinth/assets'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import capeModelUrl from '@/assets/models/cape.gltf?url'
import wideModelUrl from '@/assets/models/classic_player.gltf?url'
import slimModelUrl from '@/assets/models/slim_player.gltf?url'
const modal = useTemplateRef('modal') const modal = useTemplateRef('modal')
@@ -88,9 +85,6 @@ defineExpose({
<div class="absolute top-[-4rem] left-0 h-[32rem] w-[16rem] flex-shrink-0"> <div class="absolute top-[-4rem] left-0 h-[32rem] w-[16rem] flex-shrink-0">
<SkinPreviewRenderer <SkinPreviewRenderer
v-if="currentSkinTexture" v-if="currentSkinTexture"
:slim-model-src="slimModelUrl"
:wide-model-src="wideModelUrl"
:cape-model-src="capeModelUrl"
:cape-src="currentCapeTexture" :cape-src="currentCapeTexture"
:texture-src="currentSkinTexture" :texture-src="currentSkinTexture"
:variant="currentSkinVariant" :variant="currentSkinVariant"

View File

@@ -27,14 +27,14 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onBeforeUnmount, watch } from 'vue'
import { UploadIcon } from '@modrinth/assets'
import { useNotifications } from '@/store/state'
import { getCurrentWebview } from '@tauri-apps/api/webview'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { get_dragged_skin_data } from '@/helpers/skins' import { get_dragged_skin_data } from '@/helpers/skins'
import { UploadIcon } from '@modrinth/assets'
import { injectNotificationManager } from '@modrinth/ui'
import { getCurrentWebview } from '@tauri-apps/api/webview'
import { onBeforeUnmount, ref, watch } from 'vue'
const notifications = useNotifications() const { addNotification } = injectNotificationManager()
const modal = ref() const modal = ref()
const fileInput = ref<HTMLInputElement>() const fileInput = ref<HTMLInputElement>()
@@ -99,7 +99,7 @@ async function setupDragDropListener() {
const data = await get_dragged_skin_data(filePath) const data = await get_dragged_skin_data(filePath)
await processData(data.buffer) await processData(data.buffer)
} catch (error) { } catch (error) {
notifications.addNotification({ addNotification({
title: 'Error processing file', title: 'Error processing file',
text: error instanceof Error ? error.message : 'Failed to read the dropped file.', text: error instanceof Error ? error.message : 'Failed to read the dropped file.',
type: 'error', type: 'error',

View File

@@ -1,6 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Dayjs } from 'dayjs' import { trackEvent } from '@/helpers/analytics'
import dayjs from 'dayjs' import { get_project } from '@/helpers/cache'
import { process_listener } from '@/helpers/events'
import { get_by_profile_path } from '@/helpers/process'
import { kill, run } from '@/helpers/profile'
import type { GameInstance } from '@/helpers/types'
import { showProfileInFolder } from '@/helpers/utils'
import { handleSevereError } from '@/store/error'
import { import {
EyeIcon, EyeIcon,
FolderOpenIcon, FolderOpenIcon,
@@ -13,25 +19,20 @@ import {
Avatar, Avatar,
ButtonStyled, ButtonStyled,
commonMessages, commonMessages,
injectNotificationManager,
OverflowMenu, OverflowMenu,
SmartClickable, SmartClickable,
useRelativeTime, useRelativeTime,
} from '@modrinth/ui' } from '@modrinth/ui'
import { useVIntl } from '@vintl/vintl'
import { computed, nextTick, ref, onMounted, onUnmounted } from 'vue'
import { showProfileInFolder } from '@/helpers/utils'
import { convertFileSrc } from '@tauri-apps/api/core'
import { useRouter } from 'vue-router'
import type { GameInstance } from '@/helpers/types'
import { get_project } from '@/helpers/cache'
import { capitalizeString } from '@modrinth/utils' import { capitalizeString } from '@modrinth/utils'
import { kill, run } from '@/helpers/profile' import { convertFileSrc } from '@tauri-apps/api/core'
import { handleSevereError } from '@/store/error' import { useVIntl } from '@vintl/vintl'
import { trackEvent } from '@/helpers/analytics' import type { Dayjs } from 'dayjs'
import { get_by_profile_path } from '@/helpers/process' import dayjs from 'dayjs'
import { handleError } from '@/store/notifications' import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { process_listener } from '@/helpers/events' import { useRouter } from 'vue-router'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime() const formatRelativeTime = useRelativeTime()

View File

@@ -1,29 +1,31 @@
<script setup lang="ts"> <script setup lang="ts">
import InstanceItem from '@/components/ui/world/InstanceItem.vue'
import WorldItem from '@/components/ui/world/WorldItem.vue'
import { trackEvent } from '@/helpers/analytics'
import { process_listener, profile_listener } from '@/helpers/events'
import { get_all } from '@/helpers/process'
import { kill, run } from '@/helpers/profile'
import type { GameInstance } from '@/helpers/types'
import { import {
type ServerWorld, type ProtocolVersion,
type ServerData, type ServerData,
type ServerWorld,
type WorldWithProfile, type WorldWithProfile,
get_recent_worlds,
getWorldIdentifier, getWorldIdentifier,
get_profile_protocol_version, get_profile_protocol_version,
get_recent_worlds,
refreshServerData, refreshServerData,
start_join_server, start_join_server,
start_join_singleplayer_world, start_join_singleplayer_world,
} from '@/helpers/worlds.ts' } from '@/helpers/worlds.ts'
import { HeadingLink, GAME_MODES } from '@modrinth/ui' import { handleSevereError } from '@/store/error'
import WorldItem from '@/components/ui/world/WorldItem.vue' import { useTheming } from '@/store/theme.ts'
import InstanceItem from '@/components/ui/world/InstanceItem.vue' import { GAME_MODES, HeadingLink, injectNotificationManager } from '@modrinth/ui'
import { watch, onMounted, onUnmounted, ref, computed } from 'vue'
import type { Dayjs } from 'dayjs' import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { useTheming } from '@/store/theme.ts' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { kill, run } from '@/helpers/profile'
import { handleError } from '@/store/notifications' const { handleError } = injectNotificationManager()
import { trackEvent } from '@/helpers/analytics'
import { process_listener, profile_listener } from '@/helpers/events'
import { get_all } from '@/helpers/process'
import type { GameInstance } from '@/helpers/types'
import { handleSevereError } from '@/store/error'
const props = defineProps<{ const props = defineProps<{
recentInstances: GameInstance[] recentInstances: GameInstance[]
@@ -33,7 +35,7 @@ const theme = useTheming()
const jumpBackInItems = ref<JumpBackInItem[]>([]) const jumpBackInItems = ref<JumpBackInItem[]>([])
const serverData = ref<Record<string, ServerData>>({}) const serverData = ref<Record<string, ServerData>>({})
const protocolVersions = ref<Record<string, number | null>>({}) const protocolVersions = ref<Record<string, ProtocolVersion | null>>({})
const MIN_JUMP_BACK_IN = 3 const MIN_JUMP_BACK_IN = 3
const MAX_JUMP_BACK_IN = 6 const MAX_JUMP_BACK_IN = 6
@@ -121,11 +123,8 @@ async function populateJumpBackIn() {
} }
}) })
// fetch each server's data servers.forEach(({ instancePath, address }) =>
Promise.all( refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address),
servers.map(({ instancePath, address }) =>
refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address),
),
) )
} }
@@ -150,8 +149,8 @@ async function populateJumpBackIn() {
.slice(0, MAX_JUMP_BACK_IN) .slice(0, MAX_JUMP_BACK_IN)
} }
async function refreshServer(address: string, instancePath: string) { function refreshServer(address: string, instancePath: string) {
await refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address) refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address)
} }
async function joinWorld(world: WorldWithProfile) { async function joinWorld(world: WorldWithProfile) {

View File

@@ -1,6 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import dayjs from 'dayjs' import dayjs from 'dayjs'
import type { ServerStatus, ServerWorld, SingleplayerWorld, World } from '@/helpers/worlds.ts' import type {
ProtocolVersion,
ServerStatus,
ServerWorld,
SingleplayerWorld,
World,
} from '@/helpers/worlds.ts'
import { set_world_display_status, getWorldIdentifier } from '@/helpers/worlds.ts' import { set_world_display_status, getWorldIdentifier } from '@/helpers/worlds.ts'
import { formatNumber, getPingLevel } from '@modrinth/utils' import { formatNumber, getPingLevel } from '@modrinth/utils'
import { import {
@@ -54,8 +60,9 @@ const props = withDefaults(
playingInstance?: boolean playingInstance?: boolean
playingWorld?: boolean playingWorld?: boolean
startingInstance?: boolean startingInstance?: boolean
supportsQuickPlay?: boolean supportsServerQuickPlay?: boolean
currentProtocol?: number | null supportsWorldQuickPlay?: boolean
currentProtocol?: ProtocolVersion | null
highlighted?: boolean highlighted?: boolean
// Server only // Server only
@@ -78,7 +85,8 @@ const props = withDefaults(
playingInstance: false, playingInstance: false,
playingWorld: false, playingWorld: false,
startingInstance: false, startingInstance: false,
supportsQuickPlay: false, supportsServerQuickPlay: true,
supportsWorldQuickPlay: false,
currentProtocol: null, currentProtocol: null,
refreshing: false, refreshing: false,
@@ -102,7 +110,8 @@ const serverIncompatible = computed(
!!props.serverStatus && !!props.serverStatus &&
!!props.serverStatus.version?.protocol && !!props.serverStatus.version?.protocol &&
!!props.currentProtocol && !!props.currentProtocol &&
props.serverStatus.version.protocol !== props.currentProtocol, (props.serverStatus.version.protocol !== props.currentProtocol.version ||
props.serverStatus.version.legacy !== props.currentProtocol.legacy),
) )
const locked = computed(() => props.world.type === 'singleplayer' && props.world.locked) const locked = computed(() => props.world.type === 'singleplayer' && props.world.locked)
@@ -120,14 +129,26 @@ const messages = defineMessages({
id: 'instance.worlds.a_minecraft_server', id: 'instance.worlds.a_minecraft_server',
defaultMessage: 'A Minecraft Server', defaultMessage: 'A Minecraft Server',
}, },
noQuickPlay: { noServerQuickPlay: {
id: 'instance.worlds.no_quick_play', id: 'instance.worlds.no_server_quick_play',
defaultMessage: 'You can only jump straight into worlds on Minecraft 1.20+', defaultMessage: 'You can only jump straight into servers on Minecraft Alpha 1.0.5+',
},
noSingleplayerQuickPlay: {
id: 'instance.worlds.no_singleplayer_quick_play',
defaultMessage: 'You can only jump straight into singleplayer worlds on Minecraft 1.20+',
}, },
gameAlreadyOpen: { gameAlreadyOpen: {
id: 'instance.worlds.game_already_open', id: 'instance.worlds.game_already_open',
defaultMessage: 'Instance is already open', defaultMessage: 'Instance is already open',
}, },
noContact: {
id: 'instance.worlds.no_contact',
defaultMessage: "Server couldn't be contacted",
},
incompatibleServer: {
id: 'instance.worlds.incompatible_server',
defaultMessage: 'Server is incompatible',
},
copyAddress: { copyAddress: {
id: 'instance.worlds.copy_address', id: 'instance.worlds.copy_address',
defaultMessage: 'Copy address', defaultMessage: 'Copy address',
@@ -136,10 +157,6 @@ const messages = defineMessages({
id: 'instance.worlds.view_instance', id: 'instance.worlds.view_instance',
defaultMessage: 'View instance', defaultMessage: 'View instance',
}, },
playAnyway: {
id: 'instance.worlds.play_anyway',
defaultMessage: 'Play anyway',
},
playInstance: { playInstance: {
id: 'instance.worlds.play_instance', id: 'instance.worlds.play_instance',
defaultMessage: 'Play instance', defaultMessage: 'Play instance',
@@ -302,39 +319,40 @@ const messages = defineMessages({
</template> </template>
</div> </div>
<div class="flex gap-1 justify-end smart-clickable:allow-pointer-events"> <div class="flex gap-1 justify-end smart-clickable:allow-pointer-events">
<template v-if="world.type === 'singleplayer' || serverStatus"> <ButtonStyled
<ButtonStyled v-if="(playingWorld || (locked && playingInstance)) && !startingInstance"
v-if="(playingWorld || (locked && playingInstance)) && !startingInstance" color="red"
color="red" >
> <button @click="emit('stop')">
<button @click="emit('stop')"> <StopCircleIcon aria-hidden="true" />
<StopCircleIcon aria-hidden="true" /> {{ formatMessage(commonMessages.stopButton) }}
{{ formatMessage(commonMessages.stopButton) }} </button>
</button> </ButtonStyled>
</ButtonStyled>
<ButtonStyled v-else>
<button
v-tooltip="
serverIncompatible
? 'Server is incompatible'
: !supportsQuickPlay
? formatMessage(messages.noQuickPlay)
: playingOtherWorld || locked
? formatMessage(messages.gameAlreadyOpen)
: null
"
:disabled="!supportsQuickPlay || playingOtherWorld || startingInstance"
@click="emit('play')"
>
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
<PlayIcon v-else aria-hidden="true" />
{{ formatMessage(commonMessages.playButton) }}
</button>
</ButtonStyled>
</template>
<ButtonStyled v-else> <ButtonStyled v-else>
<button class="invisible"> <button
<PlayIcon aria-hidden="true" /> v-tooltip="
world.type == 'server' && !supportsServerQuickPlay
? formatMessage(messages.noServerQuickPlay)
: world.type == 'singleplayer' && !supportsWorldQuickPlay
? formatMessage(messages.noSingleplayerQuickPlay)
: playingOtherWorld || locked
? formatMessage(messages.gameAlreadyOpen)
: !serverStatus
? formatMessage(messages.noContact)
: serverIncompatible
? formatMessage(messages.incompatibleServer)
: null
"
:disabled="
playingOtherWorld ||
startingInstance ||
(world.type == 'server' && !supportsServerQuickPlay) ||
(world.type == 'singleplayer' && !supportsWorldQuickPlay)
"
@click="emit('play')"
>
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
<PlayIcon v-else aria-hidden="true" />
{{ formatMessage(commonMessages.playButton) }} {{ formatMessage(commonMessages.playButton) }}
</button> </button>
</ButtonStyled> </ButtonStyled>
@@ -347,11 +365,6 @@ const messages = defineMessages({
disabled: playingInstance, disabled: playingInstance,
action: () => emit('play-instance'), action: () => emit('play-instance'),
}, },
{
id: 'play-anyway',
shown: serverIncompatible && !playingInstance && supportsQuickPlay,
action: () => emit('play'),
},
{ {
id: 'open-instance', id: 'open-instance',
shown: !!instancePath, shown: !!instancePath,
@@ -417,10 +430,6 @@ const messages = defineMessages({
<PlayIcon aria-hidden="true" /> <PlayIcon aria-hidden="true" />
{{ formatMessage(messages.playInstance) }} {{ formatMessage(messages.playInstance) }}
</template> </template>
<template #play-anyway>
<PlayIcon aria-hidden="true" />
{{ formatMessage(messages.playAnyway) }}
</template>
<template #open-instance> <template #open-instance>
<EyeIcon aria-hidden="true" /> <EyeIcon aria-hidden="true" />
{{ formatMessage(messages.viewInstance) }} {{ formatMessage(messages.viewInstance) }}

View File

@@ -1,15 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { PlayIcon, PlusIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, commonMessages } from '@modrinth/ui'
import { ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import type { GameInstance } from '@/helpers/types'
import InstanceModalTitlePrefix from '@/components/ui/modal/InstanceModalTitlePrefix.vue' import InstanceModalTitlePrefix from '@/components/ui/modal/InstanceModalTitlePrefix.vue'
import { add_server_to_profile, type ServerPackStatus, type ServerWorld } from '@/helpers/worlds.ts' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { handleError } from '@/store/notifications'
import ServerModalBody from '@/components/ui/world/modal/ServerModalBody.vue' import ServerModalBody from '@/components/ui/world/modal/ServerModalBody.vue'
import type { GameInstance } from '@/helpers/types'
import { add_server_to_profile, type ServerPackStatus, type ServerWorld } from '@/helpers/worlds.ts'
import { PlayIcon, PlusIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, commonMessages, injectNotificationManager } from '@modrinth/ui'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { ref } from 'vue'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const emit = defineEmits<{ const emit = defineEmits<{

View File

@@ -1,21 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import { SaveIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, commonMessages } from '@modrinth/ui'
import { computed, ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import HideFromHomeOption from '@/components/ui/world/modal/HideFromHomeOption.vue'
import ServerModalBody from '@/components/ui/world/modal/ServerModalBody.vue'
import type { GameInstance } from '@/helpers/types' import type { GameInstance } from '@/helpers/types'
import { import {
type ServerPackStatus,
edit_server_in_profile, edit_server_in_profile,
type ServerWorld,
set_world_display_status, set_world_display_status,
type DisplayStatus, type DisplayStatus,
type ServerPackStatus,
type ServerWorld,
} from '@/helpers/worlds.ts' } from '@/helpers/worlds.ts'
import { SaveIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, commonMessages, injectNotificationManager } from '@modrinth/ui'
import { defineMessage, useVIntl } from '@vintl/vintl' import { defineMessage, useVIntl } from '@vintl/vintl'
import { handleError } from '@/store/notifications' import { computed, ref } from 'vue'
import ServerModalBody from '@/components/ui/world/modal/ServerModalBody.vue'
import HideFromHomeOption from '@/components/ui/world/modal/HideFromHomeOption.vue'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const emit = defineEmits<{ const emit = defineEmits<{

View File

@@ -1,15 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { ChevronRightIcon, SaveIcon, XIcon, UndoIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled, commonMessages } from '@modrinth/ui'
import { computed, ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import HideFromHomeOption from '@/components/ui/world/modal/HideFromHomeOption.vue'
import type { GameInstance } from '@/helpers/types' import type { GameInstance } from '@/helpers/types'
import type { DisplayStatus, SingleplayerWorld } from '@/helpers/worlds.ts' import type { DisplayStatus, SingleplayerWorld } from '@/helpers/worlds.ts'
import { set_world_display_status, rename_world, reset_world_icon } from '@/helpers/worlds.ts' import { rename_world, reset_world_icon, set_world_display_status } from '@/helpers/worlds.ts'
import { ChevronRightIcon, SaveIcon, UndoIcon, XIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled, commonMessages, injectNotificationManager } from '@modrinth/ui'
import { defineMessages, useVIntl } from '@vintl/vintl' import { defineMessages, useVIntl } from '@vintl/vintl'
import { handleError } from '@/store/notifications' import { computed, ref } from 'vue'
import HideFromHomeOption from '@/components/ui/world/modal/HideFromHomeOption.vue'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const emit = defineEmits<{ const emit = defineEmits<{

View File

@@ -0,0 +1,22 @@
import { get_max_memory } from '@/helpers/jre.js'
import { injectNotificationManager } from '@modrinth/ui'
import { computed, ref } from 'vue'
export default async function () {
const { handleError } = injectNotificationManager()
const maxMemory = ref(Math.floor((await get_max_memory().catch(handleError)) / 1024))
const snapPoints = computed(() => {
let points = []
let memory = 2048
while (memory <= maxMemory.value) {
points.push(memory)
memory *= 2
}
return points
})
return { maxMemory, snapPoints }
}

View File

@@ -1,6 +1,6 @@
import { fetch } from '@tauri-apps/plugin-http' import { injectNotificationManager } from '@modrinth/ui'
import { handleError } from '@/store/state.js'
import { getVersion } from '@tauri-apps/api/app' import { getVersion } from '@tauri-apps/api/app'
import { fetch } from '@tauri-apps/plugin-http'
export const useFetch = async (url, item, isSilent) => { export const useFetch = async (url, item, isSilent) => {
try { try {
@@ -11,6 +11,7 @@ export const useFetch = async (url, item, isSilent) => {
}) })
} catch (err) { } catch (err) {
if (!isSilent) { if (!isSilent) {
const { handleError } = injectNotificationManager()
handleError({ message: `Error fetching ${item}` }) handleError({ message: `Error fetching ${item}` })
} }
console.error(err) console.error(err)

View File

@@ -36,8 +36,8 @@ export async function get_jre(path) {
// Tests JRE version by running 'java -version' on it. // Tests JRE version by running 'java -version' on it.
// Returns true if the version is valid, and matches given (after extraction) // Returns true if the version is valid, and matches given (after extraction)
export async function test_jre(path, majorVersion, minorVersion) { export async function test_jre(path, majorVersion) {
return await invoke('plugin:jre|jre_test_jre', { path, majorVersion, minorVersion }) return await invoke('plugin:jre|jre_test_jre', { path, majorVersion })
} }
// Automatically installs specified java version // Automatically installs specified java version

View File

@@ -16,3 +16,7 @@ export async function logout() {
export async function get() { export async function get() {
return await invoke('plugin:mr-auth|get') return await invoke('plugin:mr-auth|get')
} }
export async function cancelLogin() {
return await invoke('plugin:mr-auth|cancel_modrinth_login')
}

View File

@@ -3,9 +3,9 @@
* So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized, * So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
* and deserialized into a usable JS object. * and deserialized into a usable JS object.
*/ */
import { invoke } from '@tauri-apps/api/core'
import { install_to_existing_profile } from '@/helpers/pack.js' import { install_to_existing_profile } from '@/helpers/pack.js'
import { handleError } from '@/store/notifications.js' import { injectNotificationManager } from '@modrinth/ui'
import { invoke } from '@tauri-apps/api/core'
/// Add instance /// Add instance
/* /*
@@ -190,6 +190,7 @@ export async function edit_icon(path, iconPath) {
} }
export async function finish_install(instance) { export async function finish_install(instance) {
const { handleError } = injectNotificationManager()
if (instance.install_stage !== 'pack_installed') { if (instance.install_stage !== 'pack_installed') {
let linkedData = instance.linked_data let linkedData = instance.linked_data
await install_to_existing_profile( await install_to_existing_profile(

View File

@@ -2,27 +2,46 @@ import * as THREE from 'three'
import type { Skin, Cape } from '../skins' import type { Skin, Cape } from '../skins'
import { get_normalized_skin_texture, determineModelType } from '../skins' import { get_normalized_skin_texture, determineModelType } from '../skins'
import { reactive } from 'vue' import { reactive } from 'vue'
import { setupSkinModel, disposeCaches } from '@modrinth/utils' import {
setupSkinModel,
disposeCaches,
loadTexture,
applyCapeTexture,
createTransparentTexture,
} from '@modrinth/utils'
import { skinPreviewStorage } from '../storage/skin-preview-storage' import { skinPreviewStorage } from '../storage/skin-preview-storage'
import capeModelUrl from '@/assets/models/cape.gltf?url' import { headStorage } from '../storage/head-storage'
import wideModelUrl from '@/assets/models/classic_player.gltf?url' import { ClassicPlayerModel, SlimPlayerModel } from '@modrinth/assets'
import slimModelUrl from '@/assets/models/slim_player.gltf?url'
export interface RenderResult { export interface RenderResult {
forwards: string forwards: string
backwards: string backwards: string
} }
export interface RawRenderResult {
forwards: Blob
backwards: Blob
}
class BatchSkinRenderer { class BatchSkinRenderer {
private renderer: THREE.WebGLRenderer private renderer: THREE.WebGLRenderer | null = null
private readonly scene: THREE.Scene private scene: THREE.Scene | null = null
private readonly camera: THREE.PerspectiveCamera private camera: THREE.PerspectiveCamera | null = null
private currentModel: THREE.Group | null = null private currentModel: THREE.Group | null = null
private readonly width: number
private readonly height: number
constructor(width: number = 360, height: number = 504) { constructor(width: number = 360, height: number = 504) {
this.width = width
this.height = height
}
private initializeRenderer(): void {
if (this.renderer) return
const canvas = document.createElement('canvas') const canvas = document.createElement('canvas')
canvas.width = width canvas.width = this.width
canvas.height = height canvas.height = this.height
this.renderer = new THREE.WebGLRenderer({ this.renderer = new THREE.WebGLRenderer({
canvas: canvas, canvas: canvas,
@@ -35,10 +54,10 @@ class BatchSkinRenderer {
this.renderer.toneMapping = THREE.NoToneMapping this.renderer.toneMapping = THREE.NoToneMapping
this.renderer.toneMappingExposure = 10.0 this.renderer.toneMappingExposure = 10.0
this.renderer.setClearColor(0x000000, 0) this.renderer.setClearColor(0x000000, 0)
this.renderer.setSize(width, height) this.renderer.setSize(this.width, this.height)
this.scene = new THREE.Scene() this.scene = new THREE.Scene()
this.camera = new THREE.PerspectiveCamera(20, width / height, 0.4, 1000) this.camera = new THREE.PerspectiveCamera(20, this.width / this.height, 0.4, 1000)
const ambientLight = new THREE.AmbientLight(0xffffff, 2) const ambientLight = new THREE.AmbientLight(0xffffff, 2)
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2) const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2)
@@ -52,9 +71,12 @@ class BatchSkinRenderer {
textureUrl: string, textureUrl: string,
modelUrl: string, modelUrl: string,
capeUrl?: string, capeUrl?: string,
capeModelUrl?: string, ): Promise<RawRenderResult> {
): Promise<RenderResult> { this.initializeRenderer()
await this.setupModel(modelUrl, textureUrl, capeModelUrl, capeUrl)
this.clearScene()
await this.setupModel(modelUrl, textureUrl, capeUrl)
const headPart = this.currentModel!.getObjectByName('Head') const headPart = this.currentModel!.getObjectByName('Head')
let lookAtTarget: [number, number, number] let lookAtTarget: [number, number, number]
@@ -79,35 +101,35 @@ class BatchSkinRenderer {
private async renderView( private async renderView(
cameraPosition: [number, number, number], cameraPosition: [number, number, number],
lookAtPosition: [number, number, number], lookAtPosition: [number, number, number],
): Promise<string> { ): Promise<Blob> {
if (!this.camera || !this.renderer || !this.scene) {
throw new Error('Renderer not initialized')
}
this.camera.position.set(...cameraPosition) this.camera.position.set(...cameraPosition)
this.camera.lookAt(...lookAtPosition) this.camera.lookAt(...lookAtPosition)
this.renderer.render(this.scene, this.camera) this.renderer.render(this.scene, this.camera)
return new Promise<string>((resolve, reject) => { const dataUrl = this.renderer.domElement.toDataURL('image/webp', 0.9)
this.renderer.domElement.toBlob((blob) => { const response = await fetch(dataUrl)
if (blob) { return await response.blob()
const url = URL.createObjectURL(blob)
resolve(url)
} else {
reject(new Error('Failed to create blob from canvas'))
}
}, 'image/png')
})
} }
private async setupModel( private async setupModel(modelUrl: string, textureUrl: string, capeUrl?: string): Promise<void> {
modelUrl: string, if (!this.scene) {
textureUrl: string, throw new Error('Renderer not initialized')
capeModelUrl?: string,
capeUrl?: string,
): Promise<void> {
if (this.currentModel) {
this.scene.remove(this.currentModel)
} }
const { model } = await setupSkinModel(modelUrl, textureUrl, capeModelUrl, capeUrl) const { model } = await setupSkinModel(modelUrl, textureUrl)
if (capeUrl) {
const capeTexture = await loadTexture(capeUrl)
applyCapeTexture(model, capeTexture)
} else {
const transparentTexture = createTransparentTexture()
applyCapeTexture(model, null, transparentTexture)
}
const group = new THREE.Group() const group = new THREE.Group()
group.add(model) group.add(model)
@@ -118,8 +140,39 @@ class BatchSkinRenderer {
this.currentModel = group this.currentModel = group
} }
private clearScene(): void {
if (!this.scene) return
while (this.scene.children.length > 0) {
const child = this.scene.children[0]
this.scene.remove(child)
if (child instanceof THREE.Mesh) {
if (child.geometry) child.geometry.dispose()
if (child.material) {
if (Array.isArray(child.material)) {
child.material.forEach((material) => material.dispose())
} else {
child.material.dispose()
}
}
}
}
const ambientLight = new THREE.AmbientLight(0xffffff, 2)
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2)
directionalLight.castShadow = true
directionalLight.position.set(2, 4, 3)
this.scene.add(ambientLight)
this.scene.add(directionalLight)
this.currentModel = null
}
public dispose(): void { public dispose(): void {
this.renderer.dispose() if (this.renderer) {
this.renderer.dispose()
}
disposeCaches() disposeCaches()
} }
} }
@@ -127,18 +180,33 @@ class BatchSkinRenderer {
function getModelUrlForVariant(variant: string): string { function getModelUrlForVariant(variant: string): string {
switch (variant) { switch (variant) {
case 'SLIM': case 'SLIM':
return slimModelUrl return SlimPlayerModel
case 'CLASSIC': case 'CLASSIC':
case 'UNKNOWN': case 'UNKNOWN':
default: default:
return wideModelUrl return ClassicPlayerModel
} }
} }
export const map = reactive(new Map<string, RenderResult>()) export const skinBlobUrlMap = reactive(new Map<string, RenderResult>())
export const headMap = reactive(new Map<string, string>()) export const headBlobUrlMap = reactive(new Map<string, string>())
const DEBUG_MODE = false const DEBUG_MODE = false
let sharedRenderer: BatchSkinRenderer | null = null
function getSharedRenderer(): BatchSkinRenderer {
if (!sharedRenderer) {
sharedRenderer = new BatchSkinRenderer()
}
return sharedRenderer
}
export function disposeSharedRenderer(): void {
if (sharedRenderer) {
sharedRenderer.dispose()
sharedRenderer = null
}
}
export async function cleanupUnusedPreviews(skins: Skin[]): Promise<void> { export async function cleanupUnusedPreviews(skins: Skin[]): Promise<void> {
const validKeys = new Set<string>() const validKeys = new Set<string>()
const validHeadKeys = new Set<string>() const validHeadKeys = new Set<string>()
@@ -152,7 +220,7 @@ export async function cleanupUnusedPreviews(skins: Skin[]): Promise<void> {
try { try {
await skinPreviewStorage.cleanupInvalidKeys(validKeys) await skinPreviewStorage.cleanupInvalidKeys(validKeys)
await skinPreviewStorage.cleanupInvalidKeys(validHeadKeys) await headStorage.cleanupInvalidKeys(validHeadKeys)
} catch (error) { } catch (error) {
console.warn('Failed to cleanup unused skin previews:', error) console.warn('Failed to cleanup unused skin previews:', error)
} }
@@ -231,13 +299,17 @@ export async function generatePlayerHeadBlob(skinUrl: string, size: number = 64)
outputCtx.drawImage(hatCanvas, 0, 0, 8, 8, 0, 0, size, size) outputCtx.drawImage(hatCanvas, 0, 0, 8, 8, 0, 0, size, size)
} }
outputCanvas.toBlob((blob) => { outputCanvas.toBlob(
if (blob) { (blob) => {
resolve(blob) if (blob) {
} else { resolve(blob)
reject(new Error('Failed to create blob from canvas')) } else {
} reject(new Error('Failed to create blob from canvas'))
}, 'image/png') }
},
'image/webp',
0.9,
)
} catch (error) { } catch (error) {
reject(error) reject(error)
} }
@@ -254,34 +326,24 @@ export async function generatePlayerHeadBlob(skinUrl: string, size: number = 64)
async function generateHeadRender(skin: Skin): Promise<string> { async function generateHeadRender(skin: Skin): Promise<string> {
const headKey = `${skin.texture_key}-head` const headKey = `${skin.texture_key}-head`
if (headMap.has(headKey)) { if (headBlobUrlMap.has(headKey)) {
if (DEBUG_MODE) { if (DEBUG_MODE) {
const url = headMap.get(headKey)! const url = headBlobUrlMap.get(headKey)!
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
headMap.delete(headKey) headBlobUrlMap.delete(headKey)
} else { } else {
return headMap.get(headKey)! return headBlobUrlMap.get(headKey)!
} }
} }
try {
const cached = await skinPreviewStorage.retrieve(headKey)
if (cached && typeof cached === 'string') {
headMap.set(headKey, cached)
return cached
}
} catch (error) {
console.warn('Failed to retrieve cached head render:', error)
}
const skinUrl = await get_normalized_skin_texture(skin) const skinUrl = await get_normalized_skin_texture(skin)
const headBlob = await generatePlayerHeadBlob(skinUrl, 64) const headBlob = await generatePlayerHeadBlob(skinUrl, 64)
const headUrl = URL.createObjectURL(headBlob) const headUrl = URL.createObjectURL(headBlob)
headMap.set(headKey, headUrl) headBlobUrlMap.set(headKey, headUrl)
try { try {
await skinPreviewStorage.store(headKey, headUrl) await headStorage.store(headKey, headBlob)
} catch (error) { } catch (error) {
console.warn('Failed to store head render in persistent storage:', error) console.warn('Failed to store head render in persistent storage:', error)
} }
@@ -294,30 +356,49 @@ export async function getPlayerHeadUrl(skin: Skin): Promise<string> {
} }
export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promise<void> { export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promise<void> {
const renderer = new BatchSkinRenderer()
try { try {
const skinKeys = skins.map(
(skin) => `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`,
)
const headKeys = skins.map((skin) => `${skin.texture_key}-head`)
const [cachedSkinPreviews, cachedHeadPreviews] = await Promise.all([
skinPreviewStorage.batchRetrieve(skinKeys),
headStorage.batchRetrieve(headKeys),
])
for (let i = 0; i < skins.length; i++) {
const skinKey = skinKeys[i]
const headKey = headKeys[i]
const rawCached = cachedSkinPreviews[skinKey]
if (rawCached) {
const cached: RenderResult = {
forwards: URL.createObjectURL(rawCached.forwards),
backwards: URL.createObjectURL(rawCached.backwards),
}
skinBlobUrlMap.set(skinKey, cached)
}
const cachedHead = cachedHeadPreviews[headKey]
if (cachedHead) {
headBlobUrlMap.set(headKey, URL.createObjectURL(cachedHead))
}
}
for (const skin of skins) { for (const skin of skins) {
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}` const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
if (map.has(key)) { if (skinBlobUrlMap.has(key)) {
if (DEBUG_MODE) { if (DEBUG_MODE) {
const result = map.get(key)! const result = skinBlobUrlMap.get(key)!
URL.revokeObjectURL(result.forwards) URL.revokeObjectURL(result.forwards)
URL.revokeObjectURL(result.backwards) URL.revokeObjectURL(result.backwards)
map.delete(key) skinBlobUrlMap.delete(key)
} else continue } else continue
} }
try { const renderer = getSharedRenderer()
const cached = await skinPreviewStorage.retrieve(key)
if (cached) {
map.set(key, cached)
continue
}
} catch (error) {
console.warn('Failed to retrieve cached skin preview:', error)
}
let variant = skin.variant let variant = skin.variant
if (variant === 'UNKNOWN') { if (variant === 'UNKNOWN') {
@@ -331,25 +412,35 @@ export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promis
const modelUrl = getModelUrlForVariant(variant) const modelUrl = getModelUrlForVariant(variant)
const cape: Cape | undefined = capes.find((_cape) => _cape.id === skin.cape_id) const cape: Cape | undefined = capes.find((_cape) => _cape.id === skin.cape_id)
const renderResult = await renderer.renderSkin( const rawRenderResult = await renderer.renderSkin(
await get_normalized_skin_texture(skin), await get_normalized_skin_texture(skin),
modelUrl, modelUrl,
cape?.texture, cape?.texture,
capeModelUrl,
) )
map.set(key, renderResult) const renderResult: RenderResult = {
forwards: URL.createObjectURL(rawRenderResult.forwards),
backwards: URL.createObjectURL(rawRenderResult.backwards),
}
skinBlobUrlMap.set(key, renderResult)
try { try {
await skinPreviewStorage.store(key, renderResult) await skinPreviewStorage.store(key, rawRenderResult)
} catch (error) { } catch (error) {
console.warn('Failed to store skin preview in persistent storage:', error) console.warn('Failed to store skin preview in persistent storage:', error)
} }
await generateHeadRender(skin) const headKey = `${skin.texture_key}-head`
if (!headBlobUrlMap.has(headKey)) {
await generateHeadRender(skin)
}
} }
} finally { } finally {
renderer.dispose() disposeSharedRenderer()
await cleanupUnusedPreviews(skins) await cleanupUnusedPreviews(skins)
await skinPreviewStorage.debugCalculateStorage()
await headStorage.debugCalculateStorage()
} }
} }

View File

@@ -1,6 +1,6 @@
import { invoke } from '@tauri-apps/api/core' import { injectNotificationManager } from '@modrinth/ui'
import { handleError } from '@/store/notifications'
import { arrayBufferToBase64 } from '@modrinth/utils' import { arrayBufferToBase64 } from '@modrinth/utils'
import { invoke } from '@tauri-apps/api/core'
export interface Cape { export interface Cape {
id: string id: string
@@ -39,6 +39,7 @@ export const DEFAULT_MODELS: Record<string, SkinModel> = {
export function filterSavedSkins(list: Skin[]) { export function filterSavedSkins(list: Skin[]) {
const customSkins = list.filter((s) => s.source !== 'default') const customSkins = list.filter((s) => s.source !== 'default')
const { handleError } = injectNotificationManager()
fixUnknownSkins(customSkins).catch(handleError) fixUnknownSkins(customSkins).catch(handleError)
return customSkins return customSkins
} }
@@ -62,15 +63,12 @@ export async function determineModelType(texture: string): Promise<'SLIM' | 'CLA
context.drawImage(image, 0, 0) context.drawImage(image, 0, 0)
const armX = 44 const armX = 54
const armY = 16 const armY = 20
const armWidth = 4 const armWidth = 2
const armHeight = 12 const armHeight = 12
const imageData = context.getImageData(armX, armY, armWidth, armHeight).data const imageData = context.getImageData(armX, armY, armWidth, armHeight).data
for (let alphaIndex = 3; alphaIndex < imageData.length; alphaIndex += 4) {
for (let y = 0; y < armHeight; y++) {
const alphaIndex = (3 + y * armWidth) * 4 + 3
if (imageData[alphaIndex] !== 0) { if (imageData[alphaIndex] !== 0) {
resolve('CLASSIC') resolve('CLASSIC')
return return
@@ -97,7 +95,11 @@ export async function fixUnknownSkins(list: Skin[]) {
export function filterDefaultSkins(list: Skin[]) { export function filterDefaultSkins(list: Skin[]) {
return list return list
.filter((s) => s.source === 'default' && (!s.name || s.variant === DEFAULT_MODELS[s.name])) .filter(
(s) =>
s.source === 'default' &&
(!s.name || !(s.name in DEFAULT_MODELS) || s.variant === DEFAULT_MODELS[s.name]),
)
.sort((a, b) => { .sort((a, b) => {
const aIndex = a.name ? DEFAULT_MODEL_SORTING.indexOf(a.name) : -1 const aIndex = a.name ? DEFAULT_MODEL_SORTING.indexOf(a.name) : -1
const bIndex = b.name ? DEFAULT_MODEL_SORTING.indexOf(b.name) : -1 const bIndex = b.name ? DEFAULT_MODEL_SORTING.indexOf(b.name) : -1

View File

@@ -0,0 +1,229 @@
interface StoredHead {
blob: Blob
timestamp: number
}
export class HeadStorage {
private dbName = 'head-storage'
private version = 1
private db: IDBDatabase | null = null
async init(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version)
request.onerror = () => reject(request.error)
request.onsuccess = () => {
this.db = request.result
resolve()
}
request.onupgradeneeded = () => {
const db = request.result
if (!db.objectStoreNames.contains('heads')) {
db.createObjectStore('heads')
}
}
})
}
async store(key: string, blob: Blob): Promise<void> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['heads'], 'readwrite')
const store = transaction.objectStore('heads')
const storedHead: StoredHead = {
blob,
timestamp: Date.now(),
}
return new Promise((resolve, reject) => {
const request = store.put(storedHead, key)
request.onsuccess = () => resolve()
request.onerror = () => reject(request.error)
})
}
async retrieve(key: string): Promise<string | null> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['heads'], 'readonly')
const store = transaction.objectStore('heads')
return new Promise((resolve, reject) => {
const request = store.get(key)
request.onsuccess = () => {
const result = request.result as StoredHead | undefined
if (!result) {
resolve(null)
return
}
const url = URL.createObjectURL(result.blob)
resolve(url)
}
request.onerror = () => reject(request.error)
})
}
async batchRetrieve(keys: string[]): Promise<Record<string, Blob | null>> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['heads'], 'readonly')
const store = transaction.objectStore('heads')
const results: Record<string, Blob | null> = {}
return new Promise((resolve, _reject) => {
let completedRequests = 0
if (keys.length === 0) {
resolve(results)
return
}
for (const key of keys) {
const request = store.get(key)
request.onsuccess = () => {
const result = request.result as StoredHead | undefined
if (result) {
results[key] = result.blob
} else {
results[key] = null
}
completedRequests++
if (completedRequests === keys.length) {
resolve(results)
}
}
request.onerror = () => {
results[key] = null
completedRequests++
if (completedRequests === keys.length) {
resolve(results)
}
}
}
})
}
async cleanupInvalidKeys(validKeys: Set<string>): Promise<number> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['heads'], 'readwrite')
const store = transaction.objectStore('heads')
let deletedCount = 0
return new Promise((resolve, reject) => {
const request = store.openCursor()
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result
if (cursor) {
const key = cursor.primaryKey as string
if (!validKeys.has(key)) {
const deleteRequest = cursor.delete()
deleteRequest.onsuccess = () => {
deletedCount++
}
deleteRequest.onerror = () => {
console.warn('Failed to delete invalid head entry:', key)
}
}
cursor.continue()
} else {
resolve(deletedCount)
}
}
request.onerror = () => reject(request.error)
})
}
async debugCalculateStorage(): Promise<void> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['heads'], 'readonly')
const store = transaction.objectStore('heads')
let totalSize = 0
let count = 0
const entries: Array<{ key: string; size: number }> = []
return new Promise((resolve, reject) => {
const request = store.openCursor()
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result
if (cursor) {
const key = cursor.primaryKey as string
const value = cursor.value as StoredHead
const entrySize = value.blob.size
totalSize += entrySize
count++
entries.push({
key,
size: entrySize,
})
cursor.continue()
} else {
console.group('🗄️ Head Storage Debug Info')
console.log(`Total entries: ${count}`)
console.log(`Total size: ${(totalSize / 1024 / 1024).toFixed(2)} MB`)
console.log(
`Average size per entry: ${count > 0 ? (totalSize / count / 1024).toFixed(2) : 0} KB`,
)
if (entries.length > 0) {
const sortedEntries = entries.sort((a, b) => b.size - a.size)
console.log(
'Largest entry:',
sortedEntries[0].key,
'(' + (sortedEntries[0].size / 1024).toFixed(2) + ' KB)',
)
console.log(
'Smallest entry:',
sortedEntries[sortedEntries.length - 1].key,
'(' + (sortedEntries[sortedEntries.length - 1].size / 1024).toFixed(2) + ' KB)',
)
}
console.groupEnd()
resolve()
}
}
request.onerror = () => reject(request.error)
})
}
async clearAll(): Promise<void> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['heads'], 'readwrite')
const store = transaction.objectStore('heads')
return new Promise((resolve, reject) => {
const request = store.clear()
request.onsuccess = () => resolve()
request.onerror = () => reject(request.error)
})
}
}
export const headStorage = new HeadStorage()

View File

@@ -1,4 +1,4 @@
import type { RenderResult } from '../rendering/batch-skin-renderer' import type { RawRenderResult } from '../rendering/batch-skin-renderer'
interface StoredPreview { interface StoredPreview {
forwards: Blob forwards: Blob
@@ -30,18 +30,15 @@ export class SkinPreviewStorage {
}) })
} }
async store(key: string, result: RenderResult): Promise<void> { async store(key: string, result: RawRenderResult): Promise<void> {
if (!this.db) await this.init() if (!this.db) await this.init()
const forwardsBlob = await fetch(result.forwards).then((r) => r.blob())
const backwardsBlob = await fetch(result.backwards).then((r) => r.blob())
const transaction = this.db!.transaction(['previews'], 'readwrite') const transaction = this.db!.transaction(['previews'], 'readwrite')
const store = transaction.objectStore('previews') const store = transaction.objectStore('previews')
const storedPreview: StoredPreview = { const storedPreview: StoredPreview = {
forwards: forwardsBlob, forwards: result.forwards,
backwards: backwardsBlob, backwards: result.backwards,
timestamp: Date.now(), timestamp: Date.now(),
} }
@@ -53,7 +50,7 @@ export class SkinPreviewStorage {
}) })
} }
async retrieve(key: string): Promise<RenderResult | null> { async retrieve(key: string): Promise<RawRenderResult | null> {
if (!this.db) await this.init() if (!this.db) await this.init()
const transaction = this.db!.transaction(['previews'], 'readonly') const transaction = this.db!.transaction(['previews'], 'readonly')
@@ -70,14 +67,56 @@ export class SkinPreviewStorage {
return return
} }
const forwards = URL.createObjectURL(result.forwards) resolve({ forwards: result.forwards, backwards: result.backwards })
const backwards = URL.createObjectURL(result.backwards)
resolve({ forwards, backwards })
} }
request.onerror = () => reject(request.error) request.onerror = () => reject(request.error)
}) })
} }
async batchRetrieve(keys: string[]): Promise<Record<string, RawRenderResult | null>> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['previews'], 'readonly')
const store = transaction.objectStore('previews')
const results: Record<string, RawRenderResult | null> = {}
return new Promise((resolve, _reject) => {
let completedRequests = 0
if (keys.length === 0) {
resolve(results)
return
}
for (const key of keys) {
const request = store.get(key)
request.onsuccess = () => {
const result = request.result as StoredPreview | undefined
if (result) {
results[key] = { forwards: result.forwards, backwards: result.backwards }
} else {
results[key] = null
}
completedRequests++
if (completedRequests === keys.length) {
resolve(results)
}
}
request.onerror = () => {
results[key] = null
completedRequests++
if (completedRequests === keys.length) {
resolve(results)
}
}
}
})
}
async cleanupInvalidKeys(validKeys: Set<string>): Promise<number> { async cleanupInvalidKeys(validKeys: Set<string>): Promise<number> {
if (!this.db) await this.init() if (!this.db) await this.init()
@@ -113,6 +152,67 @@ export class SkinPreviewStorage {
request.onerror = () => reject(request.error) request.onerror = () => reject(request.error)
}) })
} }
async debugCalculateStorage(): Promise<void> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['previews'], 'readonly')
const store = transaction.objectStore('previews')
let totalSize = 0
let count = 0
const entries: Array<{ key: string; size: number }> = []
return new Promise((resolve, reject) => {
const request = store.openCursor()
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result
if (cursor) {
const key = cursor.primaryKey as string
const value = cursor.value as StoredPreview
const entrySize = value.forwards.size + value.backwards.size
totalSize += entrySize
count++
entries.push({
key,
size: entrySize,
})
cursor.continue()
} else {
console.group('🗄️ Skin Preview Storage Debug Info')
console.log(`Total entries: ${count}`)
console.log(`Total size: ${(totalSize / 1024 / 1024).toFixed(2)} MB`)
console.log(
`Average size per entry: ${count > 0 ? (totalSize / count / 1024).toFixed(2) : 0} KB`,
)
if (entries.length > 0) {
const sortedEntries = entries.sort((a, b) => b.size - a.size)
console.log(
'Largest entry:',
sortedEntries[0].key,
'(' + (sortedEntries[0].size / 1024).toFixed(2) + ' KB)',
)
console.log(
'Smallest entry:',
sortedEntries[sortedEntries.length - 1].key,
'(' + (sortedEntries[sortedEntries.length - 1].size / 1024).toFixed(2) + ' KB)',
)
}
console.groupEnd()
resolve()
}
}
request.onerror = () => reject(request.error)
})
}
} }
export const skinPreviewStorage = new SkinPreviewStorage() export const skinPreviewStorage = new SkinPreviewStorage()

View File

@@ -51,6 +51,7 @@ export type ServerStatus = {
version?: { version?: {
name: string name: string
protocol: number protocol: number
legacy: boolean
} }
favicon?: string favicon?: string
enforces_secure_chat: boolean enforces_secure_chat: boolean
@@ -70,11 +71,17 @@ export interface Chat {
export type ServerData = { export type ServerData = {
refreshing: boolean refreshing: boolean
lastSuccessfulRefresh?: number
status?: ServerStatus status?: ServerStatus
rawMotd?: string | Chat rawMotd?: string | Chat
renderedMotd?: string renderedMotd?: string
} }
export type ProtocolVersion = {
version: number
legacy: boolean
}
export async function get_recent_worlds( export async function get_recent_worlds(
limit: number, limit: number,
displayStatuses?: DisplayStatus[], displayStatuses?: DisplayStatus[],
@@ -156,13 +163,13 @@ export async function remove_server_from_profile(path: string, index: number): P
return await invoke('plugin:worlds|remove_server_from_profile', { path, index }) return await invoke('plugin:worlds|remove_server_from_profile', { path, index })
} }
export async function get_profile_protocol_version(path: string): Promise<number | null> { export async function get_profile_protocol_version(path: string): Promise<ProtocolVersion | null> {
return await invoke('plugin:worlds|get_profile_protocol_version', { path }) return await invoke('plugin:worlds|get_profile_protocol_version', { path })
} }
export async function get_server_status( export async function get_server_status(
address: string, address: string,
protocolVersion: number | null = null, protocolVersion: ProtocolVersion | null = null,
): Promise<ServerStatus> { ): Promise<ServerStatus> {
return await invoke('plugin:worlds|get_server_status', { address, protocolVersion }) return await invoke('plugin:worlds|get_server_status', { address, protocolVersion })
} }
@@ -206,30 +213,39 @@ export function isServerWorld(world: World): world is ServerWorld {
export async function refreshServerData( export async function refreshServerData(
serverData: ServerData, serverData: ServerData,
protocolVersion: number | null, protocolVersion: ProtocolVersion | null,
address: string, address: string,
): Promise<void> { ): Promise<void> {
const refreshTime = Date.now()
serverData.refreshing = true serverData.refreshing = true
await get_server_status(address, protocolVersion) await get_server_status(address, protocolVersion)
.then((status) => { .then((status) => {
if (serverData.lastSuccessfulRefresh && serverData.lastSuccessfulRefresh > refreshTime) {
// Don't update if there was a more recent successful refresh
return
}
serverData.lastSuccessfulRefresh = Date.now()
serverData.status = status serverData.status = status
if (status.description) { if (status.description) {
serverData.rawMotd = status.description serverData.rawMotd = status.description
serverData.renderedMotd = autoToHTML(status.description) serverData.renderedMotd = autoToHTML(status.description)
} }
}) })
.catch((err) => {
console.error(`Refreshing addr: ${address}`, err)
})
.finally(() => { .finally(() => {
serverData.refreshing = false serverData.refreshing = false
}) })
.catch((err) => {
console.error(`Refreshing addr ${address}`, protocolVersion, err)
if (!protocolVersion?.legacy) {
refreshServerData(serverData, { version: 74, legacy: true }, address)
}
})
} }
export async function refreshServers( export function refreshServers(
worlds: World[], worlds: World[],
serverData: Record<string, ServerData>, serverData: Record<string, ServerData>,
protocolVersion: number | null, protocolVersion: ProtocolVersion | null,
) { ) {
const servers = worlds.filter(isServerWorld) const servers = worlds.filter(isServerWorld)
servers.forEach((server) => { servers.forEach((server) => {
@@ -243,10 +259,8 @@ export async function refreshServers(
}) })
// noinspection ES6MissingAwait - handled with .then by refreshServerData already // noinspection ES6MissingAwait - handled with .then by refreshServerData already
Promise.all( Object.keys(serverData).forEach((address) =>
Object.keys(serverData).map((address) => refreshServerData(serverData[address], protocolVersion, address),
refreshServerData(serverData[address], protocolVersion, address),
),
) )
} }
@@ -297,15 +311,24 @@ export async function refreshWorlds(instancePath: string): Promise<World[]> {
return worlds ?? [] return worlds ?? []
} }
const FIRST_QUICK_PLAY_VERSION = '23w14a' export function hasServerQuickPlaySupport(gameVersions: GameVersion[], currentVersion: string) {
if (!gameVersions.length) {
return true
}
export function hasQuickPlaySupport(gameVersions: GameVersion[], currentVersion: string) { const versionIndex = gameVersions.findIndex((v) => v.version === currentVersion)
const targetIndex = gameVersions.findIndex((v) => v.version === 'a1.0.5_01')
return versionIndex === -1 || targetIndex === -1 || versionIndex <= targetIndex
}
export function hasWorldQuickPlaySupport(gameVersions: GameVersion[], currentVersion: string) {
if (!gameVersions.length) { if (!gameVersions.length) {
return false return false
} }
const versionIndex = gameVersions.findIndex((v) => v.version === currentVersion) const versionIndex = gameVersions.findIndex((v) => v.version === currentVersion)
const targetIndex = gameVersions.findIndex((v) => v.version === FIRST_QUICK_PLAY_VERSION) const targetIndex = gameVersions.findIndex((v) => v.version === '23w14a')
return versionIndex !== -1 && targetIndex !== -1 && versionIndex <= targetIndex return versionIndex !== -1 && targetIndex !== -1 && versionIndex <= targetIndex
} }

View File

@@ -377,11 +377,17 @@
"instance.worlds.hardcore": { "instance.worlds.hardcore": {
"message": "Hardcore mode" "message": "Hardcore mode"
}, },
"instance.worlds.no_quick_play": { "instance.worlds.incompatible_server": {
"message": "You can only jump straight into worlds on Minecraft 1.20+" "message": "Server is incompatible"
}, },
"instance.worlds.play_anyway": { "instance.worlds.no_contact": {
"message": "Play anyway" "message": "Server couldn't be contacted"
},
"instance.worlds.no_server_quick_play": {
"message": "You can only jump straight into servers on Minecraft Alpha 1.0.5+"
},
"instance.worlds.no_singleplayer_quick_play": {
"message": "You can only jump straight into singleplayer worlds on Minecraft 1.20+"
}, },
"instance.worlds.play_instance": { "instance.worlds.play_instance": {
"message": "Play instance" "message": "Play instance"

View File

@@ -1,12 +1,12 @@
import { createApp } from 'vue'
import router from '@/routes'
import App from '@/App.vue' import App from '@/App.vue'
import { createPinia } from 'pinia' import router from '@/routes'
import FloatingVue from 'floating-vue'
import 'floating-vue/dist/style.css'
import { createPlugin } from '@vintl/vintl/plugin'
import * as Sentry from '@sentry/vue' import * as Sentry from '@sentry/vue'
import { VueScanPlugin } from '@taijased/vue-render-tracker' import { VueScanPlugin } from '@taijased/vue-render-tracker'
import { createPlugin } from '@vintl/vintl/plugin'
import FloatingVue from 'floating-vue'
import 'floating-vue/dist/style.css'
import { createPinia } from 'pinia'
import { createApp } from 'vue'
const VIntlPlugin = createPlugin({ const VIntlPlugin = createPlugin({
controllerOpts: { controllerOpts: {

View File

@@ -1,33 +1,34 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, ref, shallowRef, watch } from 'vue' import ContextMenu from '@/components/ui/ContextMenu.vue'
import type { Ref } from 'vue' import type Instance from '@/components/ui/Instance.vue'
import { SearchIcon, XIcon, ClipboardCopyIcon, GlobeIcon, ExternalIcon } from '@modrinth/assets' import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
import NavTabs from '@/components/ui/NavTabs.vue'
import SearchCard from '@/components/ui/SearchCard.vue'
import { get_search_results } from '@/helpers/cache.js'
import { get as getInstance, get_projects as getInstanceProjects } from '@/helpers/profile.js'
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { ClipboardCopyIcon, ExternalIcon, GlobeIcon, SearchIcon, XIcon } from '@modrinth/assets'
import type { Category, GameVersion, Platform, ProjectType, SortType, Tags } from '@modrinth/ui' import type { Category, GameVersion, Platform, ProjectType, SortType, Tags } from '@modrinth/ui'
import { import {
SearchFilterControl,
SearchSidebarFilter,
Button, Button,
Checkbox, Checkbox,
DropdownSelect, DropdownSelect,
injectNotificationManager,
LoadingIndicator, LoadingIndicator,
Pagination, Pagination,
SearchFilterControl,
SearchSidebarFilter,
useSearch, useSearch,
} from '@modrinth/ui' } from '@modrinth/ui'
import { handleError } from '@/store/state' import { openUrl } from '@tauri-apps/plugin-opener'
import { useBreadcrumbs } from '@/store/breadcrumbs' import { defineMessages, useVIntl } from '@vintl/vintl'
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags' import type { Ref } from 'vue'
import { computed, nextTick, ref, shallowRef, watch } from 'vue'
import type { LocationQuery } from 'vue-router' import type { LocationQuery } from 'vue-router'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import SearchCard from '@/components/ui/SearchCard.vue'
import { get as getInstance, get_projects as getInstanceProjects } from '@/helpers/profile.js'
import { get_search_results } from '@/helpers/cache.js'
import NavTabs from '@/components/ui/NavTabs.vue'
import type Instance from '@/components/ui/Instance.vue'
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
import { defineMessages, useVIntl } from '@vintl/vintl'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import { openUrl } from '@tauri-apps/plugin-opener'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const router = useRouter() const router = useRouter()
@@ -220,7 +221,7 @@ async function refreshSearch() {
} }
} }
results.value = rawResults.result results.value = rawResults.result
currentPage.value = Math.max(1, Math.min(pageCount.value, currentPage.value)) currentPage.value = 1
const persistentParams: LocationQuery = {} const persistentParams: LocationQuery = {}
@@ -266,6 +267,7 @@ async function onSearchChangeToTop() {
function clearSearch() { function clearSearch() {
query.value = '' query.value = ''
currentPage.value = 1
} }
watch( watch(

View File

@@ -1,17 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onUnmounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import RowDisplay from '@/components/RowDisplay.vue' import RowDisplay from '@/components/RowDisplay.vue'
import { list } from '@/helpers/profile.js'
import { profile_listener } from '@/helpers/events'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { handleError } from '@/store/notifications.js'
import dayjs from 'dayjs'
import { get_search_results } from '@/helpers/cache.js'
import type { SearchResult } from '@modrinth/utils'
import RecentWorldsList from '@/components/ui/world/RecentWorldsList.vue' import RecentWorldsList from '@/components/ui/world/RecentWorldsList.vue'
import { get_search_results } from '@/helpers/cache.js'
import { profile_listener } from '@/helpers/events'
import { list } from '@/helpers/profile.js'
import type { GameInstance } from '@/helpers/types' import type { GameInstance } from '@/helpers/types'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { injectNotificationManager } from '@modrinth/ui'
import type { SearchResult } from '@modrinth/utils'
import dayjs from 'dayjs'
import { computed, onUnmounted, ref } from 'vue'
import { useRoute } from 'vue-router'
const { handleError } = injectNotificationManager()
const route = useRoute() const route = useRoute()
const breadcrumbs = useBreadcrumbs() const breadcrumbs = useBreadcrumbs()

View File

@@ -1,4 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
import type AccountsCard from '@/components/ui/AccountsCard.vue'
import EditSkinModal from '@/components/ui/skin/EditSkinModal.vue'
import SelectCapeModal from '@/components/ui/skin/SelectCapeModal.vue'
import UploadSkinModal from '@/components/ui/skin/UploadSkinModal.vue'
import { trackEvent } from '@/helpers/analytics'
import { get_default_user, login as login_flow, users } from '@/helpers/auth'
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
import { generateSkinPreviews, skinBlobUrlMap } from '@/helpers/rendering/batch-skin-renderer.ts'
import { get as getSettings } from '@/helpers/settings.ts'
import type { Cape, Skin } from '@/helpers/skins.ts'
import {
equip_skin,
filterDefaultSkins,
filterSavedSkins,
get_available_capes,
get_available_skins,
get_normalized_skin_texture,
normalize_skin_texture,
remove_custom_skin,
set_default_cape,
} from '@/helpers/skins.ts'
import { handleSevereError } from '@/store/error'
import { import {
EditIcon, EditIcon,
ExcitedRinthbot, ExcitedRinthbot,
@@ -12,46 +34,21 @@ import {
Button, Button,
ButtonStyled, ButtonStyled,
ConfirmModal, ConfirmModal,
injectNotificationManager,
SkinButton, SkinButton,
SkinLikeTextButton, SkinLikeTextButton,
SkinPreviewRenderer, SkinPreviewRenderer,
} from '@modrinth/ui' } from '@modrinth/ui'
import { arrayBufferToBase64 } from '@modrinth/utils'
import { computedAsync } from '@vueuse/core' import { computedAsync } from '@vueuse/core'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import { computed, inject, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue' import { computed, inject, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'
import EditSkinModal from '@/components/ui/skin/EditSkinModal.vue'
import SelectCapeModal from '@/components/ui/skin/SelectCapeModal.vue'
import UploadSkinModal from '@/components/ui/skin/UploadSkinModal.vue'
import { handleError, useNotifications } from '@/store/notifications'
import type { Cape, Skin } from '@/helpers/skins.ts'
import {
normalize_skin_texture,
equip_skin,
filterDefaultSkins,
filterSavedSkins,
get_available_capes,
get_available_skins,
get_normalized_skin_texture,
remove_custom_skin,
set_default_cape,
} from '@/helpers/skins.ts'
import { get as getSettings } from '@/helpers/settings.ts'
import { get_default_user, login as login_flow, users } from '@/helpers/auth'
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
import { generateSkinPreviews, map } from '@/helpers/rendering/batch-skin-renderer.ts'
import { handleSevereError } from '@/store/error'
import { trackEvent } from '@/helpers/analytics'
import type AccountsCard from '@/components/ui/AccountsCard.vue'
import { arrayBufferToBase64 } from '@modrinth/utils'
import capeModelUrl from '@/assets/models/cape.gltf?url'
import wideModelUrl from '@/assets/models/classic_player.gltf?url'
import slimModelUrl from '@/assets/models/slim_player.gltf?url'
const editSkinModal = useTemplateRef('editSkinModal') const editSkinModal = useTemplateRef('editSkinModal')
const selectCapeModal = useTemplateRef('selectCapeModal') const selectCapeModal = useTemplateRef('selectCapeModal')
const uploadSkinModal = useTemplateRef('uploadSkinModal') const uploadSkinModal = useTemplateRef('uploadSkinModal')
const notifications = useNotifications() const notifications = injectNotificationManager()
const { handleError } = notifications
const settings = ref(await getSettings()) const settings = ref(await getSettings())
const skins = ref<Skin[]>([]) const skins = ref<Skin[]>([])
@@ -117,7 +114,7 @@ async function loadCapes() {
defaultCape.value = capes.value.find((c) => c.is_equipped) defaultCape.value = capes.value.find((c) => c.is_equipped)
originalDefaultCape.value = defaultCape.value originalDefaultCape.value = defaultCape.value
} catch (error) { } catch (error) {
if (currentUser.value) { if (currentUser.value && error instanceof Error) {
handleError(error) handleError(error)
} }
} }
@@ -130,7 +127,7 @@ async function loadSkins() {
selectedSkin.value = skins.value.find((s) => s.is_equipped) ?? null selectedSkin.value = skins.value.find((s) => s.is_equipped) ?? null
originalSelectedSkin.value = selectedSkin.value originalSelectedSkin.value = selectedSkin.value
} catch (error) { } catch (error) {
if (currentUser.value) { if (currentUser.value && error instanceof Error) {
handleError(error) handleError(error)
} }
} }
@@ -165,7 +162,7 @@ async function changeSkin(newSkin: Skin) {
text: "You're changing your skin too frequently. Mojang's servers have temporarily blocked further requests. Please wait a moment before trying again.", text: "You're changing your skin too frequently. Mojang's servers have temporarily blocked further requests. Please wait a moment before trying again.",
}) })
} else { } else {
handleError(error) handleError(error as Error)
} }
} }
} }
@@ -194,7 +191,7 @@ async function handleCapeSelected(cape: Cape | undefined) {
text: "You're changing your cape too frequently. Mojang's servers have temporarily blocked further requests. Please wait a moment before trying again.", text: "You're changing your cape too frequently. Mojang's servers have temporarily blocked further requests. Please wait a moment before trying again.",
}) })
} else { } else {
handleError(error) handleError(error as Error)
} }
} }
} }
@@ -211,7 +208,7 @@ async function loadCurrentUser() {
const allAccounts = await users() const allAccounts = await users()
currentUser.value = allAccounts.find((acc) => acc.profile.id === defaultId) currentUser.value = allAccounts.find((acc) => acc.profile.id === defaultId)
} catch (e) { } catch (e) {
handleError(e) handleError(e as Error)
currentUser.value = undefined currentUser.value = undefined
currentUserId.value = undefined currentUserId.value = undefined
} }
@@ -219,7 +216,7 @@ async function loadCurrentUser() {
function getBakedSkinTextures(skin: Skin): RenderResult | undefined { function getBakedSkinTextures(skin: Skin): RenderResult | undefined {
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}` const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
return map.get(key) return skinBlobUrlMap.get(key)
} }
async function login() { async function login() {
@@ -280,7 +277,7 @@ async function checkUserChanges() {
await loadSkins() await loadSkins()
} }
} catch (error) { } catch (error) {
if (currentUser.value) { if (currentUser.value && error instanceof Error) {
handleError(error) handleError(error)
} }
} }
@@ -320,9 +317,6 @@ await Promise.all([loadCapes(), loadSkins(), loadCurrentUser()])
</h1> </h1>
<div class="preview-container"> <div class="preview-container">
<SkinPreviewRenderer <SkinPreviewRenderer
:wide-model-src="wideModelUrl"
:slim-model-src="slimModelUrl"
:cape-model-src="capeModelUrl"
:cape-src="capeTexture" :cape-src="capeTexture"
:texture-src="skinTexture || ''" :texture-src="skinTexture || ''"
:variant="skinVariant" :variant="skinVariant"
@@ -383,7 +377,7 @@ await Promise.all([loadCapes(), loadSkins(), loadCurrentUser()])
color="green" color="green"
aria-label="Edit skin" aria-label="Edit skin"
class="pointer-events-auto" class="pointer-events-auto"
@click.stop="(e) => editSkinModal?.show(e, skin)" @click.stop="(e: MouseEvent) => editSkinModal?.show(e, skin)"
> >
<EditIcon /> Edit <EditIcon /> Edit
</Button> </Button>

View File

@@ -157,13 +157,18 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { import ContextMenu from '@/components/ui/ContextMenu.vue'
Avatar, import ExportModal from '@/components/ui/ExportModal.vue'
ButtonStyled, import InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.vue'
ContentPageHeader, import NavTabs from '@/components/ui/NavTabs.vue'
LoadingIndicator, import { trackEvent } from '@/helpers/analytics'
OverflowMenu, import { get_project, get_version_many } from '@/helpers/cache.js'
} from '@modrinth/ui' import { process_listener, profile_listener } from '@/helpers/events'
import { get_by_profile_path } from '@/helpers/process'
import { finish_install, get, get_full_path, kill, run } from '@/helpers/profile'
import { showProfileInFolder } from '@/helpers/utils.js'
import { handleSevereError } from '@/store/error.js'
import { useBreadcrumbs, useLoading } from '@/store/state'
import { import {
CheckCircleIcon, CheckCircleIcon,
ClipboardCopyIcon, ClipboardCopyIcon,
@@ -187,28 +192,25 @@ import {
UserPlusIcon, UserPlusIcon,
XIcon, XIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { finish_install, get, get_full_path, kill, run } from '@/helpers/profile' import {
import { get_by_profile_path } from '@/helpers/process' Avatar,
import { process_listener, profile_listener } from '@/helpers/events' ButtonStyled,
import { useRoute, useRouter } from 'vue-router' ContentPageHeader,
import { computed, onUnmounted, ref, watch } from 'vue' injectNotificationManager,
import { handleError, useBreadcrumbs, useLoading } from '@/store/state' LoadingIndicator,
import { showProfileInFolder } from '@/helpers/utils.js' OverflowMenu,
import ContextMenu from '@/components/ui/ContextMenu.vue' } from '@modrinth/ui'
import NavTabs from '@/components/ui/NavTabs.vue'
import { trackEvent } from '@/helpers/analytics'
import { convertFileSrc } from '@tauri-apps/api/core' import { convertFileSrc } from '@tauri-apps/api/core'
import { handleSevereError } from '@/store/error.js'
import { get_project, get_version_many } from '@/helpers/cache.js'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import duration from 'dayjs/plugin/duration' import duration from 'dayjs/plugin/duration'
import relativeTime from 'dayjs/plugin/relativeTime' import relativeTime from 'dayjs/plugin/relativeTime'
import ExportModal from '@/components/ui/ExportModal.vue' import { computed, onUnmounted, ref, watch } from 'vue'
import InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.vue' import { useRoute, useRouter } from 'vue-router'
dayjs.extend(duration) dayjs.extend(duration)
dayjs.extend(relativeTime) dayjs.extend(relativeTime)
const { handleError } = injectNotificationManager()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()

View File

@@ -88,30 +88,30 @@
</template> </template>
<script setup> <script setup>
import { CheckIcon, ClipboardCopyIcon, ShareIcon, TrashIcon } from '@modrinth/assets' import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue'
import { Button, Card, Checkbox, DropdownSelect } from '@modrinth/ui' import { process_listener } from '@/helpers/events.js'
import { import {
delete_logs_by_filename, delete_logs_by_filename,
get_latest_log_cursor,
get_logs, get_logs,
get_output_by_filename, get_output_by_filename,
get_latest_log_cursor,
} from '@/helpers/logs.js' } from '@/helpers/logs.js'
import { computed, nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, watch } from 'vue' import { get_by_profile_path } from '@/helpers/process.js'
import { CheckIcon, ClipboardCopyIcon, ShareIcon, TrashIcon } from '@modrinth/assets'
import { Button, Card, Checkbox, DropdownSelect, injectNotificationManager } from '@modrinth/ui'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import isToday from 'dayjs/plugin/isToday' import isToday from 'dayjs/plugin/isToday'
import isYesterday from 'dayjs/plugin/isYesterday' import isYesterday from 'dayjs/plugin/isYesterday'
import { get_by_profile_path } from '@/helpers/process.js'
import { useRoute } from 'vue-router'
import { process_listener } from '@/helpers/events.js'
import { handleError } from '@/store/notifications.js'
import { ofetch } from 'ofetch' import { ofetch } from 'ofetch'
import { computed, nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { RecycleScroller } from 'vue-virtual-scroller' import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css' import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue'
dayjs.extend(isToday) dayjs.extend(isToday)
dayjs.extend(isYesterday) dayjs.extend(isYesterday)
const { handleError } = injectNotificationManager()
const route = useRoute() const route = useRoute()
const props = defineProps({ const props = defineProps({
@@ -483,7 +483,7 @@ onUnmounted(() => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
height: calc(100vh - 11rem); height: 100vh;
} }
.button-row { .button-row {

View File

@@ -249,6 +249,30 @@
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { TextInputIcon } from '@/assets/icons'
import AddContentButton from '@/components/ui/AddContentButton.vue'
import type ContextMenu from '@/components/ui/ContextMenu.vue'
import ExportModal from '@/components/ui/ExportModal.vue'
import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue'
import ModpackVersionModal from '@/components/ui/ModpackVersionModal.vue'
import { trackEvent } from '@/helpers/analytics'
import {
get_organization_many,
get_project_many,
get_team_many,
get_version_many,
} from '@/helpers/cache.js'
import { profile_listener } from '@/helpers/events.js'
import {
add_project_from_path,
get_projects,
remove_project,
toggle_disable_project,
update_all,
update_project,
} from '@/helpers/profile.js'
import type { CacheBehaviour, ContentFile, GameInstance } from '@/helpers/types'
import { highlightModInProfile } from '@/helpers/utils.js'
import { import {
CheckCircleIcon, CheckCircleIcon,
ClipboardCopyIcon, ClipboardCopyIcon,
@@ -271,44 +295,22 @@ import {
Button, Button,
ButtonStyled, ButtonStyled,
ContentListPanel, ContentListPanel,
injectNotificationManager,
OverflowMenu, OverflowMenu,
Pagination, Pagination,
RadialHeader, RadialHeader,
Toggle, Toggle,
} from '@modrinth/ui' } from '@modrinth/ui'
import type { ContentItem } from '@modrinth/ui/src/components/content/ContentListItem.vue'
import type { Organization, Project, TeamMember, Version } from '@modrinth/utils' import type { Organization, Project, TeamMember, Version } from '@modrinth/utils'
import { formatProjectType } from '@modrinth/utils' import { formatProjectType } from '@modrinth/utils'
import { getCurrentWebview } from '@tauri-apps/api/webview'
import { defineMessages, useVIntl } from '@vintl/vintl'
import dayjs from 'dayjs'
import type { ComputedRef } from 'vue' import type { ComputedRef } from 'vue'
import { computed, onUnmounted, ref, watch } from 'vue' import { computed, onUnmounted, ref, watch } from 'vue'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { const { handleError } = injectNotificationManager()
add_project_from_path,
get_projects,
remove_project,
toggle_disable_project,
update_all,
update_project,
} from '@/helpers/profile.js'
import { handleError } from '@/store/notifications.js'
import { trackEvent } from '@/helpers/analytics'
import { highlightModInProfile } from '@/helpers/utils.js'
import { TextInputIcon } from '@/assets/icons'
import ExportModal from '@/components/ui/ExportModal.vue'
import ModpackVersionModal from '@/components/ui/ModpackVersionModal.vue'
import AddContentButton from '@/components/ui/AddContentButton.vue'
import {
get_organization_many,
get_project_many,
get_team_many,
get_version_many,
} from '@/helpers/cache.js'
import { profile_listener } from '@/helpers/events.js'
import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue'
import { getCurrentWebview } from '@tauri-apps/api/webview'
import dayjs from 'dayjs'
import type { CacheBehaviour, ContentFile, GameInstance } from '@/helpers/types'
import type ContextMenu from '@/components/ui/ContextMenu.vue'
import type { ContentItem } from '@modrinth/ui/src/components/content/ContentListItem.vue'
const props = defineProps<{ const props = defineProps<{
instance: GameInstance instance: GameInstance

View File

@@ -67,7 +67,8 @@
:key="`world-${world.type}-${world.type == 'singleplayer' ? world.path : `${world.address}-${world.index}`}`" :key="`world-${world.type}-${world.type == 'singleplayer' ? world.path : `${world.address}-${world.index}`}`"
:world="world" :world="world"
:highlighted="highlightedWorld === getWorldIdentifier(world)" :highlighted="highlightedWorld === getWorldIdentifier(world)"
:supports-quick-play="supportsQuickPlay" :supports-server-quick-play="supportsServerQuickPlay"
:supports-world-quick-play="supportsWorldQuickPlay"
:current-protocol="protocolVersion" :current-protocol="protocolVersion"
:playing-instance="playing" :playing-instance="playing"
:playing-world="worldsMatch(world, worldPlaying)" :playing-world="worldsMatch(world, worldPlaying)"
@@ -120,53 +121,55 @@
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onUnmounted, watch } from 'vue' import type ContextMenu from '@/components/ui/ContextMenu.vue'
import { useRoute } from 'vue-router' import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import type { GameInstance } from '@/helpers/types'
import {
Button,
ButtonStyled,
RadialHeader,
FilterBar,
type FilterBarOption,
type GameVersion,
GAME_MODES,
} from '@modrinth/ui'
import { PlusIcon, SpinnerIcon, UpdatedIcon, SearchIcon, XIcon } from '@modrinth/assets'
import {
type SingleplayerWorld,
type World,
type ServerWorld,
type ServerData,
type ProfileEvent,
get_profile_protocol_version,
remove_server_from_profile,
delete_world,
start_join_server,
start_join_singleplayer_world,
getWorldIdentifier,
refreshServerData,
refreshWorld,
sortWorlds,
refreshServers,
hasQuickPlaySupport,
refreshWorlds,
handleDefaultProfileUpdateEvent,
showWorldInFolder,
} from '@/helpers/worlds.ts'
import AddServerModal from '@/components/ui/world/modal/AddServerModal.vue' import AddServerModal from '@/components/ui/world/modal/AddServerModal.vue'
import EditServerModal from '@/components/ui/world/modal/EditServerModal.vue' import EditServerModal from '@/components/ui/world/modal/EditServerModal.vue'
import EditWorldModal from '@/components/ui/world/modal/EditSingleplayerWorldModal.vue' import EditWorldModal from '@/components/ui/world/modal/EditSingleplayerWorldModal.vue'
import WorldItem from '@/components/ui/world/WorldItem.vue' import WorldItem from '@/components/ui/world/WorldItem.vue'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import { handleError } from '@/store/notifications'
import type ContextMenu from '@/components/ui/ContextMenu.vue'
import type { Version } from '@modrinth/utils'
import { profile_listener } from '@/helpers/events' import { profile_listener } from '@/helpers/events'
import { get_game_versions } from '@/helpers/tags' import { get_game_versions } from '@/helpers/tags'
import type { GameInstance } from '@/helpers/types'
import {
type ProfileEvent,
type ProtocolVersion,
type ServerData,
type ServerWorld,
type SingleplayerWorld,
type World,
delete_world,
getWorldIdentifier,
get_profile_protocol_version,
handleDefaultProfileUpdateEvent,
hasServerQuickPlaySupport,
hasWorldQuickPlaySupport,
refreshServerData,
refreshServers,
refreshWorld,
refreshWorlds,
remove_server_from_profile,
showWorldInFolder,
sortWorlds,
start_join_server,
start_join_singleplayer_world,
} from '@/helpers/worlds.ts'
import { PlusIcon, SearchIcon, SpinnerIcon, UpdatedIcon, XIcon } from '@modrinth/assets'
import {
Button,
ButtonStyled,
FilterBar,
type FilterBarOption,
GAME_MODES,
type GameVersion,
RadialHeader,
injectNotificationManager,
} from '@modrinth/ui'
import type { Version } from '@modrinth/utils'
import { defineMessages } from '@vintl/vintl' import { defineMessages } from '@vintl/vintl'
import { computed, onUnmounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
const { handleError } = injectNotificationManager()
const route = useRoute() const route = useRoute()
const addServerModal = ref<InstanceType<typeof AddServerModal>>() const addServerModal = ref<InstanceType<typeof AddServerModal>>()
@@ -210,7 +213,9 @@ const worldPlaying = ref<World>()
const worlds = ref<World[]>([]) const worlds = ref<World[]>([])
const serverData = ref<Record<string, ServerData>>({}) const serverData = ref<Record<string, ServerData>>({})
const protocolVersion = ref<number | null>(await get_profile_protocol_version(instance.value.path)) const protocolVersion = ref<ProtocolVersion | null>(
await get_profile_protocol_version(instance.value.path),
)
const unlistenProfile = await profile_listener(async (e: ProfileEvent) => { const unlistenProfile = await profile_listener(async (e: ProfileEvent) => {
if (e.profile_path_id !== instance.value.path) return if (e.profile_path_id !== instance.value.path) return
@@ -246,7 +251,7 @@ async function refreshAllWorlds() {
worlds.value = await refreshWorlds(instance.value.path).finally( worlds.value = await refreshWorlds(instance.value.path).finally(
() => (refreshingAll.value = false), () => (refreshingAll.value = false),
) )
await refreshServers(worlds.value, serverData.value, protocolVersion.value) refreshServers(worlds.value, serverData.value, protocolVersion.value)
const hasNoWorlds = worlds.value.length === 0 const hasNoWorlds = worlds.value.length === 0
@@ -277,7 +282,7 @@ async function editServer(server: ServerWorld) {
await refreshServer(server.address) await refreshServer(server.address)
} }
} else { } else {
handleError(`Error refreshing server, refreshing all worlds`) handleError(new Error(`Error refreshing server, refreshing all worlds`))
await refreshAllWorlds() await refreshAllWorlds()
} }
} }
@@ -296,7 +301,7 @@ async function editWorld(path: string, name: string, removeIcon: boolean) {
} }
sortWorlds(worlds.value) sortWorlds(worlds.value)
} else { } else {
handleError(`Error finding world in list, refreshing all worlds`) handleError(new Error(`Error finding world in list, refreshing all worlds`))
await refreshAllWorlds() await refreshAllWorlds()
} }
} }
@@ -306,7 +311,7 @@ async function deleteWorld(world: SingleplayerWorld) {
worlds.value = worlds.value.filter((w) => w.type !== 'singleplayer' || w.path !== world.path) worlds.value = worlds.value.filter((w) => w.type !== 'singleplayer' || w.path !== world.path)
} }
function handleJoinError(err: unknown) { function handleJoinError(err: Error) {
handleError(err) handleError(err)
startingInstance.value = false startingInstance.value = false
worldPlaying.value = undefined worldPlaying.value = undefined
@@ -352,8 +357,11 @@ function worldsMatch(world: World, other: World | undefined) {
} }
const gameVersions = ref<GameVersion[]>(await get_game_versions().catch(() => [])) const gameVersions = ref<GameVersion[]>(await get_game_versions().catch(() => []))
const supportsQuickPlay = computed(() => const supportsServerQuickPlay = computed(() =>
hasQuickPlaySupport(gameVersions.value, instance.value.game_version), hasServerQuickPlaySupport(gameVersions.value, instance.value.game_version),
)
const supportsWorldQuickPlay = computed(() =>
hasWorldQuickPlaySupport(gameVersions.value, instance.value.game_version),
) )
const filterOptions = computed(() => { const filterOptions = computed(() => {
@@ -428,7 +436,7 @@ function promptToRemoveWorld(world: World): boolean {
async function proceedRemoveServer() { async function proceedRemoveServer() {
if (!serverToRemove.value) { if (!serverToRemove.value) {
handleError(`Error removing server, no server marked for removal.`) handleError(new Error(`Error removing server, no server marked for removal.`))
return return
} }
await removeServer(serverToRemove.value) await removeServer(serverToRemove.value)
@@ -437,7 +445,7 @@ async function proceedRemoveServer() {
async function proceedDeleteWorld() { async function proceedDeleteWorld() {
if (!worldToDelete.value) { if (!worldToDelete.value) {
handleError(`Error deleting world, no world marked for removal.`) handleError(new Error(`Error deleting world, no world marked for removal.`))
return return
} }
await deleteWorld(worldToDelete.value) await deleteWorld(worldToDelete.value)

View File

@@ -1,16 +1,16 @@
<script setup> <script setup>
import { onUnmounted, ref, shallowRef } from 'vue'
import { list } from '@/helpers/profile.js'
import { useRoute } from 'vue-router'
import { useBreadcrumbs } from '@/store/breadcrumbs.js'
import { profile_listener } from '@/helpers/events.js'
import { handleError } from '@/store/notifications.js'
import { Button } from '@modrinth/ui'
import { PlusIcon } from '@modrinth/assets'
import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue'
import { NewInstanceImage } from '@/assets/icons' import { NewInstanceImage } from '@/assets/icons'
import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue'
import NavTabs from '@/components/ui/NavTabs.vue' import NavTabs from '@/components/ui/NavTabs.vue'
import { profile_listener } from '@/helpers/events.js'
import { list } from '@/helpers/profile.js'
import { useBreadcrumbs } from '@/store/breadcrumbs.js'
import { PlusIcon } from '@modrinth/assets'
import { Button, injectNotificationManager } from '@modrinth/ui'
import { onUnmounted, ref, shallowRef } from 'vue'
import { useRoute } from 'vue-router'
const { handleError } = injectNotificationManager()
const route = useRoute() const route = useRoute()
const breadcrumbs = useBreadcrumbs() const breadcrumbs = useBreadcrumbs()

View File

@@ -129,46 +129,46 @@
</template> </template>
<script setup> <script setup>
import ContextMenu from '@/components/ui/ContextMenu.vue'
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
import NavTabs from '@/components/ui/NavTabs.vue'
import { get_project, get_team, get_version_many } from '@/helpers/cache.js'
import { get as getInstance, get_projects as getInstanceProjects } from '@/helpers/profile'
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { install as installVersion } from '@/store/install.js'
import { useTheming } from '@/store/state.js'
import { import {
BookmarkIcon, BookmarkIcon,
MoreVerticalIcon,
DownloadIcon,
ReportIcon,
HeartIcon,
ExternalIcon,
CheckIcon, CheckIcon,
GlobeIcon,
ClipboardCopyIcon, ClipboardCopyIcon,
DownloadIcon,
ExternalIcon,
GlobeIcon,
HeartIcon,
MoreVerticalIcon,
ReportIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { import {
ProjectHeader,
ProjectSidebarCompatibility,
ButtonStyled, ButtonStyled,
OverflowMenu, OverflowMenu,
ProjectSidebarLinks, ProjectBackgroundGradient,
ProjectHeader,
ProjectSidebarCompatibility,
ProjectSidebarCreators, ProjectSidebarCreators,
ProjectSidebarDetails, ProjectSidebarDetails,
ProjectBackgroundGradient, ProjectSidebarLinks,
injectNotificationManager,
} from '@modrinth/ui' } from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import { get as getInstance, get_projects as getInstanceProjects } from '@/helpers/profile'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime' import relativeTime from 'dayjs/plugin/relativeTime'
import { useRoute, useRouter } from 'vue-router'
import { ref, shallowRef, watch } from 'vue' import { ref, shallowRef, watch } from 'vue'
import { useBreadcrumbs } from '@/store/breadcrumbs' import { useRoute, useRouter } from 'vue-router'
import { handleError } from '@/store/notifications.js'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import { install as installVersion } from '@/store/install.js'
import { get_project, get_team, get_version_many } from '@/helpers/cache.js'
import NavTabs from '@/components/ui/NavTabs.vue'
import { useTheming } from '@/store/state.js'
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
import { openUrl } from '@tauri-apps/plugin-opener'
dayjs.extend(relativeTime) dayjs.extend(relativeTime)
const { handleError } = injectNotificationManager()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const breadcrumbs = useBreadcrumbs() const breadcrumbs = useBreadcrumbs()

View File

@@ -65,12 +65,16 @@
</template> </template>
<script setup> <script setup>
import { ProjectPageVersions, ButtonStyled, OverflowMenu } from '@modrinth/ui'
import { CheckIcon, DownloadIcon, ExternalIcon, MoreVerticalIcon } from '@modrinth/assets'
import { ref } from 'vue'
import { SwapIcon } from '@/assets/icons/index.js' import { SwapIcon } from '@/assets/icons/index.js'
import { get_game_versions, get_loaders } from '@/helpers/tags.js' import { get_game_versions, get_loaders } from '@/helpers/tags.js'
import { handleError } from '@/store/notifications.js' import { CheckIcon, DownloadIcon, ExternalIcon, MoreVerticalIcon } from '@modrinth/assets'
import {
ButtonStyled,
OverflowMenu,
ProjectPageVersions,
injectNotificationManager,
} from '@modrinth/ui'
import { ref } from 'vue'
defineProps({ defineProps({
project: { project: {
@@ -103,6 +107,8 @@ defineProps({
}, },
}) })
const { handleError } = injectNotificationManager()
const [loaders, gameVersions] = await Promise.all([ const [loaders, gameVersions] = await Promise.all([
get_loaders().catch(handleError).then(ref), get_loaders().catch(handleError).then(ref),
get_game_versions().catch(handleError).then(ref), get_game_versions().catch(handleError).then(ref),

View File

@@ -0,0 +1,48 @@
import {
AbstractWebNotificationManager,
type NotificationPanelLocation,
type WebNotification,
} from '@modrinth/ui'
import { ref, type Ref } from 'vue'
export class AppNotificationManager extends AbstractWebNotificationManager {
private readonly state: Ref<WebNotification[]>
private readonly locationState: Ref<NotificationPanelLocation>
public constructor() {
super()
this.state = ref<WebNotification[]>([])
this.locationState = ref<NotificationPanelLocation>('right')
}
public getNotificationLocation(): NotificationPanelLocation {
return this.locationState.value
}
public setNotificationLocation(location: NotificationPanelLocation): void {
this.locationState.value = location
}
public getNotifications(): WebNotification[] {
return this.state.value
}
protected addNotificationToStorage(notification: WebNotification): void {
this.state.value.push(notification)
}
protected removeNotificationFromStorage(id: string | number): void {
const index = this.state.value.findIndex((n) => n.id === id)
if (index > -1) {
this.state.value.splice(index, 1)
}
}
protected removeNotificationFromStorageByIndex(index: number): void {
this.state.value.splice(index, 1)
}
protected clearAllNotificationsFromStorage(): void {
this.state.value.splice(0)
}
}

View File

@@ -1,4 +1,6 @@
import { defineStore } from 'pinia' import { trackEvent } from '@/helpers/analytics.js'
import { get_project, get_version_many } from '@/helpers/cache.js'
import { create_profile_and_install as packInstall } from '@/helpers/pack.js'
import { import {
add_project_from_version, add_project_from_version,
check_installed, check_installed,
@@ -7,11 +9,9 @@ import {
list, list,
remove_project, remove_project,
} from '@/helpers/profile.js' } from '@/helpers/profile.js'
import { handleError } from '@/store/notifications.js' import { injectNotificationManager } from '@modrinth/ui'
import { get_project, get_version_many } from '@/helpers/cache.js'
import { create_profile_and_install as packInstall } from '@/helpers/pack.js'
import { trackEvent } from '@/helpers/analytics.js'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { defineStore } from 'pinia'
export const useInstall = defineStore('installStore', { export const useInstall = defineStore('installStore', {
state: () => ({ state: () => ({
@@ -49,6 +49,7 @@ export const install = async (
callback = () => {}, callback = () => {},
createInstanceCallback = () => {}, createInstanceCallback = () => {},
) => { ) => {
const { handleError } = injectNotificationManager()
const project = await get_project(projectId, 'must_revalidate').catch(handleError) const project = await get_project(projectId, 'must_revalidate').catch(handleError)
if (project.project_type === 'modpack') { if (project.project_type === 'modpack') {
@@ -160,6 +161,7 @@ export const install = async (
} }
export const installVersionDependencies = async (profile, version) => { export const installVersionDependencies = async (profile, version) => {
const { handleError } = injectNotificationManager()
for (const dep of version.dependencies) { for (const dep of version.dependencies) {
if (dep.dependency_type !== 'required') continue if (dep.dependency_type !== 'required') continue
// disallow fabric api install on quilt // disallow fabric api install on quilt

View File

@@ -1,25 +0,0 @@
import { defineStore } from 'pinia'
export const useNotifications = defineStore('notificationsStore', {
state: () => ({
notificationsWrapper: null,
}),
actions: {
setNotifs(notifs) {
this.notificationsWrapper = notifs
},
addNotification(notif) {
this.notificationsWrapper.addNotification(notif)
},
},
})
export const handleError = (err) => {
const notifs = useNotifications()
notifs.addNotification({
title: 'An error occurred',
text: err.message ?? err,
type: 'error',
})
console.error(err)
}

View File

@@ -1,7 +1,6 @@
import { useTheming } from './theme.ts'
import { useBreadcrumbs } from './breadcrumbs' import { useBreadcrumbs } from './breadcrumbs'
import { useLoading } from './loading'
import { useNotifications, handleError } from './notifications'
import { useInstall } from './install' import { useInstall } from './install'
import { useLoading } from './loading'
import { useTheming } from './theme.ts'
export { useTheming, useBreadcrumbs, useLoading, useNotifications, handleError, useInstall } export { useBreadcrumbs, useInstall, useLoading, useTheming }

View File

@@ -15,7 +15,7 @@ pub async fn authenticate_run() -> theseus::Result<Credentials> {
println!("A browser window will now open, follow the login flow there."); println!("A browser window will now open, follow the login flow there.");
let login = minecraft_auth::begin_login().await?; let login = minecraft_auth::begin_login().await?;
println!("Open URL {} in a browser", login.redirect_uri.as_str()); println!("Open URL {} in a browser", login.auth_request_uri.as_str());
println!("Please enter URL code: "); println!("Please enter URL code: ");
let mut input = String::new(); let mut input = String::new();

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "theseus_gui" name = "theseus_gui"
version = "0.10.1" version = "1.0.0-local" # The actual version is set by the theseus-build workflow on tagging
description = "The Modrinth App is a desktop application for managing your Minecraft mods" description = "The Modrinth App is a desktop application for managing your Minecraft mods"
license = "GPL-3.0-only" license = "GPL-3.0-only"
repository = "https://github.com/modrinth/code/apps/app/" repository = "https://github.com/modrinth/code/apps/app/"
@@ -31,6 +31,8 @@ thiserror.workspace = true
daedalus.workspace = true daedalus.workspace = true
chrono.workspace = true chrono.workspace = true
either.workspace = true either.workspace = true
hyper = { workspace = true, features = ["server"] }
hyper-util.workspace = true
url.workspace = true url.workspace = true
urlencoding.workspace = true urlencoding.workspace = true

View File

@@ -18,5 +18,25 @@
<string>A Minecraft mod wants to access your camera.</string> <string>A Minecraft mod wants to access your camera.</string>
<key>NSMicrophoneUsageDescription</key> <key>NSMicrophoneUsageDescription</key>
<string>A Minecraft mod wants to access your microphone.</string> <string>A Minecraft mod wants to access your microphone.</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>asset.localhost</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
<key>textures.minecraft.net</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
</dict>
</dict>
</dict> </dict>
</plist> </plist>

View File

@@ -120,7 +120,12 @@ fn main() {
.plugin( .plugin(
"mr-auth", "mr-auth",
InlinedPlugin::new() InlinedPlugin::new()
.commands(&["modrinth_login", "logout", "get"]) .commands(&[
"modrinth_login",
"logout",
"get",
"cancel_modrinth_login",
])
.default_permission( .default_permission(
DefaultPermissionRule::AllowAllCommands, DefaultPermissionRule::AllowAllCommands,
), ),

View File

@@ -33,7 +33,7 @@ pub async fn login<R: Runtime>(
let window = tauri::WebviewWindowBuilder::new( let window = tauri::WebviewWindowBuilder::new(
&app, &app,
"signin", "signin",
tauri::WebviewUrl::External(flow.redirect_uri.parse().map_err( tauri::WebviewUrl::External(flow.auth_request_uri.parse().map_err(
|_| { |_| {
theseus::ErrorKind::OtherError( theseus::ErrorKind::OtherError(
"Error parsing auth redirect URL".to_string(), "Error parsing auth redirect URL".to_string(),
@@ -77,6 +77,7 @@ pub async fn login<R: Runtime>(
window.close()?; window.close()?;
Ok(None) Ok(None)
} }
#[tauri::command] #[tauri::command]
pub async fn remove_user(user: uuid::Uuid) -> Result<()> { pub async fn remove_user(user: uuid::Uuid) -> Result<()> {
Ok(minecraft_auth::remove_user(user).await?) Ok(minecraft_auth::remove_user(user).await?)

View File

@@ -22,6 +22,8 @@ pub mod cache;
pub mod friends; pub mod friends;
pub mod worlds; pub mod worlds;
mod oauth_utils;
pub type Result<T> = std::result::Result<T, TheseusSerializableError>; pub type Result<T> = std::result::Result<T, TheseusSerializableError>;
// // Main returnable Theseus GUI error // // Main returnable Theseus GUI error

View File

@@ -1,79 +1,70 @@
use crate::api::Result; use crate::api::Result;
use chrono::{Duration, Utc}; use crate::api::TheseusSerializableError;
use crate::api::oauth_utils;
use tauri::Manager;
use tauri::Runtime;
use tauri::plugin::TauriPlugin; use tauri::plugin::TauriPlugin;
use tauri::{Manager, Runtime, UserAttentionType}; use tauri_plugin_opener::OpenerExt;
use theseus::prelude::*; use theseus::prelude::*;
use tokio::sync::oneshot;
pub fn init<R: tauri::Runtime>() -> TauriPlugin<R> { pub fn init<R: tauri::Runtime>() -> TauriPlugin<R> {
tauri::plugin::Builder::new("mr-auth") tauri::plugin::Builder::new("mr-auth")
.invoke_handler(tauri::generate_handler![modrinth_login, logout, get,]) .invoke_handler(tauri::generate_handler![
modrinth_login,
logout,
get,
cancel_modrinth_login,
])
.build() .build()
} }
#[tauri::command] #[tauri::command]
pub async fn modrinth_login<R: Runtime>( pub async fn modrinth_login<R: Runtime>(
app: tauri::AppHandle<R>, app: tauri::AppHandle<R>,
) -> Result<Option<ModrinthCredentials>> { ) -> Result<ModrinthCredentials> {
let redirect_uri = mr_auth::authenticate_begin_flow(); let (auth_code_recv_socket_tx, auth_code_recv_socket) = oneshot::channel();
let auth_code = tokio::spawn(oauth_utils::auth_code_reply::listen(
auth_code_recv_socket_tx,
));
let start = Utc::now(); let auth_code_recv_socket = auth_code_recv_socket.await.unwrap()?;
if let Some(window) = app.get_webview_window("modrinth-signin") { let auth_request_uri = format!(
window.close()?; "{}?launcher=true&ipver={}&port={}",
} mr_auth::authenticate_begin_flow(),
if auth_code_recv_socket.is_ipv4() {
"4"
} else {
"6"
},
auth_code_recv_socket.port()
);
let window = tauri::WebviewWindowBuilder::new( app.opener()
&app, .open_url(auth_request_uri, None::<&str>)
"modrinth-signin", .map_err(|e| {
tauri::WebviewUrl::External(redirect_uri.parse().map_err(|_| { TheseusSerializableError::Theseus(
theseus::ErrorKind::OtherError( theseus::ErrorKind::OtherError(format!(
"Error parsing auth redirect URL".to_string(), "Failed to open auth request URI: {e}"
))
.into(),
) )
.as_error() })?;
})?),
)
.min_inner_size(420.0, 632.0)
.inner_size(420.0, 632.0)
.max_inner_size(420.0, 632.0)
.zoom_hotkeys_enabled(false)
.title("Sign into Modrinth")
.always_on_top(true)
.center()
.build()?;
window.request_user_attention(Some(UserAttentionType::Critical))?; let Some(auth_code) = auth_code.await.unwrap()? else {
return Err(TheseusSerializableError::Theseus(
theseus::ErrorKind::OtherError("Login canceled".into()).into(),
));
};
while (Utc::now() - start) < Duration::minutes(10) { let credentials = mr_auth::authenticate_finish_flow(&auth_code).await?;
if window.title().is_err() {
// user closed window, cancelling flow
return Ok(None);
}
if window if let Some(main_window) = app.get_window("main") {
.url()? main_window.set_focus().ok();
.as_str()
.starts_with("https://launcher-files.modrinth.com")
{
let url = window.url()?;
let code = url.query_pairs().find(|(key, _)| key == "code");
window.close()?;
return if let Some((_, code)) = code {
let val = mr_auth::authenticate_finish_flow(&code).await?;
Ok(Some(val))
} else {
Ok(None)
};
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
} }
window.close()?; Ok(credentials)
Ok(None)
} }
#[tauri::command] #[tauri::command]
@@ -85,3 +76,8 @@ pub async fn logout() -> Result<()> {
pub async fn get() -> Result<Option<ModrinthCredentials>> { pub async fn get() -> Result<Option<ModrinthCredentials>> {
Ok(theseus::mr_auth::get_credentials().await?) Ok(theseus::mr_auth::get_credentials().await?)
} }
#[tauri::command]
pub fn cancel_modrinth_login() {
oauth_utils::auth_code_reply::stop_listeners();
}

View File

@@ -0,0 +1,159 @@
//! A minimal OAuth 2.0 authorization code grant flow redirection/reply loopback URI HTTP
//! server implementation, compliant with [RFC 6749]'s authorization code grant flow and
//! [RFC 8252]'s best current practices for OAuth 2.0 in native apps.
//!
//! This server is needed for the step 4 of the OAuth authentication dance represented in
//! figure 1 of [RFC 8252].
//!
//! Further reading: https://www.oauth.com/oauth2-servers/oauth-native-apps/redirect-urls-for-native-apps/
//!
//! [RFC 6749]: https://datatracker.ietf.org/doc/html/rfc6749
//! [RFC 8252]: https://datatracker.ietf.org/doc/html/rfc8252
use std::{
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
sync::{LazyLock, Mutex},
time::Duration,
};
use hyper::body::Incoming;
use hyper_util::rt::{TokioIo, TokioTimer};
use theseus::ErrorKind;
use tokio::{
net::TcpListener,
sync::{broadcast, oneshot},
};
static SERVER_SHUTDOWN: LazyLock<broadcast::Sender<()>> =
LazyLock::new(|| broadcast::channel(1024).0);
/// Starts a temporary HTTP server to receive OAuth 2.0 authorization code grant flow redirects
/// on a loopback interface with an ephemeral port. The caller can know the bound socket address
/// by listening on the counterpart channel for `listen_socket_tx`.
///
/// If the server is stopped before receiving an authorization code, `Ok(None)` is returned.
pub async fn listen(
listen_socket_tx: oneshot::Sender<Result<SocketAddr, theseus::Error>>,
) -> Result<Option<String>, theseus::Error> {
// IPv4 is tried first for the best compatibility and performance with most systems.
// IPv6 is also tried in case IPv4 is not available. Resolving "localhost" is avoided
// to prevent failures deriving from improper name resolution setup. Any available
// ephemeral port is used to prevent conflicts with other services. This is all as per
// RFC 8252's recommendations
const ANY_LOOPBACK_SOCKET: &[SocketAddr] = &[
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 0),
];
let listener = match TcpListener::bind(ANY_LOOPBACK_SOCKET).await {
Ok(listener) => {
listen_socket_tx
.send(listener.local_addr().map_err(|e| {
ErrorKind::OtherError(format!(
"Failed to get auth code reply socket address: {e}"
))
.into()
}))
.ok();
listener
}
Err(e) => {
let error_msg =
format!("Failed to bind auth code reply socket: {e}");
listen_socket_tx
.send(Err(ErrorKind::OtherError(error_msg.clone()).into()))
.ok();
return Err(ErrorKind::OtherError(error_msg).into());
}
};
let mut auth_code = Mutex::new(None);
let mut shutdown_notification = SERVER_SHUTDOWN.subscribe();
while auth_code.get_mut().unwrap().is_none() {
let client_socket = tokio::select! {
biased;
_ = shutdown_notification.recv() => {
break;
}
conn_accept_result = listener.accept() => {
match conn_accept_result {
Ok((socket, _)) => socket,
Err(e) => {
tracing::warn!("Failed to accept auth code reply: {e}");
continue;
}
}
}
};
if let Err(e) = hyper::server::conn::http1::Builder::new()
.keep_alive(false)
.header_read_timeout(Duration::from_secs(5))
.timer(TokioTimer::new())
.auto_date_header(false)
.serve_connection(
TokioIo::new(client_socket),
hyper::service::service_fn(|req| handle_reply(req, &auth_code)),
)
.await
{
tracing::warn!("Failed to handle auth code reply: {e}");
}
}
Ok(auth_code.into_inner().unwrap())
}
/// Stops any active OAuth 2.0 authorization code grant flow reply listening HTTP servers.
pub fn stop_listeners() {
SERVER_SHUTDOWN.send(()).ok();
}
async fn handle_reply(
req: hyper::Request<Incoming>,
auth_code_out: &Mutex<Option<String>>,
) -> Result<hyper::Response<String>, hyper::http::Error> {
if req.method() != hyper::Method::GET {
return hyper::Response::builder()
.status(hyper::StatusCode::METHOD_NOT_ALLOWED)
.header("Allow", "GET")
.body("".into());
}
// The authorization code is guaranteed to be sent as a "code" query parameter
// in the request URI query string as per RFC 6749 § 4.1.2
let auth_code = req.uri().query().and_then(|query_string| {
query_string
.split('&')
.filter_map(|query_pair| query_pair.split_once('='))
.find_map(|(key, value)| (key == "code").then_some(value))
});
let response = if let Some(auth_code) = auth_code {
*auth_code_out.lock().unwrap() = Some(auth_code.to_string());
hyper::Response::builder()
.status(hyper::StatusCode::OK)
.header("Content-Type", "text/html;charset=utf-8")
.body(
include_str!("auth_code_reply/page.html")
.replace("{{title}}", "Success")
.replace("{{message}}", "You have successfully signed in! You can close this page now."),
)
} else {
hyper::Response::builder()
.status(hyper::StatusCode::BAD_REQUEST)
.header("Content-Type", "text/html;charset=utf-8")
.body(
include_str!("auth_code_reply/page.html")
.replace("{{title}}", "Error")
.replace("{{message}}", "Authorization code not found. Please try signing in again."),
)
}?;
Ok(response)
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,3 @@
//! Assorted utilities for OAuth 2.0 authorization flows.
pub mod auth_code_reply;

View File

@@ -250,7 +250,7 @@ pub async fn profile_get_pack_export_candidates(
// invoke('plugin:profile|profile_run', path) // invoke('plugin:profile|profile_run', path)
#[tauri::command] #[tauri::command]
pub async fn profile_run(path: &str) -> Result<ProcessMetadata> { pub async fn profile_run(path: &str) -> Result<ProcessMetadata> {
let process = profile::run(path, &QuickPlayType::None).await?; let process = profile::run(path, QuickPlayType::None).await?;
Ok(process) Ok(process)
} }

View File

@@ -4,9 +4,10 @@ use enumset::EnumSet;
use tauri::{AppHandle, Manager, Runtime}; use tauri::{AppHandle, Manager, Runtime};
use theseus::prelude::ProcessMetadata; use theseus::prelude::ProcessMetadata;
use theseus::profile::{QuickPlayType, get_full_path}; use theseus::profile::{QuickPlayType, get_full_path};
use theseus::server_address::ServerAddress;
use theseus::worlds::{ use theseus::worlds::{
DisplayStatus, ServerPackStatus, ServerStatus, World, WorldType, DisplayStatus, ProtocolVersion, ServerPackStatus, ServerStatus, World,
WorldWithProfile, WorldType, WorldWithProfile,
}; };
use theseus::{profile, worlds}; use theseus::{profile, worlds};
@@ -183,14 +184,16 @@ pub async fn remove_server_from_profile(
} }
#[tauri::command] #[tauri::command]
pub async fn get_profile_protocol_version(path: &str) -> Result<Option<i32>> { pub async fn get_profile_protocol_version(
path: &str,
) -> Result<Option<ProtocolVersion>> {
Ok(worlds::get_profile_protocol_version(path).await?) Ok(worlds::get_profile_protocol_version(path).await?)
} }
#[tauri::command] #[tauri::command]
pub async fn get_server_status( pub async fn get_server_status(
address: &str, address: &str,
protocol_version: Option<i32>, protocol_version: Option<ProtocolVersion>,
) -> Result<ServerStatus> { ) -> Result<ServerStatus> {
Ok(worlds::get_server_status(address, protocol_version).await?) Ok(worlds::get_server_status(address, protocol_version).await?)
} }
@@ -201,7 +204,7 @@ pub async fn start_join_singleplayer_world(
world: String, world: String,
) -> Result<ProcessMetadata> { ) -> Result<ProcessMetadata> {
let process = let process =
profile::run(path, &QuickPlayType::Singleplayer(world)).await?; profile::run(path, QuickPlayType::Singleplayer(world)).await?;
Ok(process) Ok(process)
} }
@@ -211,8 +214,11 @@ pub async fn start_join_server(
path: &str, path: &str,
address: &str, address: &str,
) -> Result<ProcessMetadata> { ) -> Result<ProcessMetadata> {
let process = let process = profile::run(
profile::run(path, &QuickPlayType::Server(address.to_owned())).await?; path,
QuickPlayType::Server(ServerAddress::Unresolved(address.to_owned())),
)
.await?;
Ok(process) Ok(process)
} }

View File

@@ -41,7 +41,7 @@
] ]
}, },
"productName": "Modrinth App", "productName": "Modrinth App",
"version": "0.10.1", "version": "../app-frontend/package.json",
"mainBinaryName": "Modrinth App", "mainBinaryName": "Modrinth App",
"identifier": "ModrinthApp", "identifier": "ModrinthApp",
"plugins": { "plugins": {
@@ -63,6 +63,7 @@
"height": 800, "height": 800,
"resizable": true, "resizable": true,
"title": "Modrinth App", "title": "Modrinth App",
"label": "main",
"width": 1280, "width": 1280,
"minHeight": 700, "minHeight": 700,
"minWidth": 1100, "minWidth": 1100,

View File

@@ -1,21 +1,27 @@
# syntax=docker/dockerfile:1
FROM rust:1.88.0 AS build FROM rust:1.88.0 AS build
ENV PKG_CONFIG_ALLOW_CROSS=1
WORKDIR /usr/src/daedalus WORKDIR /usr/src/daedalus
COPY . . COPY . .
RUN cargo build --release --package daedalus_client RUN --mount=type=cache,target=/usr/src/daedalus/target \
--mount=type=cache,target=/usr/local/cargo/git/db \
--mount=type=cache,target=/usr/local/cargo/registry \
cargo build --release --package daedalus_client
FROM build AS artifacts
RUN --mount=type=cache,target=/usr/src/daedalus/target \
mkdir /daedalus \
&& cp /usr/src/daedalus/target/release/daedalus_client /daedalus/daedalus_client
FROM debian:bookworm-slim FROM debian:bookworm-slim
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates openssl \ && apt-get install -y --no-install-recommends ca-certificates openssl \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN update-ca-certificates COPY --from=artifacts /daedalus /daedalus
COPY --from=build /usr/src/daedalus/target/release/daedalus_client /daedalus/daedalus_client
WORKDIR /daedalus_client WORKDIR /daedalus_client
CMD ["/daedalus/daedalus_client"]
CMD /daedalus/daedalus_client

View File

@@ -19,8 +19,6 @@ From there, you can create the database and perform all database migrations with
sqlx database setup sqlx database setup
``` ```
Finally, if on Linux, you will need the OpenSSL library. On Debian-based systems, this involves the `pkg-config` and `libssl-dev` packages.
To enable labrinth to create a project, you need to add two things. To enable labrinth to create a project, you need to add two things.
1. An entry in the `loaders` table. 1. An entry in the `loaders` table.

View File

@@ -38,9 +38,10 @@
"@intercom/messenger-js-sdk": "^0.0.14", "@intercom/messenger-js-sdk": "^0.0.14",
"@ltd/j-toml": "^1.38.0", "@ltd/j-toml": "^1.38.0",
"@modrinth/assets": "workspace:*", "@modrinth/assets": "workspace:*",
"@modrinth/blog": "workspace:*",
"@modrinth/moderation": "workspace:*",
"@modrinth/ui": "workspace:*", "@modrinth/ui": "workspace:*",
"@modrinth/utils": "workspace:*", "@modrinth/utils": "workspace:*",
"@modrinth/blog": "workspace:*",
"@pinia/nuxt": "^0.5.1", "@pinia/nuxt": "^0.5.1",
"@types/three": "^0.172.0", "@types/three": "^0.172.0",
"@vintl/vintl": "^4.4.1", "@vintl/vintl": "^4.4.1",
@@ -58,9 +59,12 @@
"markdown-it": "14.1.0", "markdown-it": "14.1.0",
"pathe": "^1.1.2", "pathe": "^1.1.2",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^4.4.1",
"prettier": "^3.6.2",
"qrcode.vue": "^3.4.0", "qrcode.vue": "^3.4.0",
"semver": "^7.5.4", "semver": "^7.5.4",
"three": "^0.172.0", "three": "^0.172.0",
"vue-confetti-explosion": "^1.0.2",
"vue-multiselect": "3.0.0-alpha.2", "vue-multiselect": "3.0.0-alpha.2",
"vue-typed-virtual-list": "^1.0.10", "vue-typed-virtual-list": "^1.0.10",
"vue3-ace-editor": "^2.2.4", "vue3-ace-editor": "^2.2.4",

View File

@@ -1,11 +1,14 @@
<template> <template>
<NuxtLayout> <NuxtLayout>
<ModrinthLoadingIndicator /> <ModrinthLoadingIndicator />
<Notifications /> <NotificationPanel />
<NuxtPage /> <NuxtPage />
</NuxtLayout> </NuxtLayout>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { NotificationPanel, provideNotificationManager } from "@modrinth/ui";
import { FrontendNotificationManager } from "./providers/frontend-notifications.ts";
import ModrinthLoadingIndicator from "~/components/ui/modrinth-loading-indicator.ts"; import ModrinthLoadingIndicator from "~/components/ui/modrinth-loading-indicator.ts";
import Notifications from "~/components/ui/Notifications.vue";
provideNotificationManager(new FrontendNotificationManager());
</script> </script>

View File

@@ -197,13 +197,13 @@
} }
> :where( > :where(
input + *, input + *,
.input-group + *, .input-group + *,
.textarea-wrapper + *, .textarea-wrapper + *,
.chips + *, .chips + *,
.resizable-textarea-wrapper + *, .resizable-textarea-wrapper + *,
.input-div + * .input-div + *
) { ) {
&:not(:empty) { &:not(:empty) {
margin-block-start: var(--spacing-card-md); margin-block-start: var(--spacing-card-md);
} }

View File

@@ -115,10 +115,12 @@ html {
--shadow-inset-sm: inset 0px -1px 2px hsla(221, 39%, 11%, 0.15); --shadow-inset-sm: inset 0px -1px 2px hsla(221, 39%, 11%, 0.15);
--shadow-raised-lg: 0px 2px 4px hsla(221, 39%, 11%, 0.2); --shadow-raised-lg: 0px 2px 4px hsla(221, 39%, 11%, 0.2);
--shadow-raised: 0.3px 0.5px 0.6px hsl(var(--shadow-color) / 0.15), --shadow-raised:
0.3px 0.5px 0.6px hsl(var(--shadow-color) / 0.15),
1px 2px 2.2px -1.7px hsl(var(--shadow-color) / 0.12), 1px 2px 2.2px -1.7px hsl(var(--shadow-color) / 0.12),
4.4px 8.8px 9.7px -3.4px hsl(var(--shadow-color) / 0.09); 4.4px 8.8px 9.7px -3.4px hsl(var(--shadow-color) / 0.09);
--shadow-floating: hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, --shadow-floating:
hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, hsla(0, 0%, 0%, 0.1) 0px 2px 4px -1px; hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, hsla(0, 0%, 0%, 0.1) 0px 2px 4px -1px;
--shadow-card: rgba(50, 50, 100, 0.1) 0px 2px 4px 0px; --shadow-card: rgba(50, 50, 100, 0.1) 0px 2px 4px 0px;
@@ -150,8 +152,8 @@ html {
rgba(255, 255, 255, 0.35) 0%, rgba(255, 255, 255, 0.35) 0%,
rgba(255, 255, 255, 0.2695) 100% rgba(255, 255, 255, 0.2695) 100%
); );
--landing-blob-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16), --landing-blob-shadow:
inset 2px 2px 64px rgba(255, 255, 255, 0.45); 2px 2px 12px rgba(0, 0, 0, 0.16), inset 2px 2px 64px rgba(255, 255, 255, 0.45);
--landing-card-bg: rgba(255, 255, 255, 0.8); --landing-card-bg: rgba(255, 255, 255, 0.8);
--landing-card-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16); --landing-card-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16);
@@ -251,13 +253,15 @@ html {
--shadow-raised-lg: 0px 2px 4px hsla(221, 39%, 11%, 0.2); --shadow-raised-lg: 0px 2px 4px hsla(221, 39%, 11%, 0.2);
--shadow-raised: 0px -2px 4px hsla(221, 39%, 11%, 0.1); --shadow-raised: 0px -2px 4px hsla(221, 39%, 11%, 0.1);
--shadow-floating: hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, --shadow-floating:
hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.06) 0px 2px 4px -1px; hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.06) 0px 2px 4px -1px;
--shadow-card: rgba(0, 0, 0, 0.25) 0px 2px 4px 0px; --shadow-card: rgba(0, 0, 0, 0.25) 0px 2px 4px 0px;
--landing-maze-bg: url("https://cdn.modrinth.com/landing-new/landing.webp"); --landing-maze-bg: url("https://cdn.modrinth.com/landing-new/landing.webp");
--landing-maze-gradient-bg: linear-gradient(0deg, #31375f 0%, rgba(8, 14, 55, 0) 100%), --landing-maze-gradient-bg:
linear-gradient(0deg, #31375f 0%, rgba(8, 14, 55, 0) 100%),
url("https://cdn.modrinth.com/landing-new/landing-lower.webp"); url("https://cdn.modrinth.com/landing-new/landing-lower.webp");
--landing-maze-outer-bg: linear-gradient(180deg, #06060d 0%, #000000 100%); --landing-maze-outer-bg: linear-gradient(180deg, #06060d 0%, #000000 100%);
@@ -284,7 +288,8 @@ html {
rgba(44, 48, 79, 0.35) 0%, rgba(44, 48, 79, 0.35) 0%,
rgba(32, 35, 50, 0.2695) 100% rgba(32, 35, 50, 0.2695) 100%
); );
--landing-blob-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16), inset 2px 2px 64px rgba(57, 61, 94, 0.45); --landing-blob-shadow:
2px 2px 12px rgba(0, 0, 0, 0.16), inset 2px 2px 64px rgba(57, 61, 94, 0.45);
--landing-card-bg: rgba(59, 63, 85, 0.15); --landing-card-bg: rgba(59, 63, 85, 0.15);
--landing-card-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16); --landing-card-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16);
@@ -360,8 +365,9 @@ body {
// Defaults // Defaults
background-color: var(--color-bg); background-color: var(--color-bg);
color: var(--color-text); color: var(--color-text);
--font-standard: Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, --font-standard:
Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell,
Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
--mono-font: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace; --mono-font: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
font-family: var(--font-standard); font-family: var(--font-standard);
font-size: 16px; font-size: 16px;

View File

@@ -51,7 +51,9 @@
<script setup> <script setup>
import { PlusIcon, XIcon } from "@modrinth/assets"; import { PlusIcon, XIcon } from "@modrinth/assets";
import { ButtonStyled, NewModal } from "@modrinth/ui"; import { ButtonStyled, NewModal } from "@modrinth/ui";
import { injectNotificationManager } from "@modrinth/ui";
const { addNotification } = injectNotificationManager();
const router = useNativeRouter(); const router = useNativeRouter();
const name = ref(""); const name = ref("");
@@ -87,7 +89,6 @@ async function create() {
await router.push(`/collection/${result.id}`); await router.push(`/collection/${result.id}`);
} catch (err) { } catch (err) {
addNotification({ addNotification({
group: "main",
title: "An error occurred", title: "An error occurred",
text: err?.data?.description || err?.message || err, text: err?.data?.description || err?.message || err,
type: "error", type: "error",

View File

@@ -84,8 +84,11 @@
</template> </template>
<script setup> <script setup>
import { NewModal, ButtonStyled, DropdownSelect } from "@modrinth/ui"; import { PlusIcon, XIcon } from "@modrinth/assets";
import { XIcon, PlusIcon } from "@modrinth/assets"; import { ButtonStyled, DropdownSelect, NewModal } from "@modrinth/ui";
import { injectNotificationManager } from "@modrinth/ui";
const { addNotification } = injectNotificationManager();
const router = useRouter(); const router = useRouter();
const app = useNuxtApp(); const app = useNuxtApp();
@@ -180,8 +183,7 @@ async function createProject() {
}, },
}); });
} catch (err) { } catch (err) {
app.$notify({ addNotification({
group: "main",
title: "An error occurred", title: "An error occurred",
text: err.data ? err.data.description : err, text: err.data ? err.data.description : err,
type: "error", type: "error",

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