140 Commits

Author SHA1 Message Date
f90998157d Merge branch 'beta' into release 2025-07-24 16:39:31 +03:00
634000cdb6 Merge commit '15892a88d345f7ff67e2e46e298560afb635ac23' into beta 2025-07-24 16:38:58 +03:00
tomasalias
5fd8c38c1c Implement Curseforge profile codes 2025-07-24 03:41:41 +02: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
46d30e491a ci: another fix 2025-07-21 02:20:12 +03:00
059c0618f1 ci: reconfigure output bundles 2025-07-21 02:00:52 +03:00
7ef60fcafe fix: incorrect authlib injector setup in special cases 2025-07-21 02:00:17 +03:00
ec17e79014 Merge pull request 'feature-elyby-account' (#9) from feature-elyby-account into beta
Reviewed-on: didirus/AstralRinth#9
2025-07-21 00:49:30 +03:00
e351d674f4 refactor: Improves some features in our utils.rs 2025-07-21 00:41:23 +03:00
f555fa916a (WIP) feat: ely.by account authentication 2025-07-20 08:10:04 +03:00
dbe38cb4e7 Merge commit 'ae25a15abd6e78be3d5dbf8f23aa1a5cdc53531e' into feature-elyby-account 2025-07-20 02:09:53 +03:00
2e40e26116 Merge commit 'a8caa1afc3115cc79da25d8129e749932c7dc2a5' into feature-elyby-account 2025-07-20 02:08:02 +03: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
10afd673db add download authlib injector support 2025-07-17 00:54:26 +03: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
5a10292add feat: add support for multiple account types in database 2025-07-16 20:33:58 +03:00
3f606a08aa Merge commit 'eb595cdc3e4a6953cbde00c0e119e476ef767a52' into beta 2025-07-16 14:34:07 +03:00
2d5d747202 update readme markdown files, added RUS language 2025-07-16 13:49:50 +03: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
7516ff9e47 Merge pull request 'feature-elyby-skins' (#8) from feature-elyby-skins into beta
Reviewed-on: didirus/AstralRinth#8
2025-07-16 13:13:03 +03: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
df9bbe3ba0 chore: add patch file to patches directory 2025-07-16 02:31:59 +03:00
362fd7f32a feat: Implement Ely By skin system 2025-07-16 02:27:48 +03: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
7716a0c524 Merge pull request 'beta' (#7) from beta into release
Reviewed-on: didirus/AstralRinth#7
2025-07-15 00:47:12 +03: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
adf831dab9 Merge commit 'efeac22d14fb6e782869d56d5d65685667721e4a' into feature-elyby-skins 2025-07-11 04:41:14 +03:00
efeac22d14 Merge pull request 'feature-improve-updater' (#6) from feature-improve-updater into beta
Reviewed-on: didirus/AstralRinth#6
2025-07-11 04:10:01 +03:00
591d98a9eb fix: crlf hash? 2025-07-11 03:56:11 +03:00
77472d9a09 fix: crlf hash? 2025-07-11 03:46:33 +03:00
789d666515 refactor: windows auto updater only works with signed app 2025-07-11 03:26:04 +03:00
d917bff6ef feat: add ability to auto exec downloaded installer on windows; minor changes 2025-07-11 03:04:37 +03:00
4e69cd8bde feat: add auto application restart after migration successful fix attempt 2025-07-11 02:38:23 +03:00
b71e4cc6f9 refactor: update checker moved to App.vue, added new animated icons 2025-07-11 02:29:05 +03:00
a56ab6adb9 refactor: move updates to settings 2025-07-11 01:34:31 +03:00
f1b67c9584 refactor: improve ErrorModal.vue 2025-07-10 23:12:47 +03:00
3d32640b83 refactor: comments 2025-07-10 21:32:44 +03:00
6dfb599e14 Merge pull request 'feature-another-migration-fix' (#5) from feature-another-migration-fix into beta
Reviewed-on: didirus/AstralRinth#5
2025-07-10 21:18:40 +03:00
332a543f66 fix: added ability for regenerate checksums with issued mr migrations. 2025-07-10 21:09:06 +03:00
1ef96c447e ci: patch validating git config on windows runner 2025-07-10 16:30:04 +03:00
1ec92b5f97 ci: add steps with LF & CRLF checks 2025-07-10 15:59:17 +03:00
f0a4532051 ci: update astralrinth-build.yml 2025-07-10 15:53:26 +03: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
14f6450cf4 Merge commit '14bf06e4bd7a70a2c6483e6bbb9712cb9ff84486' into feature-elyby-skins 2025-07-10 01:07:43 +03:00
14bf06e4bd Merge commit 'cb72d2ac80910cf01c9d2025d04d772fb8397abd' into beta 2025-07-10 01:07:09 +03: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
97bd18c7b3 Merge commit '8af0288274bedbd78db03530b10aca0c5a3f13f1' into feature-elyby-skins 2025-07-10 00:01:12 +03:00
8af0288274 Merge commit '36ad1f16e46333cd85d4719d8ecfcfd745167075' into beta 2025-07-09 23:57:16 +03:00
167072de0c Merge pull request 'feat: added fake host for bypass minecraft account type (MSA) check on 1.16.4/5.' (#4) from feature-mp-button-fix into beta
Reviewed-on: didirus/AstralRinth#4
2025-07-09 23:49:07 +03:00
2df37be9a7 feat: added fake host for bypass minecraft account type (MSA) check on 1.16.4/5. 2025-07-09 23:45:21 +03:00
34d85a03b2 Merge commit '17cf5e31321ef9c3a4f95489eb18d33818fb2090' into feature-elyby-skins 2025-07-09 22:58:27 +03:00
17cf5e3132 Merge pull request 'feature-fix-db' (#3) from feature-fix-db into beta
Reviewed-on: didirus/AstralRinth#3
2025-07-09 00:56:09 +03: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
c5e67a5c6f fix: typo 2025-07-08 23:43:50 +03:00
e2e21c1496 fix: another try to fix x86 windows arch 2025-07-08 23:40:55 +03:00
6da942ccbb fix: Ignore x86 windows arch 2025-07-08 23:32:33 +03: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
0ab4dec62d Merge pull request 'v0.10.302' (#2) from feature-clean into beta
Reviewed-on: didirus/AstralRinth#2
2025-07-08 18:00:08 +03:00
Prospector
f256ef43c0 Add x-archon-request header 2025-07-07 22:16:26 -07:00
3ecb20afd6 chore: store db fix patch file in all patches 2025-07-08 05:15:01 +03:00
1e10f24efe fix: typo 2025-07-08 05:06:39 +03:00
006fd7c7f5 fix: another sqlx migrations fix 2025-07-08 04:59:33 +03:00
1e8e001eb8 fix: Impl. fixes for all known migration issues from modrinth authors 2025-07-08 04:25:37 +03:00
585935c799 fix: Impl. fix for migration 20240711194701 2025-07-08 03:42:03 +03:00
Prospector
e0cde2d6ff Revert "fix: error handling improvements (#3797)"
This reverts commit 706976439d.
2025-07-07 17:37:43 -07:00
a64c3360d2 refactor: remove unnecessary code lines 2025-07-08 03:08:27 +03: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
a2b2711204 refactor: Improve update.js and RunningAppBar.vue.
Bump to v0.10.302
2025-07-08 01:12:16 +03: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
ab57926e44 ref: Remove unused workflow steps 2025-07-07 19:20:40 +03:00
35cd79727a Merge commit 'c47bcf665d0686db29732629b36113d3b25915af' into feature-clean 2025-07-07 19:04:00 +03: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
fba296215d fix for gitea 2025-07-07 18:37:14 +03:00
d7e03fe2be another fix for github actions 2025-07-07 18:20:37 +03:00
ba88244571 fix? 2025-07-07 18:18:57 +03:00
d6d77256fe fix workflow 2025-07-07 18:01:20 +03:00
7449a209fb update workflow 2025-07-07 17:57:14 +03:00
81852859ca update workflow and tauri config 2025-07-07 17:21:03 +03:00
9bd87cf986 Merge commit 'bc90c27e27df60f95a1fdc3572fb0bd5aa4fd102' into feature-clean 2025-07-07 17:14:06 +03: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
f914ea1c7d fix: Ubuntu CI/CD building 2025-07-06 01:38:26 +03:00
f55da799f1 fix: Skins (beta) incorrect loading and parsing 2025-07-06 01:38:12 +03: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
329 changed files with 17174 additions and 2036 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

153
.github/workflows/astralrinth-build.yml vendored Normal file
View File

@@ -0,0 +1,153 @@
name: AstralRinth App build
on:
push:
branches:
- main
- feature*
tags:
- 'v*'
paths:
- .github/workflows/astralrinth-build.yml
- 'apps/app/**'
- 'apps/app-frontend/**'
- 'packages/app-lib/**'
- 'packages/app-macros/**'
- 'packages/assets/**'
- 'packages/ui/**'
- 'packages/utils/**'
workflow_dispatch:
jobs:
build:
name: Build
strategy:
fail-fast: false
matrix:
# platform: [macos-latest, windows-latest, ubuntu-latest]
platform: [windows-latest, ubuntu-latest]
include:
# - platform: macos-latest
# artifact-target-name: universal-apple-darwin
- platform: windows-latest
artifact-target-name: x86_64-pc-windows-msvc
- platform: ubuntu-latest
artifact-target-name: x86_64-unknown-linux-gnu
runs-on: ${{ matrix.platform }}
steps:
- name: 📥 Check out code
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: 🔍 Validate Git config does not introduce CRLF
shell: bash
run: |
echo "🔍 Checking Git config for CRLF settings..."
autocrlf=$(git config --get core.autocrlf || echo "unset")
eol_setting=$(git config --get core.eol || echo "unset")
echo "core.autocrlf = $autocrlf"
echo "core.eol = $eol_setting"
if [ "$autocrlf" = "true" ]; then
echo "⚠️ WARNING: core.autocrlf is set to 'true'. Consider setting it to 'input' or 'false'."
fi
if [ "$eol_setting" = "crlf" ]; then
echo "⚠️ WARNING: core.eol is set to 'crlf'. Consider unsetting it or setting to 'lf'."
fi
- name: 🔍 Check migration files line endings (LF only)
shell: bash
run: |
echo "🔍 Scanning migration SQL files for CR characters (\\r)..."
if grep -Iq $'\r' packages/app-lib/migrations/*.sql; then
echo "❌ ERROR: Some migration files contain CR (\\r) characters — expected only LF line endings."
exit 1
fi
echo "✅ All migration files use LF line endings"
- name: 🧰 Setup Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
target: ${{ matrix.artifact-target-name }}
- 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: matrix.platform == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -yq \
libgtk-3-dev \
libwebkit2gtk-4.1-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
xdg-utils \
openjdk-11-jdk
- name: 💨 Setup Turbo cache
uses: rharkor/caching-for-turbo@v1.8
- name: 🧰 Install dependencies
run: pnpm install
- name: ✍️ Set up Windows code signing (jsign)
if: matrix.platform == 'windows-latest' && env.SIGN_WINDOWS_BINARIES == 'true'
shell: bash
run: |
choco install jsign --ignore-dependencies
- name: 🗑️ Clean up cached bundles
shell: bash
run: |
rm -rf target/release/bundle
rm -rf target/*/release/bundle || true
# - name: 🔨 Build macOS app
# if: matrix.platform == 'macos-latest'
# run: pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-release.conf.json
# env:
# TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
# TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
- name: 🔨 Build Linux app
if: matrix.platform == 'ubuntu-latest'
run: pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
- name: 🔨 Build Windows app
if: matrix.platform == 'windows-latest'
shell: pwsh
run: |
$env:JAVA_HOME = "$env:JAVA_HOME_11_X64"
pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json --verbose --bundles 'nsis'
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
- name: 📤 Upload app bundles
uses: actions/upload-artifact@v3
with:
name: App bundle (${{ matrix.artifact-target-name }})
path: |
target/release/bundle/appimage/AstralRinth App_*.AppImage*
target/release/bundle/deb/AstralRinth App_*.deb*
target/release/bundle/rpm/AstralRinth App-*.rpm*
target/universal-apple-darwin/release/bundle/macos/AstralRinth App.app.tar.gz*
target/universal-apple-darwin/release/bundle/dmg/AstralRinth App_*.dmg*
target/release/bundle/nsis/AstralRinth App_*-setup.exe*
target/release/bundle/nsis/AstralRinth App_*-setup.nsis.zip*

View File

@@ -1,180 +0,0 @@
name: 'AstralRinth App Build'
on:
push:
branches:
- feature*
tags:
- 'build*'
- '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:
inputs:
sign-windows-binaries:
description: Sign Windows binaries
type: boolean
default: true
required: false
jobs:
build:
strategy:
fail-fast: false
matrix:
platform: [macos-latest, windows-latest, ubuntu-latest]
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- name: Rust setup (mac)
if: startsWith(matrix.platform, 'macos')
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
targets: aarch64-apple-darwin, x86_64-apple-darwin
- name: Rust setup
if: "!startsWith(matrix.platform, 'macos')"
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- 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 xdg-utils
- 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:
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 (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: |
[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
if: startsWith(matrix.platform, 'windows')
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
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 ${{ matrix.platform }}
uses: actions/upload-artifact@v3
with:
name: ${{ matrix.platform }}
path: |
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
target/release/bundle/*/*.AppImage.tar.gz
target/release/bundle/*/*.AppImage.tar.gz.sig
target/release/bundle/*/*.deb
target/release/bundle/*/*.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

507
Cargo.lock generated

File diff suppressed because it is too large Load Diff

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

157
README.md
View File

@@ -1,76 +1,123 @@
# Navigation in this README # 📘 Navigation
- [Install instructions](#install-instructions)
- [Features](#features) - [🔧 Install Instructions](#install-instructions)
- [Getting started](#getting-started) - [✨ Features](#features)
- [Disclaimer](#disclaimer) - [🚀 Getting Started](#getting-started)
- [Donate](#support-our-project-crypto-wallets) - [⚠️ Disclaimer](#disclaimer)
- [💰 Donate](#support-our-project-crypto-wallets)
## Other languages
> [Русский](readme/ru_ru/README.md)
## Support channel
> [Telegram](https://me.astralium.su/ref/telegram_channel)
---
# About Project # About Project
## AstralRinth • Empowering Your Minecraft Adventure ## **AstralRinth • Empowering Your Minecraft Adventure**
Welcome to AR • Fork of Modrinth, the ultimate game launcher designed to enhance your Minecraft experience through the Modrinth platform and their API. Whether you're a graphical interface enthusiast, or a developer integrating Modrinth projects, Theseus core is your gateway to a new level of Minecraft gaming.
## About Software Welcome to **AstralRinth (AR)** — a powerful fork of Modrinth, reimagined to enhance your Minecraft journey. Whether you're a GUI enthusiast or a developer building with Modrinths API, **Theseus Core** is your launchpad into a new era of Minecraft gameplay.
Introducing AstralRinth, a specialized variant of Theseus dedicated to implementing offline authorization for an even more flexible and user-centric Minecraft Modrinth experience. Roam the Minecraft realms without the constraints of online authentication, thanks to AstralRinth.
## AR • Unlocking Minecraft's Boundless Horizon - *Recently, improved integration with the Git Astralium API has been added.*
Dive into the extraordinary world of AstralRinth, a fork of the original project with a unique focus on providing a free trial experience for Minecraft, all without the need for a license. Currently boasting:
# Install instructions ## **About the Software**
- To install our application, you need to download a file for your operating system from our available releases or development builds • [Download variants here](https://git.astralium.su/didirus/AstralRinth/releases)
- After you have downloaded the required executable file or archive, then open it
### Downloadable file extensions **AstralRinth** is a dedicated branch of the Theseus project, focused on **offline authentication**, offering you more flexibility and control. Play Minecraft without the need for constant online verification — a user-first approach to modern modded gaming.
- `.msi` format for Windows OS system _(Supported popular latest versions of Microsoft Windows)_
- `.dmg` format for MacOS system _(Works on Macos Ventura / Sonoma / Sequoia, but it should be works on older OS builds)_
- `.deb` format for Linux OS systems _(Since there are quite a few distributions, we do not guarantee
### Installation subjects ## **AR • Unlocking Minecraft's Boundless Horizon**
- Builds in releases that are signed with the following prefixes are not recommended for installation and may contain errors:
- `dev` This unique fork introduces a **free trial Minecraft experience**, bypassing license checks while maintaining rich functionality. Currently includes:
- `nightly`
- `dirty` ---
- `dirty-dev`
- `dirty-nightly` # Install Instructions
- `dirty_dev`
- `dirty_nightly` To install the launcher:
- Auto-updating takes place through parsing special versions from releases, so we also distribute clean types of `.msi, .dmg and .deb`
1. Visit the [releases page](https://git.astralium.su/didirus/AstralRinth/releases) to download the correct version for your system.
2. Run the downloaded file or extract and launch it, depending on the format.
### Downloadable File Extensions
| Extension | OS | Notes |
| --------- | ------- | --------------------------------------------------------------------- |
| `.msi` | Windows | Supported on all recent Windows versions |
| `.dmg` | macOS | Works on Ventura, Sonoma, Sequoia _(may also support older versions)_ |
| `.deb` | Linux | Basic support; compatibility may vary by distribution |
### Installation Warnings
Avoid using builds with these prefixes — they may be unstable or experimental:
- `dev`
- `nightly`
- `dirty`
- `dirty-dev`
- `dirty-nightly`
- `dirty_dev`
- `dirty_nightly`
---
# Features # Features
### Featured enhancement in AR > _The launcher provides an opportunity to use the well-known Modrinth, but with an improved user experience._
- AstralRinth offers a range of authorization options, giving users the flexibility to log in with valid licenses or even a pirate account without auth credentials breaks (_Unlike MultiMC Cracked and similar software_). Experience Minecraft on your terms, breaking free from traditional licensing constraints (_Popular in Russian Federation_).
### Easy to use ## Included exclusive features
- Using the launcher is intuitive, any user can figure it out.
### Update notifies - No ads in the entire launcher.
- We have implemented notifications about the release of new updates on our Git. The launcher can also download them for you and try to install them. - Custom `.svg` vector icons for a distinct UI.
- Improved compatibility with both licensed and pirate accounts.
- Use **official microsoft accounts** or **offline/pirate accounts** — login won't break.
- Supports license-free access for testing or personal use.
- No dependence on official authentication services.
- Discord Rich Presence integration:
- Dynamic status messages.
- In-game timer and AFK counter.
- Strict disabling of statistics and other Modrinth metrics.
- Optimized archive/package size.
- Integrated update fetcher for seamless version management.
- Built-in update alerts for new versions posted on Git Astralium.
- Automatic download and installation capabilities.
- Database migration fixes, when error occurred (Interactive Mode) (Modrinth issue)
- ElyBy skin system integration (AuthLib / Java)
### Enhancements ---
- Custom .SVG vectors for a personalized touch.
- Improved compatibility for both pirate and licensed accounts.
- Beautiful Discord RPC with random messages while playing, along with an in-game timer and AFK counter.
- Forced disabling of statistics collection (modrinch metrics) with a hard patch from AstralRinth, ensuring it remains deactivated regardless of the configuration setting.
- Removal of advertisements from all launcher views.
- Optimization of packages (archives).
- Integrated update fetching feature
# Getting Started # Getting Started
To begin your AstralRinth adventure, follow these steps:
1. **Download Your OS Version**: Head over to our [releases page](https://git.astralium.su/didirus/AstralRinth/releases/) to find the right file for your operating system. To begin using AstralRinth:
- **Choosing the Correct File**: Ensure you select the file that matches your OS requirements.
- [**How select file**](#downloadable-file-extensions) 1. **Download Your OS Version**
- [**How select release**](#installation-subjects)
2. **Authentication**: Log in with a valid license or, for testing, try using a pirate account to see AstralRinth in action. - Go to the [releases page](https://git.astralium.su/didirus/AstralRinth/releases)
3. **Launch Minecraft**: Start your journey by launching Minecraft through AstralRinth and enjoy the adventures that await. - [How to choose a file](#downloadable-file-extensions)
- **Choosing java installation**: The launcher will try to automatically detect the recommended JVM version for running the game, but you can configure everything in the launcher settings. - [How to choose a release](#installation-warnings)
2. **Log In**
- Use your official Mojang/Microsoft account, or test using a non-licensed account.
3. **Launch Minecraft**
- Start Minecraft from the launcher.
- The launcher will auto-detect the recommended JVM version.
- You can also configure Java manually in the settings.
---
# Disclaimer # Disclaimer
- AstralRinth is a project intended for experimentation and educational purposes only. It does not endorse or support piracy, and users are encouraged to obtain valid licenses for a fully-supported Minecraft experience.
- Users are reminded to respect licensing agreements and support the developers of Minecraft.
# Support our Project (Crypto Wallets) - **AstralRinth** is intended **solely for educational and experimental use**.
- We **do not condone piracy** — users are encouraged to purchase a legitimate Minecraft license.
- Respect all relevant licensing agreements and support Minecraft developers.
---
# Support Our Project (Crypto Wallets)
If you'd like to support development, you can donate via the following crypto wallets:
- BTC (Telegram): 14g6asNYzcUoaQtB8B2QGKabgEvn55wfLj - BTC (Telegram): 14g6asNYzcUoaQtB8B2QGKabgEvn55wfLj
- USDT TRC20 (Telegram): TMSmv1D5Fdf4fipUpwBCdh16WevrV45vGr
- TONCOIN (Telegram): UQAqUJ2_hVBI6k_gPyfp_jd-1K0OS61nIFPZuJWN9BwGAvKe - TONCOIN (Telegram): UQAqUJ2_hVBI6k_gPyfp_jd-1K0OS61nIFPZuJWN9BwGAvKe

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",

View File

@@ -42,7 +42,7 @@ import ModrinthLoadingIndicator from '@/components/LoadingIndicatorBar.vue'
import { handleError, useNotifications } from '@/store/notifications.js' import { handleError, useNotifications } from '@/store/notifications.js'
import { command_listener, warning_listener } from '@/helpers/events.js' import { command_listener, warning_listener } from '@/helpers/events.js'
import { type } from '@tauri-apps/plugin-os' import { type } from '@tauri-apps/plugin-os'
import { getOS, isDev, restartApp } from '@/helpers/utils.js' import { getOS, isDev } from '@/helpers/utils.js'
import { debugAnalytics, initAnalytics, optOutAnalytics, trackEvent } from '@/helpers/analytics' import { debugAnalytics, initAnalytics, optOutAnalytics, trackEvent } from '@/helpers/analytics'
import { getCurrentWindow } from '@tauri-apps/api/window' import { getCurrentWindow } from '@tauri-apps/api/window'
import { getVersion } from '@tauri-apps/api/app' import { getVersion } from '@tauri-apps/api/app'
@@ -61,9 +61,10 @@ import { renderString } from '@modrinth/utils'
import { useFetch } from '@/helpers/fetch.js' import { useFetch } from '@/helpers/fetch.js'
// import { check } from '@tauri-apps/plugin-updater' // import { check } from '@tauri-apps/plugin-updater'
import NavButton from '@/components/ui/NavButton.vue' import NavButton from '@/components/ui/NavButton.vue'
import { get as getCreds, login, logout } from '@/helpers/mr_auth.js' import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.js'
import { get_user } from '@/helpers/cache.js' import { get_user } from '@/helpers/cache.js'
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue' import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue'
// import PromotionWrapper from '@/components/ui/PromotionWrapper.vue' // import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
// import { hide_ads_window, init_ads_window } from '@/helpers/ads.js' // import { hide_ads_window, init_ads_window } from '@/helpers/ads.js'
import FriendsList from '@/components/ui/friends/FriendsList.vue' import FriendsList from '@/components/ui/friends/FriendsList.vue'
@@ -72,6 +73,9 @@ import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
import { get_available_capes, get_available_skins } from './helpers/skins' import { get_available_capes, get_available_skins } from './helpers/skins'
import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer' import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer'
// [AR] Feature
import { getRemote, updateState } from '@/helpers/update.js'
const themeStore = useTheming() const themeStore = useTheming()
const news = ref([]) const news = ref([])
@@ -99,6 +103,7 @@ const isMaximized = ref(false)
onMounted(async () => { onMounted(async () => {
await useCheckDisableMouseover() await useCheckDisableMouseover()
await getRemote(false) // [AR] Check for updates
document.querySelector('body').addEventListener('click', handleClick) document.querySelector('body').addEventListener('click', handleClick)
document.querySelector('body').addEventListener('auxclick', handleAuxClick) document.querySelector('body').addEventListener('auxclick', handleAuxClick)
@@ -161,11 +166,11 @@ async function setupApp() {
initAnalytics() initAnalytics()
if (!telemetry) { if (!telemetry) {
console.info("[AR] Telemetry disabled by default (Hard patched).") console.info("[AR] Telemetry disabled by default (Hard patched).")
optOutAnalytics() optOutAnalytics()
} }
if (!personalized_ads) { if (!personalized_ads) {
console.info("[AR] Personalized ads disabled by default (Hard patched).") console.info("[AR] Personalized ads disabled by default (Hard patched).")
} }
if (dev) debugAnalytics() if (dev) debugAnalytics()
@@ -188,7 +193,7 @@ async function setupApp() {
}), }),
) )
// Patched by AstralRinth /// [AR] Patch
// useFetch( // useFetch(
// `https://api.modrinth.com/appCriticalAnnouncement.json?version=${version}`, // `https://api.modrinth.com/appCriticalAnnouncement.json?version=${version}`,
// 'criticalAnnouncements', // 'criticalAnnouncements',
@@ -279,6 +284,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) {
@@ -288,8 +295,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() {
@@ -418,6 +441,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>
@@ -465,12 +491,20 @@ function handleAuxClick(e) {
<PlusIcon /> <PlusIcon />
</NavButton> </NavButton>
<div class="flex flex-grow"></div> <div class="flex flex-grow"></div>
<NavButton v-if="updateAvailable" v-tooltip.right="'Install update'" :to="() => restartApp()"> <!-- [AR] TODO -->
<!-- <NavButton v-if="updateAvailable" v-tooltip.right="'Install update'" :to="() => restartApp()">
<DownloadIcon /> <DownloadIcon />
</NavButton> </NavButton> -->
<NavButton v-tooltip.right="'Settings'" :to="() => $refs.settingsModal.show()"> <template v-if="updateState">
<SettingsIcon /> <NavButton class="neon-icon pulse" v-tooltip.right="'Settings'" :to="() => $refs.settingsModal.show()">
</NavButton> <SettingsIcon />
</NavButton>
</template>
<template v-else>
<NavButton v-tooltip.right="'Settings'" :to="() => $refs.settingsModal.show()">
<SettingsIcon />
</NavButton>
</template>
<ButtonStyled v-if="credentials" type="transparent" circular> <ButtonStyled v-if="credentials" type="transparent" circular>
<OverflowMenu <OverflowMenu
:options="[ :options="[
@@ -501,13 +535,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 />
@@ -659,6 +693,9 @@ function handleAuxClick(e) {
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
@import '../../../packages/assets/styles/neon-icon.scss';
@import '../../../packages/assets/styles/neon-text.scss';
.window-controls { .window-controls {
z-index: 20; z-index: 20;
display: none; display: none;

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

@@ -136,7 +136,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 +213,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,17 +1,9 @@
<template> <template>
<div <div v-if="mode !== 'isolated'" ref="button"
v-if="mode !== 'isolated'"
ref="button"
class="button-base mt-2 px-3 py-2 bg-button-bg rounded-xl flex items-center gap-2" class="button-base mt-2 px-3 py-2 bg-button-bg rounded-xl flex items-center gap-2"
:class="{ expanded: mode === 'expanded' }" :class="{ expanded: mode === 'expanded' }" @click="toggleMenu">
@click="toggleMenu" <Avatar size="36px" :src="selectedAccount ? avatarUrl : 'https://launcher-files.modrinth.com/assets/steve_head.png'
> " />
<Avatar
size="36px"
:src="
selectedAccount ? avatarUrl : 'https://launcher-files.modrinth.com/assets/steve_head.png'
"
/>
<div class="flex flex-col w-full"> <div class="flex flex-col w-full">
<span> <span>
<component :is="getAccountType(selectedAccount)" v-if="selectedAccount" class="vector-icon" /> <component :is="getAccountType(selectedAccount)" v-if="selectedAccount" class="vector-icon" />
@@ -32,31 +24,23 @@
</h4> </h4>
<p>Selected</p> <p>Selected</p>
</div> </div>
<Button <Button v-tooltip="'Log out'" icon-only color="raised" @click="logout(selectedAccount.profile.id)">
v-tooltip="'Log out'"
icon-only
color="raised"
@click="logout(selectedAccount.profile.id)"
>
<TrashIcon /> <TrashIcon />
</Button> </Button>
</div> </div>
<div v-else class="login-section account"> <div v-else class="login-section account">
<h4>Not signed in</h4> <h4>Not signed in</h4>
<Button <Button v-tooltip="'Log via Microsoft'" :disabled="microsoftLoginDisabled" icon-only @click="login()">
v-tooltip="'Log in'" <MicrosoftIcon v-if="!microsoftLoginDisabled" />
:disabled="loginDisabled"
icon-only
color="primary"
@click="login()"
>
<LogInIcon v-if="!loginDisabled" />
<SpinnerIcon v-else class="animate-spin" /> <SpinnerIcon v-else class="animate-spin" />
<MicrosoftIcon/>
</Button> </Button>
<Button v-tooltip="'Add offline'" icon-only @click="tryOfflineLogin()"> <Button v-tooltip="'Add offline account'" icon-only @click="showOfflineLoginModal()">
<PirateIcon /> <PirateIcon />
</Button> </Button>
<Button v-tooltip="'Log via Ely.by'" icon-only @click="showElybyLoginModal()">
<ElyByIcon v-if="!elybyLoginDisabled" />
<SpinnerIcon v-else class="animate-spin" />
</Button>
</div> </div>
<div v-if="displayAccounts.length > 0" class="account-group"> <div v-if="displayAccounts.length > 0" class="account-group">
<div v-for="account in displayAccounts" :key="account.profile.id" class="account-row"> <div v-for="account in displayAccounts" :key="account.profile.id" class="account-row">
@@ -73,35 +57,114 @@
</div> </div>
</div> </div>
<div v-if="accounts.length > 0" class="login-section account centered"> <div v-if="accounts.length > 0" class="login-section account centered">
<Button v-tooltip="'Log in'" icon-only @click="login()"> <Button v-tooltip="'Log via Microsoft'" icon-only @click="login()">
<MicrosoftIcon /> <MicrosoftIcon v-if="!microsoftLoginDisabled" />
<SpinnerIcon v-else class="animate-spin" />
</Button> </Button>
<Button v-tooltip="'Add offline'" icon-only @click="tryOfflineLogin()"> <Button v-tooltip="'Add offline account'" icon-only @click="showOfflineLoginModal()">
<PirateIcon /> <PirateIcon />
</Button> </Button>
<Button v-tooltip="'Log via Ely.by'" icon-only @click="showElybyLoginModal()">
<ElyByIcon v-if="!elybyLoginDisabled" />
<SpinnerIcon v-else class="animate-spin" />
</Button>
</div> </div>
</Card> </Card>
</transition> </transition>
<ModalWrapper ref="loginOfflineModal" class="modal" header="Add new offline account"> <ModalWrapper ref="addElybyModal" class="modal" header="Authenticate with Ely.by">
<div class="modal-body"> <ModalWrapper ref="requestElybyTwoFactorCodeModal" class="modal"
<div class="label">Enter offline username</div> header="Ely.by requested 2FA code for authentication">
<input type="text" v-model="playerName" placeholder="Provide offline player name" /> <div class="flex flex-col gap-4 px-6 py-5">
<Button icon-only color="secondary" @click="offlineLoginFinally()"> <label class="label">Enter your 2FA code</label>
Continue <input v-model="elybyTwoFactorCode" type="text" placeholder="Your 2FA code here..." class="input" />
</Button> <div class="mt-6 ml-auto">
<Button icon-only color="primary" class="continue-button" @click="addElybyProfile()">
Continue
</Button>
</div>
</div>
</ModalWrapper>
<div class="flex flex-col gap-4 px-6 py-5">
<label class="label">Enter your player name or email (preferred)</label>
<input v-model="elybyLogin" type="text" placeholder="Your player name or email here..." class="input" />
<label class="label">Enter your password</label>
<input v-model="elybyPassword" type="password" placeholder="Your password here..." class="input" />
<div class="mt-6 ml-auto">
<Button icon-only color="primary" class="continue-button" @click="addElybyProfile()">
Login
</Button>
</div>
</div> </div>
</ModalWrapper> </ModalWrapper>
<ModalWrapper ref="loginErrorModal" class="modal" header="Error while proceed"> <ModalWrapper ref="addOfflineModal" class="modal" header="Add new offline account">
<div class="modal-body"> <div class="flex flex-col gap-4 px-6 py-5">
<div class="label">Error occurred while adding offline account</div> <label class="label">Enter your player name</label>
<Button color="primary" @click="retryOfflineLogin()"> <input v-model="offlinePlayerName" type="text" placeholder="Your player name here..." class="input" />
Try again <div class="mt-6 ml-auto">
</Button> <Button icon-only color="primary" class="continue-button" @click="addOfflineProfile()">
Login
</Button>
</div>
</div> </div>
</ModalWrapper> </ModalWrapper>
<ModalWrapper ref="unexpectedErrorModal" class="modal" header="Ошибка"> <ModalWrapper
ref="authenticationElybyErrorModal"
class="modal"
header="Error while proceeding authentication event with Ely.by">
<div class="flex flex-col gap-4 px-6 py-5">
<label class="text-base font-medium text-red-700">
An error occurred while logging in.
</label>
<div class="mt-6 ml-auto">
<Button color="primary" class="retry-button" @click="retryAddElybyProfile">
Try again
</Button>
</div>
</div>
</ModalWrapper>
<ModalWrapper ref="inputElybyErrorModal" class="modal" header="Error while proceeding input event with Ely.by">
<div class="flex flex-col gap-4 px-6 py-5">
<label class="text-base font-medium text-red-700">
An error occurred while adding the Ely.by account. Please follow the instructions below.
</label>
<ul class="list-disc list-inside text-sm space-y-1">
<li>Check that you have entered the correct player name or email.</li>
<li>Check that you have entered the correct password.</li>
</ul>
<div class="mt-6 ml-auto">
<Button color="primary" class="retry-button" @click="retryAddElybyProfile">
Try again
</Button>
</div>
</div>
</ModalWrapper>
<ModalWrapper ref="inputErrorModal" class="modal" header="Error while proceeding input event with offline account">
<div class="flex flex-col gap-4 px-6 py-5">
<label class="text-base font-medium text-red-700">
An error occurred while adding the offline account. Please follow the instructions below.
</label>
<ul class="list-disc list-inside text-sm space-y-1">
<li>Check that you have entered the correct player name.</li>
<li>
Player name must be at least {{ minOfflinePlayerNameLength }} characters long and no more than
{{ maxOfflinePlayerNameLength }} characters.
</li>
</ul>
<div class="mt-6 ml-auto">
<Button color="primary" class="retry-button" @click="retryAddOfflineProfile">
Try again
</Button>
</div>
</div>
</ModalWrapper>
<ModalWrapper ref="exceptionErrorModal" class="modal" header="Unexpected error occurred">
<div class="modal-body"> <div class="modal-body">
<div class="label">Unexcepted error</div> <label class="label">An unexpected error has occurred. Please try again later.</label>
</div> </div>
</ModalWrapper> </ModalWrapper>
</template> </template>
@@ -109,17 +172,20 @@
<script setup> <script setup>
import { import {
DropdownIcon, DropdownIcon,
PlusIcon,
TrashIcon, TrashIcon,
LogInIcon,
PirateIcon as Offline, PirateIcon as Offline,
MicrosoftIcon as License, MicrosoftIcon as License,
ElyByIcon as Elyby,
MicrosoftIcon, MicrosoftIcon,
PirateIcon, PirateIcon,
SpinnerIcon } from '@modrinth/assets' ElyByIcon,
SpinnerIcon
} from '@modrinth/assets'
import { Avatar, Button, Card } from '@modrinth/ui' import { Avatar, Button, Card } from '@modrinth/ui'
import { ref, computed, onMounted, onBeforeUnmount, onUnmounted } from 'vue' import { ref, computed, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
import { import {
elyby_auth_authenticate,
elyby_login,
offline_login, offline_login,
users, users,
remove_user, remove_user,
@@ -146,48 +212,180 @@ defineProps({
const emit = defineEmits(['change']) const emit = defineEmits(['change'])
const accounts = ref({}) const accounts = ref({})
const loginDisabled = ref(false) const microsoftLoginDisabled = ref(false)
const elybyLoginDisabled = ref(false)
const defaultUser = ref() const defaultUser = ref()
const loginOfflineModal = ref(null)
const loginErrorModal = ref(null)
const unexpectedErrorModal = ref(null)
const playerName = ref('')
async function tryOfflineLogin() { // Patched by AstralRinth // [AR] • Feature
loginOfflineModal.value.show() const clientToken = "astralrinth"
const addOfflineModal = ref(null)
const addElybyModal = ref(null)
const requestElybyTwoFactorCodeModal = ref(null)
const authenticationElybyErrorModal = ref(null)
const inputElybyErrorModal = ref(null)
const inputErrorModal = ref(null)
const exceptionErrorModal = ref(null)
const offlinePlayerName = ref('')
const elybyLogin = ref('')
const elybyPassword = ref('')
const elybyTwoFactorCode = ref('')
const minOfflinePlayerNameLength = 2
const maxOfflinePlayerNameLength = 20
// [AR] • Feature
function getAccountType(account) {
switch (account.account_type) {
case 'microsoft':
return License
case 'pirate':
return Offline
case 'elyby':
return Elyby
}
} }
async function offlineLoginFinally() { // Patched by AstralRinth // [AR] • Feature
const name = playerName.value function showOfflineLoginModal() {
if (name.length > 1 && name.length < 20 && name !== '') { addOfflineModal.value?.show()
const loggedIn = await offline_login(name).catch(handleError) }
loginOfflineModal.value.hide()
if (loggedIn) { // [AR] • Feature
await setAccount(loggedIn) function showElybyLoginModal() {
addElybyModal.value?.show()
}
// [AR] • Feature
function retryAddOfflineProfile() {
inputErrorModal.value?.hide()
clearOfflineFields()
showOfflineLoginModal()
}
// [AR] • Feature
function retryAddElybyProfile() {
authenticationElybyErrorModal.value?.hide()
inputElybyErrorModal.value?.hide()
clearElybyFields()
showElybyLoginModal()
}
// [AR] • Feature
function clearElybyFields() {
elybyLogin.value = ''
elybyPassword.value = ''
elybyTwoFactorCode.value = ''
}
// [AR] • Feature
function clearOfflineFields() {
offlinePlayerName.value = ''
}
// [AR] • Feature
async function addOfflineProfile() {
const name = offlinePlayerName.value.trim()
const isValidName = name.length >= minOfflinePlayerNameLength && name.length <= maxOfflinePlayerNameLength
if (!isValidName) {
addOfflineModal.value?.hide()
inputErrorModal.value?.show()
clearOfflineFields()
return
}
try {
const result = await offline_login(name)
addOfflineModal.value?.hide()
if (result) {
await setAccount(result)
await refreshValues() await refreshValues()
} else { } else {
unexpectedErrorModal.value.show() exceptionErrorModal.value?.show()
} }
playerName.value = '' } catch (error) {
} else { handleError(error)
playerName.value = '' exceptionErrorModal.value?.show()
loginOfflineModal.value.hide() } finally {
loginErrorModal.value.show() clearOfflineFields()
} }
} }
function retryOfflineLogin() { // Patched by AstralRinth // [AR] • Feature
loginErrorModal.value.hide() async function addElybyProfile() {
tryOfflineLogin() if (!elybyLogin.value || !elybyPassword.value) {
} addElybyModal.value?.hide()
inputElybyErrorModal.value?.show()
clearElybyFields()
return
}
elybyLoginDisabled.value = true
function getAccountType(account) { // Patched by AstralRinth const login = elybyLogin.value.trim()
if (account.access_token != "null" && account.access_token != null && account.access_token != "") { let password = elybyPassword.value.trim()
return License const twoFactorCode = elybyTwoFactorCode.value.trim()
} else { if (password && twoFactorCode) {
return Offline password = `${password}:${twoFactorCode}`
}
try {
const raw_result = await elyby_auth_authenticate(
login,
password,
clientToken
)
const json_data = JSON.parse(raw_result)
console.log(json_data?.error)
console.log(json_data?.errorMessage)
if (!json_data.accessToken) {
if (
json_data.error === 'ForbiddenOperationException' &&
json_data.errorMessage?.includes('two factor')
) {
requestElybyTwoFactorCodeModal.value?.show()
return
}
addElybyModal.value?.hide()
requestElybyTwoFactorCodeModal.value?.hide()
authenticationElybyErrorModal.value?.show()
return
}
const accessToken = json_data.accessToken
const selectedProfileId = convertRawStringToUUIDv4(json_data.selectedProfile.id)
const selectedProfileName = json_data.selectedProfile.name
const result = await elyby_login(selectedProfileId, selectedProfileName, accessToken)
addElybyModal.value?.hide()
requestElybyTwoFactorCodeModal.value?.hide()
clearElybyFields()
await setAccount(result)
await refreshValues()
} catch (err) {
handleError(err)
exceptionErrorModal.value?.show()
} finally {
elybyLoginDisabled.value = false
} }
} }
// [AR] • Feature
function convertRawStringToUUIDv4(rawId) {
if (rawId.length !== 32) {
console.warn('Invalid UUID string:', rawId)
return rawId
}
return `${rawId.slice(0, 8)}-${rawId.slice(8, 12)}-${rawId.slice(12, 16)}-${rawId.slice(16, 20)}-${rawId.slice(20)}`
}
const equippedSkin = ref(null) const equippedSkin = ref(null)
const headUrlCache = ref(new Map()) const headUrlCache = ref(new Map())
@@ -213,13 +411,13 @@ async function refreshValues() {
} }
function setLoginDisabled(value) { function setLoginDisabled(value) {
loginDisabled.value = value microsoftLoginDisabled.value = value
} }
defineExpose({ defineExpose({
refreshValues, refreshValues,
setLoginDisabled, setLoginDisabled,
loginDisabled, loginDisabled: microsoftLoginDisabled,
}) })
await refreshValues() await refreshValues()
@@ -265,7 +463,7 @@ async function setAccount(account) {
} }
async function login() { async function login() {
loginDisabled.value = true microsoftLoginDisabled.value = true
const loggedIn = await login_flow().catch(handleSevereError) const loggedIn = await login_flow().catch(handleSevereError)
if (loggedIn) { if (loggedIn) {
@@ -274,7 +472,7 @@ async function login() {
} }
trackEvent('AccountLogIn') trackEvent('AccountLogIn')
loginDisabled.value = false microsoftLoginDisabled.value = false
} }
const logout = async (id) => { const logout = async (id) => {

View File

@@ -0,0 +1,401 @@
<template>
<ModalWrapper ref="modal" :header="'Import from CurseForge Profile Code'">
<div class="modal-body">
<div class="input-row">
<p class="input-label">Profile Code</p>
<div class="iconified-input">
<SearchIcon aria-hidden="true" class="text-lg" />
<input
ref="codeInput"
v-model="profileCode"
autocomplete="off"
class="h-12 card-shadow"
spellcheck="false"
type="text"
placeholder="Enter CurseForge profile code"
maxlength="20"
@keyup.enter="importProfile"
/>
<Button v-if="profileCode" class="r-btn" @click="() => (profileCode = '')">
<XIcon />
</Button>
</div>
</div>
<div v-if="metadata && !importing" class="profile-info">
<h3>Profile Information</h3>
<p><strong>Name:</strong> {{ metadata.name }}</p>
</div>
<div v-if="error" class="error-message">
<p>{{ error }}</p>
</div>
<div v-if="importing && importProgress.visible" class="progress-section">
<div class="progress-info">
<span class="progress-text">{{ importProgress.message }}</span>
<span class="progress-percentage">{{ Math.floor(importProgress.percentage) }}%</span>
</div>
<div class="progress-bar-container">
<div
class="progress-bar"
:style="{ width: `${importProgress.percentage}%` }"
></div>
</div>
</div>
<div class="button-row">
<Button @click="hide" :disabled="importing">
<XIcon />
Cancel
</Button>
<Button
v-if="!metadata"
@click="fetchMetadata"
:disabled="!profileCode.trim() || fetching"
color="secondary"
>
<SearchIcon v-if="!fetching" />
{{ fetching ? 'Checking...' : 'Check Profile' }}
</Button>
<Button
v-if="metadata"
@click="importProfile"
:disabled="importing"
color="primary"
>
<DownloadIcon v-if="!importing" />
{{ importing ? 'Importing...' : 'Import Profile' }}
</Button>
</div>
</div>
</ModalWrapper>
</template>
<script setup>
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { Button } from '@modrinth/ui'
import {
XIcon,
SearchIcon,
DownloadIcon
} from '@modrinth/assets'
import {
fetch_curseforge_profile_metadata,
import_curseforge_profile
} from '@/helpers/import.js'
import { handleError } from '@/store/notifications.js'
import { trackEvent } from '@/helpers/analytics'
import { loading_listener } from '@/helpers/events.js'
const props = defineProps({
closeParent: {
type: Function,
default: null
}
})
const router = useRouter()
const modal = ref(null)
const codeInput = ref(null)
const profileCode = ref('')
const metadata = ref(null)
const fetching = ref(false)
const importing = ref(false)
const error = ref('')
const importProgress = ref({
visible: false,
percentage: 0,
message: 'Starting import...',
totalMods: 0,
downloadedMods: 0
})
let unlistenLoading = null
let activeLoadingBarId = null
let progressFallbackTimer = null
defineExpose({
show: () => {
profileCode.value = ''
metadata.value = null
fetching.value = false
importing.value = false
error.value = ''
importProgress.value = {
visible: false,
percentage: 0,
message: 'Starting import...',
totalMods: 0,
downloadedMods: 0
}
modal.value?.show()
nextTick(() => {
setTimeout(() => {
codeInput.value?.focus()
}, 100)
})
trackEvent('CurseForgeProfileImportStart', { source: 'ImportModal' })
},
})
const hide = () => {
modal.value?.hide()
}
const fetchMetadata = async () => {
if (!profileCode.value.trim()) return
fetching.value = true
error.value = ''
try {
const result = await fetch_curseforge_profile_metadata(profileCode.value.trim())
metadata.value = result
trackEvent('CurseForgeProfileMetadataFetched', {
profileCode: profileCode.value.trim()
})
} catch (err) {
console.error('Failed to fetch CurseForge profile metadata:', err)
error.value = 'Failed to fetch profile information. Please check the code and try again.'
handleError(err)
} finally {
fetching.value = false
}
}
const importProfile = async () => {
if (!profileCode.value.trim()) return
importing.value = true
error.value = ''
activeLoadingBarId = null // Reset for new import session
importProgress.value = {
visible: true,
percentage: 0,
message: 'Starting import...',
totalMods: 0,
downloadedMods: 0
}
// Fallback progress timer in case loading events don't work
progressFallbackTimer = setInterval(() => {
if (importing.value && importProgress.value.percentage < 90) {
// Slowly increment progress as a fallback
importProgress.value.percentage = Math.min(90, importProgress.value.percentage + 1)
}
}, 1000)
try {
const { result, profilePath } = await import_curseforge_profile(profileCode.value.trim())
trackEvent('CurseForgeProfileImported', {
profileCode: profileCode.value.trim()
})
hide()
// Close the parent modal if provided
if (props.closeParent) {
props.closeParent()
}
// Navigate to the imported profile
await router.push(`/instance/${encodeURIComponent(profilePath)}`)
} catch (err) {
console.error('Failed to import CurseForge profile:', err)
error.value = 'Failed to import profile. Please try again.'
handleError(err)
} finally {
importing.value = false
importProgress.value.visible = false
if (progressFallbackTimer) {
clearInterval(progressFallbackTimer)
progressFallbackTimer = null
}
activeLoadingBarId = null
}
}
onMounted(async () => {
// Listen for loading events to update progress
unlistenLoading = await loading_listener((event) => {
console.log('Loading event received:', event) // Debug log
// Handle all loading events that could be related to CurseForge profile import
const isCurseForgeEvent = event.event?.type === 'curseforge_profile_download'
const hasProfileName = event.event?.profile_name && importing.value
if ((isCurseForgeEvent || hasProfileName) && importing.value) {
// Store the loading bar ID for this import session
if (!activeLoadingBarId) {
activeLoadingBarId = event.loader_uuid
}
// Only process events for our current import session
if (event.loader_uuid === activeLoadingBarId) {
if (event.fraction !== null && event.fraction !== undefined) {
const baseProgress = (event.fraction || 0) * 100
// Calculate custom progress based on the message
let finalProgress = baseProgress
const message = event.message || 'Importing profile...'
// Custom progress calculation for different stages
if (message.includes('Fetching') || message.includes('metadata')) {
finalProgress = Math.min(10, baseProgress)
} else if (message.includes('Downloading profile ZIP') || message.includes('profile ZIP')) {
finalProgress = Math.min(15, 10 + (baseProgress - 10) * 0.5)
} else if (message.includes('Extracting') || message.includes('ZIP')) {
finalProgress = Math.min(20, 15 + (baseProgress - 15) * 0.5)
} else if (message.includes('Configuring') || message.includes('profile')) {
finalProgress = Math.min(30, 20 + (baseProgress - 20) * 0.5)
} else if (message.includes('Copying') || message.includes('files')) {
finalProgress = Math.min(40, 30 + (baseProgress - 30) * 0.5)
} else if (message.includes('Downloaded mod') && message.includes(' of ')) {
// Parse "Downloaded mod X of Y" message
const match = message.match(/Downloaded mod (\d+) of (\d+)/)
if (match) {
const current = parseInt(match[1])
const total = parseInt(match[2])
// Mods take 40% of progress (from 40% to 80%)
const modProgress = (current / total) * 40
finalProgress = 40 + modProgress
} else {
finalProgress = Math.min(80, 40 + (baseProgress - 40) * 0.5)
}
} else if (message.includes('Downloading mod') || message.includes('mods')) {
// General mod downloading stage (40% to 80%)
finalProgress = Math.min(80, 40 + (baseProgress - 40) * 0.4)
} else if (message.includes('Installing Minecraft') || message.includes('Minecraft')) {
finalProgress = Math.min(95, 80 + (baseProgress - 80) * 0.75)
} else if (message.includes('Finalizing') || message.includes('completed')) {
finalProgress = Math.min(100, 95 + (baseProgress - 95))
} else {
// Default: use the base progress but ensure minimum progression
finalProgress = Math.max(importProgress.value.percentage, baseProgress)
}
importProgress.value.percentage = Math.min(100, Math.max(0, finalProgress))
importProgress.value.message = message
} else {
// Loading complete
importProgress.value.percentage = 100
importProgress.value.message = 'Import completed!'
activeLoadingBarId = null
}
}
}
})
})
onUnmounted(() => {
if (unlistenLoading) {
unlistenLoading()
}
if (progressFallbackTimer) {
clearInterval(progressFallbackTimer)
}
})
</script>
<style lang="scss" scoped>
.modal-body {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
.input-row {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.input-label {
font-weight: 600;
color: var(--color-contrast);
margin: 0;
}
.profile-info {
background: var(--color-bg);
border: 1px solid var(--color-button);
border-radius: var(--radius-md);
padding: 1rem;
h3 {
margin: 0 0 0.5rem 0;
color: var(--color-contrast);
}
p {
margin: 0.25rem 0;
color: var(--color-base);
}
}
.error-message {
background: var(--color-red);
border: 1px solid var(--color-red);
border-radius: var(--radius-md);
padding: 0.75rem;
p {
margin: 0;
color: var(--color-contrast);
font-weight: 500;
}
}
.progress-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.progress-info {
display: flex;
justify-content: space-between;
align-items: center;
.progress-text {
color: var(--color-base);
font-size: 0.875rem;
}
.progress-percentage {
color: var(--color-contrast);
font-size: 0.875rem;
font-weight: 600;
}
}
.progress-bar-container {
width: 100%;
height: 4px;
background: var(--color-button);
border-radius: 2px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: var(--color-brand);
border-radius: 2px;
transition: width 0.3s ease;
min-width: 0;
}
.button-row {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: auto;
}
</style>

View File

@@ -18,11 +18,16 @@ import { cancel_directory_change } from '@/helpers/settings.ts'
import { install } from '@/helpers/profile.js' import { install } from '@/helpers/profile.js'
import { trackEvent } from '@/helpers/analytics' import { trackEvent } from '@/helpers/analytics'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { applyMigrationFix } from '@/helpers/utils.js'
import { restartApp } from '@/helpers/utils.js'
const errorModal = ref() const errorModal = ref()
const error = ref() const error = ref()
const closable = ref(true) const closable = ref(true)
const errorCollapsed = ref(false) const errorCollapsed = ref(false)
const language = ref('en')
const migrationFixSuccess = ref(null) // null | true | false
const migrationFixCallbackModel = ref()
const title = ref('An error occurred') const title = ref('An error occurred')
const errorType = ref('unknown') const errorType = ref('unknown')
@@ -148,6 +153,30 @@ async function copyToClipboard(text) {
copied.value = false copied.value = false
}, 3000) }, 3000)
} }
function toggleLanguage() {
language.value = language.value === 'en' ? 'ru' : 'en'
}
async function onApplyMigrationFix(eol) {
console.log(`[AR] • Attempting to apply migration ${eol.toUpperCase()} fix`)
try {
const result = await applyMigrationFix(eol)
migrationFixSuccess.value = result === true
console.log(`[AR] • Successfully applied migration ${eol.toUpperCase()} fix`, result)
} catch (err) {
console.error(`[AR] • Failed to apply migration fix:`, err)
migrationFixSuccess.value = false
} finally {
migrationFixCallbackModel.value?.show?.()
if (migrationFixSuccess.value === true) {
setTimeout(async () => {
await restartApp()
}, 3000)
}
}
}
</script> </script>
<template> <template>
@@ -298,10 +327,20 @@ async function copyToClipboard(text) {
<template v-if="copied"> <CheckIcon class="text-green" /> Copied! </template> <template v-if="copied"> <CheckIcon class="text-green" /> Copied! </template>
<template v-else> <CopyIcon /> Copy debug info </template> <template v-else> <CopyIcon /> Copy debug info </template>
</button> </button>
<ButtonStyled class="neon-button neon">
<a href="https://me.astralium.su/get/ar/help" target="_blank" rel="noopener noreferrer">
Get AstralRinth support
</a>
</ButtonStyled>
<ButtonStyled class="neon-button neon" >
<a href="https://me.astralium.su/get/ar" target="_blank" rel="noopener noreferrer">
Checkout latest releases
</a>
</ButtonStyled>
</ButtonStyled> </ButtonStyled>
</div> </div>
<template v-if="hasDebugInfo"> <template v-if="hasDebugInfo">
<div class="bg-button-bg rounded-xl mt-2 overflow-clip"> <div class="bg-button-bg rounded-xl mt-2 overflow-hidden">
<button <button
class="flex items-center justify-between w-full bg-transparent border-0 px-4 py-3 cursor-pointer" class="flex items-center justify-between w-full bg-transparent border-0 px-4 py-3 cursor-pointer"
@click="errorCollapsed = !errorCollapsed" @click="errorCollapsed = !errorCollapsed"
@@ -313,12 +352,123 @@ async function copyToClipboard(text) {
/> />
</button> </button>
<Collapsible :collapsed="errorCollapsed"> <Collapsible :collapsed="errorCollapsed">
<pre class="m-0 px-4 py-3 bg-bg rounded-none">{{ debugInfo }}</pre> <pre
class="m-0 px-4 py-3 bg-bg rounded-none whitespace-pre-wrap break-words overflow-x-auto max-w-full"
>{{ debugInfo }}</pre>
</Collapsible> </Collapsible>
</div> </div>
<template v-if="errorType === 'state_init'">
<div class="notice">
<div class="flex justify-between items-center">
<h3 v-if="language === 'en'" class="notice__title"> Migration Issue Important Notice </h3>
<h3 v-if="language === 'ru'" class="notice__title"> Проблема миграции Важное уведомление </h3>
<ButtonStyled>
<button @click="toggleLanguage">
{{ language === 'en' ? '📖 Русский' : '📖 English' }}
</button>
</ButtonStyled>
</div>
<p v-if="language === 'en'" class="notice__text">
We're experiencing an issue with our database migration system due to differences in how different operating systems handle line endings. This might cause problems with our app's functionality.
</p>
<p v-if="language === 'en'" class="notice__text">
<strong>What's happening?</strong> When we build our app, we use a system that checks the integrity of our database migrations. However, this system can get confused when it encounters different line endings (like CRLF vs LF) used by different operating systems. This can lead to errors and make our app unusable.
</p>
<p v-if="language === 'en'" class="notice__text">
<strong>Why is this happening?</strong> This issue is caused by a combination of factors, including different operating systems handling line endings differently, Git's line ending conversion settings, and our app's build process.
</p>
<p v-if="language === 'en'" class="notice__text">
<strong>What are we doing about it?</strong> We're working to resolve this issue and ensure that our app works smoothly for all users. In the meantime, we apologize for any inconvenience this might cause and appreciate your patience and understanding.
</p>
<p v-if="language === 'ru'" class="notice__text">
Мы сталкиваемся с проблемой в нашей системе миграции базы данных из-за различий в том, как разные операционные системы обрабатывают окончания строк. Это может вызвать проблемы с функциональностью нашего приложения.
</p>
<p v-if="language === 'ru'" class="notice__text">
<strong>Что происходит?</strong> Когда мы строим наше приложение, мы используем систему, которая проверяет целостность наших миграций базы данных. Однако эта система может сбиваться, когда сталкивается с различными окончаниями строк (например, CRLF против LF), используемыми разными операционными системами. Это может привести к ошибкам и сделать наше приложение неработоспособным.
</p>
<p v-if="language === 'ru'" class="notice__text">
<strong>Почему это происходит?</strong> Эта проблема вызвана сочетанием факторов, включая различную обработку окончаний строк разными операционными системами, настройки преобразования окончаний строк в Git и процесс сборки нашего приложения.
</p>
<p v-if="language === 'ru'" class="notice__text">
<strong>Что мы с этим делаем?</strong> Мы работаем над решением этой проблемы и обеспечением бесперебойной работы нашего приложения для всех пользователей. В это время мы извиняемся за возможные неудобства и благодарим вас за терпение и понимание.
</p>
</div>
<h2 class="text-lg font-bold text-contrast">
<template v-if="language === 'en'">Possible fix in real time:</template>
<template v-if="language === 'ru'">Возможное исправление в реальном времени:</template>
</h2>
<div class="flex justify-between">
<ol class="flex flex-col gap-3">
<li>
<ButtonStyled class="neon-button neon">
<button
:title="language === 'en'
? 'Convert all line endings in migration files to LF (Unix-style: \\n)'
: 'Преобразовать все окончания строк в файлах миграций в LF (Unix-стиль: \\n)'"
aria-label="LF"
@click="onApplyMigrationFix('lf')"
>
{{ language === 'en' ? 'Apply LF Migration Fix' : 'Применить исправление миграции LF' }}
</button>
</ButtonStyled>
</li>
<li>
<ButtonStyled class="neon-button neon">
<button
:title="language === 'en'
? 'Convert all line endings in migration files to CRLF (Windows-style: \\r\\n)'
: 'Преобразовать все окончания строк в файлах миграций в CRLF (Windows-стиль: \\r\\n)'"
aria-label="CRLF"
@click="onApplyMigrationFix('crlf')"
>
{{ language === 'en' ? 'Apply CRLF Migration Fix' : 'Применить исправление миграции CRLF' }}
</button>
</ButtonStyled>
</li>
</ol>
</div>
</template>
</template> </template>
</div> </div>
</ModalWrapper> </ModalWrapper>
<ModalWrapper
ref="migrationFixCallbackModel"
:header="language === 'en'
? '💡 Migration fix report'
: '💡 Отчет об исправлении миграции'"
:closable="closable">
<div class="modal-body">
<h2 class="text-lg font-bold text-contrast space-y-2">
<template v-if="migrationFixSuccess === true">
<p class="flex items-center gap-2 neon-text">
{{ language === 'en'
? 'The migration fix has been applied successfully. Please restart the launcher and try to log in to the game :)'
: 'Исправление миграции успешно применено. Пожалуйста, перезапустите лаунчер и попробуйте снова авторизоваться в игре :)' }}
</p>
<p class="mt-2 text-sm neon-text">
{{ language === 'en'
? 'If the problem persists, please try the other fix.'
: 'Если проблема сохраняется, пожалуйста, попробуйте другой способ.' }}
</p>
</template>
<template v-else-if="migrationFixSuccess === false">
<p class="flex items-center gap-2 neon-text">
{{ language === 'en'
? 'The migration fix failed or had no effect.'
: 'Исправление миграции не было успешно применено или не имело эффекта.' }}
</p>
<p class="mt-2 text-sm neon-text">
{{ language === 'en'
? 'If the problem persists, please try the other fix.'
: 'Если проблема сохраняется, пожалуйста, попробуйте другой способ.' }}
</p>
</template>
</h2>
</div>
</ModalWrapper>
</template> </template>
<style> <style>
@@ -333,6 +483,9 @@ async function copyToClipboard(text) {
</style> </style>
<style scoped lang="scss"> <style scoped lang="scss">
@import '../../../../../packages/assets/styles/neon-button.scss';
@import '../../../../../packages/assets/styles/neon-text.scss';
.cta-button { .cta-button {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -163,6 +163,14 @@
<div v-else class="table-content empty">No profiles found</div> <div v-else class="table-content empty">No profiles found</div>
</div> </div>
<div class="button-row"> <div class="button-row">
<Button
v-if="selectedProfileType.name === 'Curseforge'"
@click="showCurseForgeProfileModal"
:disabled="loading"
>
<CodeIcon />
Import from Profile Code
</Button>
<Button <Button
:disabled=" :disabled="
loading || loading ||
@@ -194,10 +202,12 @@
</div> </div>
</div> </div>
</ModalWrapper> </ModalWrapper>
<CurseForgeProfileImportModal ref="curseforgeProfileModal" :close-parent="hide" />
</template> </template>
<script setup> <script setup>
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import CurseForgeProfileImportModal from '@/components/ui/CurseForgeProfileImportModal.vue'
import { import {
CodeIcon, CodeIcon,
FolderOpenIcon, FolderOpenIcon,
@@ -283,6 +293,11 @@ const hide = () => {
unlistener.value = null unlistener.value = null
} }
} }
const showCurseForgeProfileModal = () => {
curseforgeProfileModal.value?.show()
}
onUnmounted(() => { onUnmounted(() => {
if (unlistener.value) { if (unlistener.value) {
unlistener.value() unlistener.value()
@@ -305,12 +320,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')
@@ -334,6 +353,7 @@ const game_versions = computed(() => {
}) })
const modal = ref(null) const modal = ref(null)
const curseforgeProfileModal = ref(null)
const check_valid = computed(() => { const check_valid = computed(() => {
return ( return (

View File

@@ -108,7 +108,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

@@ -36,44 +36,6 @@
<span class="circle stopped" /> <span class="circle stopped" />
<span class="running-text"> No instances running </span> <span class="running-text"> No instances running </span>
</div> </div>
<div v-if="updateState">
<a>
<Button class="download" :disabled="installState" @click="confirmUpdating(), getRemote(false, false)">
<DownloadIcon />
{{
installState
? "Downloading new update..."
: "Download new update"
}}
</Button>
</a>
</div>
<ModalWrapper ref="confirmUpdate" :has-to-type="false" header="Request to update the AstralRinth launcher">
<div class="modal-body">
<div class="markdown-body">
<p>The new version of the AstralRinth launcher is available.</p>
<p>Your version is outdated. We recommend that you update to the latest version.</p>
<p><strong> Warning </strong></p>
<p>
Before updating, make sure that you have saved all running instances and made a backup copy of the instances
that are valuable to you. Remember that the authors of the product are not responsible for the breakdown of
your files, so you should always make copies of them and keep them in a safe place.
</p>
</div>
<span>Source Git Astralium</span>
<span>Version on remote server <p id="releaseData" class="cosmic inline-fix"></p></span>
<span>Version on local device
<p class="cosmic inline-fix">v{{ version }}</p>
</span>
<div class="button-group push-right">
<Button class="download-modal" @click="confirmUpdate.hide()">
Decline</Button>
<Button class="download-modal" @click="approveUpdate()">
Accept
</Button>
</div>
</div>
</ModalWrapper>
</div> </div>
<transition name="download"> <transition name="download">
<Card v-if="showCard === true && currentLoadingBars.length > 0" ref="card" class="info-card"> <Card v-if="showCard === true && currentLoadingBars.length > 0" ref="card" class="info-card">
@@ -122,25 +84,6 @@ import ProgressBar from '@/components/ui/ProgressBar.vue'
import { handleError } from '@/store/notifications.js' import { handleError } from '@/store/notifications.js'
import { get_many } from '@/helpers/profile.js' import { get_many } from '@/helpers/profile.js'
import { trackEvent } from '@/helpers/analytics' import { trackEvent } from '@/helpers/analytics'
import { getVersion } from '@tauri-apps/api/app'
const version = await getVersion()
import { installState, getRemote, updateState } from '@/helpers/update.js'
import ModalWrapper from './modal/ModalWrapper.vue'
const confirmUpdate = ref(null)
const confirmUpdating = async () => {
confirmUpdate.value.show()
}
const approveUpdate = async () => {
confirmUpdate.value.hide()
await getRemote(true, true)
}
await getRemote(true, false)
const router = useRouter() const router = useRouter()
const card = ref(null) const card = ref(null)
@@ -298,101 +241,6 @@ onBeforeUnmount(() => {
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.inline-fix {
display: inline-flex;
margin-top: -2rem;
margin-bottom: -2rem;
//margin-left: 0.3rem;
}
.cosmic {
color: #3e8cde;
text-decoration: none;
text-shadow:
0 0 4px rgba(79, 173, 255, 0.5),
0 0 8px rgba(14, 98, 204, 0.5),
0 0 12px rgba(122, 31, 199, 0.5);
transition: color 0.35s ease;
}
.markdown-body {
:deep(table) {
width: auto;
}
:deep(hr),
:deep(h1),
:deep(h2) {
max-width: max(60rem, 90%);
}
:deep(ul),
:deep(ol) {
margin-left: 2rem;
}
}
.modal-body {
display: flex;
flex-direction: column;
gap: 1rem;
padding: var(--gap-lg);
text-align: left;
.button-group {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
strong {
color: var(--color-contrast);
}
}
.download {
color: #3e8cde;
border-radius: var(--radius-md);
border: 1px solid var(--color-button-bg);
// padding: var(--gap-sm) var(--gap-lg);
background-color: rgba(0, 0, 0, 0);
text-decoration: none;
text-shadow:
0 0 4px rgba(79, 173, 255, 0.5),
0 0 8px rgba(14, 98, 204, 0.5),
0 0 12px rgba(122, 31, 199, 0.5);
transition: color 0.35s ease;
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
.download:hover,
.download:focus,
.download:active {
color: #10fae5;
text-shadow: #26065e;
}
.download-modal {
color: #3e8cde;
padding: var(--gap-sm) var(--gap-lg);
text-decoration: none;
text-shadow:
0 0 4px rgba(79, 173, 255, 0.5),
0 0 8px rgba(14, 98, 204, 0.5),
0 0 12px rgba(122, 31, 199, 0.5);
transition: color 0.35s ease;
}
.download-modal:hover,
.download-modal:focus,
.download-modal:active {
color: #10fae5;
text-shadow: #26065e;
}
.action-groups { .action-groups {
display: flex; display: flex;
flex-direction: row; flex-direction: row;

View File

@@ -26,6 +26,7 @@ import {
type Version, type Version,
} from '@modrinth/utils' } from '@modrinth/utils'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue' import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { get_project, get_version_many } from '@/helpers/cache' import { get_project, get_version_many } from '@/helpers/cache'
import ModpackVersionModal from '@/components/ui/ModpackVersionModal.vue' import ModpackVersionModal from '@/components/ui/ModpackVersionModal.vue'
import dayjs from 'dayjs' import dayjs from 'dayjs'
@@ -35,6 +36,11 @@ import type {
Manifest, Manifest,
} from '../../../helpers/types' } from '../../../helpers/types'
import { initAuthlibPatching } from '@/helpers/utils.js'
const authLibPatchingModal = ref(null)
const isAuthLibPatchedSuccess = ref(false)
const isAuthLibPatching = ref(false)
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const repairConfirmModal = ref() const repairConfirmModal = ref()
@@ -447,9 +453,43 @@ const messages = defineMessages({
defaultMessage: 'reinstall', defaultMessage: 'reinstall',
}, },
}) })
async function handleInitAuthLibPatching(ismojang: boolean) {
isAuthLibPatching.value = true
let state = false
let instance_path = props.instance.loader_version != null ? props.instance.game_version + "-" + props.instance.loader_version : props.instance.game_version
try {
state = await initAuthlibPatching(instance_path, ismojang)
} catch (err) {
console.error(err)
}
isAuthLibPatching.value = false
isAuthLibPatchedSuccess.value = state
authLibPatchingModal.value.show()
}
</script> </script>
<template> <template>
<ModalWrapper
ref="authLibPatchingModal"
:header="'AuthLib installation report'"
:closable="true"
@close="authLibPatchingModal.hide()"
>
<div class="modal-body">
<h2 class="text-lg font-bold text-contrast space-y-2">
<p class="flex items-center gap-2 neon-text">
<span v-if="isAuthLibPatchedSuccess" class="neon-text">
AuthLib installation completed successfully! Now you can log in and play!
</span>
<span v-else class="neon-text">
Failed to install AuthLib. It's possible that no compatible AuthLib version was found for the selected game and/or mod loader version.
There may also be a problem with accessing resources behind CloudFlare.
</span>
</p>
</h2>
</div>
</ModalWrapper>
<ConfirmModalWrapper <ConfirmModalWrapper
ref="repairConfirmModal" ref="repairConfirmModal"
:title="formatMessage(messages.repairConfirmTitle)" :title="formatMessage(messages.repairConfirmTitle)"
@@ -720,6 +760,24 @@ const messages = defineMessages({
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
<h2 class="m-0 mt-4 text-lg font-extrabold text-contrast block">
<div v-if="isAuthLibPatching" class="w-6 h-6 cursor-pointer hover:brightness-75 neon-icon pulse">
<SpinnerIcon class="size-4 animate-spin" />
</div>
Auth system (Skins) <span class="text-sm font-bold px-2 bg-brand-highlight text-brand rounded-full">Beta</span>
</h2>
<div class="mt-4 flex gap-2">
<ButtonStyled class="neon-button neon">
<button :disabled="isAuthLibPatching" @click="handleInitAuthLibPatching(true)">
Install Microsoft
</button>
</ButtonStyled>
<ButtonStyled class="neon-button neon">
<button :disabled="isAuthLibPatching" @click="handleInitAuthLibPatching(false) ">
Install Ely.By
</button>
</ButtonStyled>
</div>
</template> </template>
<template v-else> <template v-else>
<template v-if="instance.linked_data && instance.linked_data.locked"> <template v-if="instance.linked_data && instance.linked_data.locked">
@@ -787,3 +845,9 @@ const messages = defineMessages({
</template> </template>
</div> </div>
</template> </template>
<style lang="scss" scoped>
@import '../../../../../../packages/assets/styles/neon-button.scss';
@import '../../../../../../packages/assets/styles/neon-text.scss';
@import '../../../../../../packages/assets/styles/neon-icon.scss';
</style>

View File

@@ -6,9 +6,9 @@ import { edit, get_optimal_jre_key } from '@/helpers/profile'
import { handleError } from '@/store/notifications' import { handleError } from '@/store/notifications'
import { defineMessages, useVIntl } from '@vintl/vintl' 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 { get } from '@/helpers/settings.ts' import { get } from '@/helpers/settings.ts'
import type { InstanceSettingsTabProps, AppSettings, MemorySettings } from '../../../helpers/types' import type { InstanceSettingsTabProps, AppSettings, MemorySettings } from '../../../helpers/types'
import useMemorySlider from '@/composables/useMemorySlider'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
@@ -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

@@ -8,6 +8,8 @@ import {
PaintbrushIcon, PaintbrushIcon,
GameIcon, GameIcon,
CoffeeIcon, CoffeeIcon,
DownloadIcon,
SpinnerIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { TabbedModal } from '@modrinth/ui' import { TabbedModal } from '@modrinth/ui'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
@@ -23,6 +25,23 @@ import { useTheming } from '@/store/state'
import FeatureFlagSettings from '@/components/ui/settings/FeatureFlagSettings.vue' import FeatureFlagSettings from '@/components/ui/settings/FeatureFlagSettings.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { get, set } from '@/helpers/settings.ts' import { get, set } from '@/helpers/settings.ts'
// [AR] Imports
import { installState, getRemote, updateState } from '@/helpers/update.js'
const updateModalView = ref(null)
const updateRequestFailView = ref(null)
const initUpdateModal = async () => {
updateModalView.value.show()
}
const initDownload = async () => {
updateModalView.value.hide()
const result = await getRemote(true);
if (!result) {
updateRequestFailView.value.show()
}
}
const themeStore = useTheming() const themeStore = useTheming()
@@ -141,8 +160,7 @@ function devModeCount() {
<button <button
class="p-0 m-0 bg-transparent border-none cursor-pointer button-animation" class="p-0 m-0 bg-transparent border-none cursor-pointer button-animation"
:class="{ 'text-brand': themeStore.devMode, 'text-secondary': !themeStore.devMode }" :class="{ 'text-brand': themeStore.devMode, 'text-secondary': !themeStore.devMode }"
@click="devModeCount" @click="devModeCount">
>
<AstralRinthLogo class="w-6 h-6" /> <AstralRinthLogo class="w-6 h-6" />
</button> </button>
<div> <div>
@@ -153,9 +171,80 @@ function devModeCount() {
{{ osVersion }} {{ osVersion }}
</p> </p>
</div> </div>
<div v-if="updateState" class="w-8 h-8 cursor-pointer hover:brightness-75 neon-icon pulse">
<template v-if="installState">
<SpinnerIcon class="size-6 animate-spin" v-tooltip.bottom="'Installing in process...'" />
</template>
<template v-else>
<DownloadIcon class="size-6" v-tooltip.bottom="'View update info'" @click="!installState && (initUpdateModal(), getRemote(false))" />
</template>
</div>
</div> </div>
</div> </div>
</template> </template>
</TabbedModal> </TabbedModal>
<!-- [AR] Feature -->
<ModalWrapper ref="updateModalView" :has-to-type="false" header="Request to update the AstralRinth launcher">
<div class="space-y-4">
<div class="space-y-2">
<p>The new version of the AstralRinth launcher is available.</p>
<p>Your version is outdated. We recommend that you update to the latest version.</p>
<p><strong> Warning </strong></p>
<p>
Before updating, make sure that you have saved all running instances and made a backup copy of the instances
that are valuable to you. Remember that the authors of the product are not responsible for the breakdown of
your files, so you should always make copies of them and keep them in a safe place.
</p>
</div>
<div class="text-sm text-secondary space-y-1">
<a class="neon-text" href="https://me.astralium.su/get/ar" target="_blank"
rel="noopener noreferrer"><strong>Source:</strong> Git Astralium</a>
<p>
<strong>Version on remote server:</strong>
<span id="releaseData" class="neon-text"></span>
</p>
<p>
<strong>Version on local device:</strong>
<span class="neon-text">v{{ version }}</span>
</p>
</div>
<div class="absolute bottom-4 right-4 flex items-center gap-4 neon-button neon">
<Button class="bordered" @click="updateModalView.hide()">Cancel</Button>
<Button class="bordered" @click="initDownload()">Download file</Button>
</div>
</div>
</ModalWrapper>
<ModalWrapper ref="updateRequestFailView" :has-to-type="false" header="Failed to request a file from the server :(">
<div class="space-y-4">
<div class="space-y-2">
<p><strong>Error occurred</strong></p>
<p>Unfortunately, the program was unable to download the file from our servers.</p>
<p>
Please try downloading it yourself from
<a class="neon-text" href="https://me.astralium.su/get/ar" target="_blank" rel="noopener noreferrer">Git
Astralium</a>
if there are any updates available.
</p>
</div>
<div class="text-sm text-secondary">
<p>
<strong>Local AstralRinth:</strong>
<span class="neon-text">v{{ version }}</span>
</p>
</div>
<div class="absolute bottom-4 right-4 flex items-center gap-4 neon-button neon">
<Button class="bordered" @click="updateRequestFailView.hide()">Close</Button>
</div>
</div>
</ModalWrapper>
</ModalWrapper> </ModalWrapper>
</template> </template>
<style lang="scss" scoped>
@import '../../../../../../packages/assets/styles/neon-icon.scss';
@import '../../../../../../packages/assets/styles/neon-button.scss';
@import '../../../../../../packages/assets/styles/neon-text.scss';
</style>

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

@@ -30,7 +30,7 @@ watch(
option, you opt out and ads will no longer be shown based on your interests. option, you opt out and ads will no longer be shown based on your interests.
</p> </p>
</div> </div>
<!-- AstralRinth disabled element by default --> <!-- [AR] Patch. Disabled element by default -->
<Toggle id="personalized-ads" v-model="settings.personalized_ads" :disabled="!settings.personalized_ads" /> <Toggle id="personalized-ads" v-model="settings.personalized_ads" :disabled="!settings.personalized_ads" />
</div> </div>
@@ -43,7 +43,7 @@ watch(
longer be collected. longer be collected.
</p> </p>
</div> </div>
<!-- AstralRinth disabled element by default --> <!-- [AR] Patch. Disabled element by default -->
<Toggle id="opt-out-analytics" v-model="settings.telemetry" :disabled="!settings.telemetry" /> <Toggle id="opt-out-analytics" v-model="settings.telemetry" :disabled="!settings.telemetry" />
</div> </div>

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"
@@ -126,6 +118,7 @@ import {
type Cape, type Cape,
type SkinModel, type SkinModel,
get_normalized_skin_texture, get_normalized_skin_texture,
determineModelType,
} from '@/helpers/skins.ts' } from '@/helpers/skins.ts'
import { handleError } from '@/store/notifications' import { handleError } from '@/store/notifications'
import { import {
@@ -261,7 +254,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

@@ -128,6 +128,14 @@ const messages = defineMessages({
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',
@@ -302,39 +310,33 @@ 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>
<ButtonStyled v-else> <button
<button v-tooltip="
v-tooltip=" !serverStatus
serverIncompatible ? formatMessage(messages.noContact)
? 'Server is incompatible' : serverIncompatible
? formatMessage(messages.incompatibleServer)
: !supportsQuickPlay : !supportsQuickPlay
? formatMessage(messages.noQuickPlay) ? formatMessage(messages.noQuickPlay)
: playingOtherWorld || locked : playingOtherWorld || locked
? formatMessage(messages.gameAlreadyOpen) ? formatMessage(messages.gameAlreadyOpen)
: null : null
" "
:disabled="!supportsQuickPlay || playingOtherWorld || startingInstance" :disabled="!supportsQuickPlay || playingOtherWorld || startingInstance"
@click="emit('play')" @click="emit('play')"
> >
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" /> <SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
<PlayIcon v-else aria-hidden="true" /> <PlayIcon v-else aria-hidden="true" />
{{ formatMessage(commonMessages.playButton) }}
</button>
</ButtonStyled>
</template>
<ButtonStyled v-else>
<button class="invisible">
<PlayIcon aria-hidden="true" />
{{ formatMessage(commonMessages.playButton) }} {{ formatMessage(commonMessages.playButton) }}
</button> </button>
</ButtonStyled> </ButtonStyled>

View File

@@ -0,0 +1,21 @@
import { ref, computed } from 'vue'
import { get_max_memory } from '@/helpers/jre.js'
import { handleError } from '@/store/notifications.js'
export default async function () {
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

@@ -17,6 +17,24 @@ export async function offline_login(name) {
return await invoke('plugin:auth|offline_login', { name: name }) return await invoke('plugin:auth|offline_login', { name: name })
} }
// [AR] • Feature
export async function elyby_login(uuid, login, accessToken) {
return await invoke('plugin:auth|elyby_login', {
uuid,
login,
accessToken
})
}
// [AR] • Feature
export async function elyby_auth_authenticate(login, password, clientToken) {
return await invoke('plugin:auth|elyby_auth_authenticate', {
login,
password,
clientToken,
})
}
/** /**
* Authenticate a user with Hydra - part 1. * Authenticate a user with Hydra - part 1.
* This begins the authentication flow quasi-synchronously. * This begins the authentication flow quasi-synchronously.

View File

@@ -61,3 +61,31 @@ export async function is_valid_importable_instance(instanceFolder, launcherType)
export async function get_default_launcher_path(launcherType) { export async function get_default_launcher_path(launcherType) {
return await invoke('plugin:import|get_default_launcher_path', { launcherType }) return await invoke('plugin:import|get_default_launcher_path', { launcherType })
} }
/// Fetch CurseForge profile metadata from profile code
/// eg: fetch_curseforge_profile_metadata("eSrNlKNo")
export async function fetch_curseforge_profile_metadata(profileCode) {
return await invoke('plugin:import|fetch_curseforge_profile_metadata', { profileCode })
}
/// Import a CurseForge profile from profile code
/// eg: import_curseforge_profile("eSrNlKNo")
export async function import_curseforge_profile(profileCode) {
try {
// First, fetch the profile metadata to get the actual name
const metadata = await fetch_curseforge_profile_metadata(profileCode)
// create a basic, empty instance using the actual profile name
const profilePath = await create(metadata.name, '1.19.4', 'vanilla', 'latest', null, true)
const result = await invoke('plugin:import|import_curseforge_profile', {
profilePath,
profileCode,
})
// Return the profile path for navigation
return { result, profilePath }
} catch (error) {
throw error
}
}

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

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

@@ -62,15 +62,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 +94,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

@@ -1,22 +1,20 @@
import { ref } from 'vue' import { ref } from 'vue'
import { getVersion } from '@tauri-apps/api/app' import { getVersion } from '@tauri-apps/api/app'
import { getArtifact, getOS } from '@/helpers/utils.js' import { initUpdateLauncher, getOS } from '@/helpers/utils.js'
export const allowState = ref(false) export const allowState = ref(false)
export const installState = ref(false) export const installState = ref(false)
export const updateState = ref(false) export const updateState = ref(false)
export const latestBetaCommitTruncatedSha = ref('')
export const latestBetaCommitLink = ref('')
export const launcherUrl = 'https://www.astralium.su/get/ar'
const os = ref('') const currentOS = ref('')
const releaseLink = `https://git.astralium.su/api/v1/repos/didirus/AstralRinth/releases/latest` const releaseLink = `https://git.astralium.su/api/v1/repos/didirus/AstralRinth/releases/latest`
const failedFetch = [`Failed to fetch remote releases:`, `Failed to fetch remote commits:`] const failedFetch = [`Failed to fetch remote releases:`, `Failed to fetch remote commits:`]
const osNames = ['macos', 'windows', 'linux']
const macExtension = `.dmg` // MacOS file type for download const osList = ['macos', 'windows', 'linux']
const windowsExtension = `.msi` // Windows file type for download const macExtensionList = ['.dmg', '.pkg']
const blacklistedBuilds = [ const windowsExtensionList = ['.exe', '.msi']
const blacklistPrefixes = [
`dev`, `dev`,
`nightly`, `nightly`,
`dirty`, `dirty`,
@@ -26,110 +24,73 @@ const blacklistedBuilds = [
`dirty_nightly`, `dirty_nightly`,
] // This is blacklisted builds for download. For example, file.startsWith('dev') is not allowed. ] // This is blacklisted builds for download. For example, file.startsWith('dev') is not allowed.
/** export async function getRemote(isDownloadState) {
* Asynchronous function to get remote data and handle updates and downloads. var releaseData = null;
* var result = false;
* @param {boolean} elementIdBool - Indicates whether to disable an element ID. try {
* @param {boolean} downloadArtifactBool - Indicates whether to download an artifact. const response = await fetch(releaseLink);
*/ if (!response.ok) {
export async function getRemote(elementIdBool, downloadArtifactBool) { throw new Error(response.status);
fetch(releaseLink) }
.then((response) => { const remoteData = await response.json();
if (!response.ok) { currentOS.value = await getOS();
throw new Error(response.status) const remoteLatestReleaseTag = remoteData.tag_name;
} releaseData = document.getElementById('releaseData');
return response.json() const remoteVersion = releaseData ? (releaseData.textContent = remoteLatestReleaseTag) : remoteLatestReleaseTag;
})
.then(async (data) => {
os.value = await getOS()
const latestRelease = data.name
let remoteVersion = undefined
if (!elementIdBool) { if (osList.includes(currentOS.value.toLowerCase())) {
const releaseData = document.getElementById('releaseData') const localVersion = await getVersion();
if (releaseData == null) { const isUpdateAvailable = !remoteVersion.includes(localVersion);
console.error('Release data element not found.')
return false
}
releaseData.textContent = latestRelease
remoteVersion = `${releaseData.textContent}`
} else {
remoteVersion = latestRelease
}
if (osNames.includes(os.value.toLowerCase())) {
if (remoteVersion.startsWith('v' + await getVersion())) {
updateState.value = false
allowState.value = false
} else {
updateState.value = true
allowState.value = true
}
} else {
updateState.value = false
allowState.value = false
}
console.log('Update available state is', updateState.value)
console.log('Remote version is', remoteVersion)
console.log('Local version is', await getVersion())
console.log('Operating System is', os.value)
if (downloadArtifactBool) { updateState.value = isUpdateAvailable;
installState.value = true allowState.value = isUpdateAvailable;
const builds = data.assets } else {
const fileName = getInstaller(getExtension(), builds) updateState.value = false;
if (fileName != null) { allowState.value = false;
await getArtifact(fileName[1], fileName[0], os.value, true) }
} if (isDownloadState) {
installState.value = false installState.value = true;
} const builds = remoteData.assets;
}) const fileName = getInstaller(getExtension(), builds);
.catch((error) => { result = fileName ? await initUpdateLauncher(fileName[1], fileName[0], currentOS.value, true) : false;
console.error(failedFetch[0], error) installState.value = false;
if (!elementIdBool) { }
const errorData = document.getElementById('releaseData')
if (errorData) {
errorData.textContent = `${error.message}`
}
updateState.value = false
allowState.value = false
installState.value = false
}
})
}
/** console.log('Update available state is', updateState.value);
* Retrieves the installer for a specific operating system. console.log('Remote version is', remoteVersion);
* console.log('Local version is', await getVersion());
* @param {string} osExtension - The file extension of the installer. console.log('Operating System is', currentOS.value);
* @param {Array} builds - The list of builds. return result;
* @return {Array|null} An array containing the installer name and URL if found, or null if not found. } catch (error) {
*/ console.error(failedFetch[0], error);
function getInstaller(osExtension, builds) { if (!releaseData) {
for (let i of builds) { const errorData = document.getElementById('releaseData');
let blacklistedItem = false if (errorData) {
blacklistedBuilds.forEach((item) => { errorData.textContent = `${error.message}`;
if (i.name.startsWith(item)) {
return (blacklistedItem = true)
} }
}) updateState.value = false;
if (i.name.endsWith(osExtension) && !blacklistedItem) { allowState.value = false;
console.log(i.browser_download_url) installState.value = false;
return [i.name, i.browser_download_url]
} }
} }
return null
} }
/** function getInstaller(osExtension, builds) {
* A function to get the extension based on the operating system. console.log(osExtension, builds)
* for (const build of builds) {
* @return {string} The extension based on the operating system. if (blacklistPrefixes.some(prefix => build.name.startsWith(prefix))) {
*/ continue;
function getExtension() { }
if (os.value.toLowerCase() == osNames[0]) { if (osExtension.some(ext => build.name.endsWith(ext))) {
return macExtension console.log(build.name, build.browser_download_url);
} else if (os.value.toLowerCase() == osNames[1]) { return [build.name, build.browser_download_url];
return windowsExtension }
} }
return null return null;
}
function getExtension() {
return osList.find(osName => osName === currentOS.value.toLowerCase())?.endsWith('macos')
? macExtensionList
: windowsExtensionList;
} }

View File

@@ -10,9 +10,20 @@ export async function getOS() {
return await invoke('plugin:utils|get_os') return await invoke('plugin:utils|get_os')
} }
export async function getArtifact(downloadurl, filename, ostype, autoupdatesupported) { // [AR] Feature. Updater
console.log('Downloading build', downloadurl, filename, ostype, autoupdatesupported) export async function initUpdateLauncher(downloadUrl, filename, osType, autoUpdateSupported) {
return await invoke('plugin:utils|get_artifact', { downloadurl, filename, ostype, autoupdatesupported }) console.log('Downloading build', downloadUrl, filename, osType, autoUpdateSupported)
return await invoke('plugin:utils|init_update_launcher', { downloadUrl, filename, osType, autoUpdateSupported })
}
// [AR] Migration. Patch
export async function applyMigrationFix(eol) {
return await invoke('plugin:utils|apply_migration_fix', { eol })
}
// [AR] Feature. Ely.by
export async function initAuthlibPatching(minecraftVersion, isMojang) {
return await invoke('plugin:utils|init_authlib_patching', { minecraftVersion, isMojang })
} }
export async function openPath(path) { export async function openPath(path) {

View File

@@ -377,6 +377,12 @@
"instance.worlds.hardcore": { "instance.worlds.hardcore": {
"message": "Hardcore mode" "message": "Hardcore mode"
}, },
"instance.worlds.incompatible_server": {
"message": "Server is incompatible"
},
"instance.worlds.no_contact": {
"message": "Server couldn't be contacted"
},
"instance.worlds.no_quick_play": { "instance.worlds.no_quick_play": {
"message": "You can only jump straight into worlds on Minecraft 1.20+" "message": "You can only jump straight into worlds on Minecraft 1.20+"
}, },

View File

@@ -220,7 +220,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 +266,7 @@ async function onSearchChangeToTop() {
function clearSearch() { function clearSearch() {
query.value = '' query.value = ''
currentPage.value = 1
} }
watch( watch(

View File

@@ -38,15 +38,11 @@ import {
import { get as getSettings } from '@/helpers/settings.ts' import { get as getSettings } from '@/helpers/settings.ts'
import { get_default_user, login as login_flow, users } from '@/helpers/auth' import { get_default_user, login as login_flow, users } from '@/helpers/auth'
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts' import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
import { generateSkinPreviews, map } from '@/helpers/rendering/batch-skin-renderer.ts' import { generateSkinPreviews, skinBlobUrlMap } from '@/helpers/rendering/batch-skin-renderer.ts'
import { handleSevereError } from '@/store/error' import { handleSevereError } from '@/store/error'
import { trackEvent } from '@/helpers/analytics' import { trackEvent } from '@/helpers/analytics'
import type AccountsCard from '@/components/ui/AccountsCard.vue' import type AccountsCard from '@/components/ui/AccountsCard.vue'
import { arrayBufferToBase64 } from '@modrinth/utils' 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')
@@ -219,7 +215,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() {
@@ -320,9 +316,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"

View File

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

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

@@ -13,6 +13,8 @@ fn main() {
InlinedPlugin::new() InlinedPlugin::new()
.commands(&[ .commands(&[
"offline_login", "offline_login",
"elyby_login",
"elyby_auth_authenticate",
"login", "login",
"remove_user", "remove_user",
"get_default_user", "get_default_user",
@@ -121,7 +123,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,
), ),
@@ -218,7 +225,9 @@ fn main() {
"utils", "utils",
InlinedPlugin::new() InlinedPlugin::new()
.commands(&[ .commands(&[
"get_artifact", "init_authlib_patching",
"apply_migration_fix",
"init_update_launcher",
"get_os", "get_os",
"should_disable_mouseover", "should_disable_mouseover",
"highlight_in_folder", "highlight_in_folder",

View File

@@ -22,14 +22,13 @@
{ {
"identifier": "http:default", "identifier": "http:default",
"allow": [ "allow": [{ "url": "https://modrinth.com/*" }, { "url": "https://*.modrinth.com/*" }]
{ "url": "https://modrinth.com/*" },
{ "url": "https://*.modrinth.com/*" }
]
}, },
"auth:default", "auth:default",
"import:default", "import:default",
"import:allow-fetch-curseforge-profile-metadata",
"import:allow-import-curseforge-profile",
"jre:default", "jre:default",
"logs:default", "logs:default",
"metadata:default", "metadata:default",

View File

@@ -2,12 +2,15 @@ use crate::api::Result;
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};
use tauri::plugin::TauriPlugin; use tauri::plugin::TauriPlugin;
use tauri::{Manager, Runtime, UserAttentionType}; use tauri::{Manager, Runtime, UserAttentionType};
use tauri_plugin_http::reqwest::Client;
use theseus::prelude::*; use theseus::prelude::*;
pub fn init<R: Runtime>() -> TauriPlugin<R> { pub fn init<R: Runtime>() -> TauriPlugin<R> {
tauri::plugin::Builder::<R>::new("auth") tauri::plugin::Builder::<R>::new("auth")
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
offline_login, offline_login,
elyby_login,
elyby_auth_authenticate,
login, login,
remove_user, remove_user,
get_default_user, get_default_user,
@@ -17,14 +20,65 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
.build() .build()
} }
/// ### AR • Feature
/// Create new offline user /// Create new offline user
/// This is custom function from Astralium Org.
#[tauri::command] #[tauri::command]
pub async fn offline_login(name: &str) -> Result<Credentials> { pub async fn offline_login(name: &str) -> Result<Credentials> {
let credentials = minecraft_auth::offline_auth(name).await?; let credentials = minecraft_auth::offline_auth(name).await?;
Ok(credentials) Ok(credentials)
} }
/// ### AR • Feature
/// Create new Ely.by user
#[tauri::command]
pub async fn elyby_login(
uuid: uuid::Uuid,
login: &str,
access_token: &str
) -> Result<Credentials> {
let credentials = minecraft_auth::elyby_auth(uuid, login, access_token).await?;
Ok(credentials)
}
/// ### AR • Feature
/// Authenticate Ely.by user
#[tauri::command]
pub async fn elyby_auth_authenticate(
login: &str,
password: &str,
client_token: &str,
) -> Result<String> {
let client = Client::new();
let auth_body = serde_json::json!({
"username": login,
"password": password,
"clientToken": client_token,
});
let response = match client
.post("https://authserver.ely.by/auth/authenticate")
.header("Content-Type", "application/json")
.json(&auth_body)
.send()
.await
{
Ok(resp) => resp,
Err(e) => {
tracing::error!("[AR] • Failed to send request: {}", e);
return Ok("".to_string());
}
};
let text = match response.text().await {
Ok(body) => body,
Err(e) => {
tracing::error!("[AR] • Failed to read response text: {}", e);
return Ok("".to_string());
}
};
Ok(text)
}
/// Authenticate a user with Hydra - part 1 /// Authenticate a user with Hydra - part 1
/// This begins the authentication flow quasi-synchronously, returning a URL to visit (that the user will sign in at) /// This begins the authentication flow quasi-synchronously, returning a URL to visit (that the user will sign in at)
#[tauri::command] #[tauri::command]
@@ -42,7 +96,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(),
@@ -86,6 +140,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

@@ -2,6 +2,11 @@ use std::path::PathBuf;
use crate::api::Result; use crate::api::Result;
use theseus::pack::import::ImportLauncherType; use theseus::pack::import::ImportLauncherType;
use theseus::pack::import::curseforge_profile::{
CurseForgeProfileMetadata,
fetch_curseforge_profile_metadata as fetch_cf_metadata,
import_curseforge_profile as import_cf_profile,
};
use theseus::pack::import; use theseus::pack::import;
@@ -12,6 +17,8 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
import_instance, import_instance,
is_valid_importable_instance, is_valid_importable_instance,
get_default_launcher_path, get_default_launcher_path,
fetch_curseforge_profile_metadata,
import_curseforge_profile,
]) ])
.build() .build()
} }
@@ -68,3 +75,24 @@ pub async fn get_default_launcher_path(
) -> Result<Option<PathBuf>> { ) -> Result<Option<PathBuf>> {
Ok(import::get_default_launcher_path(launcher_type)) Ok(import::get_default_launcher_path(launcher_type))
} }
/// Fetch CurseForge profile metadata from profile code
/// eg: fetch_curseforge_profile_metadata("eSrNlKNo")
#[tauri::command]
pub async fn fetch_curseforge_profile_metadata(
profile_code: String,
) -> Result<CurseForgeProfileMetadata> {
Ok(fetch_cf_metadata(&profile_code).await?)
}
/// Import a CurseForge profile from profile code
/// profile_path should be a blank profile for this purpose- if the function fails, it will be deleted
/// eg: import_curseforge_profile("profile-path", "eSrNlKNo")
#[tauri::command]
pub async fn import_curseforge_profile(
profile_path: String,
profile_code: String,
) -> Result<()> {
import_cf_profile(&profile_code, &profile_path).await?;
Ok(())
}

View File

@@ -21,6 +21,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

@@ -10,12 +10,15 @@ use crate::api::{Result, TheseusSerializableError};
use dashmap::DashMap; use dashmap::DashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use theseus::prelude::canonicalize; use theseus::prelude::canonicalize;
use theseus::util::utils;
use url::Url; use url::Url;
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> { pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("utils") tauri::plugin::Builder::new("utils")
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
get_artifact, init_authlib_patching,
apply_migration_fix,
init_update_launcher,
get_os, get_os,
should_disable_mouseover, should_disable_mouseover,
highlight_in_folder, highlight_in_folder,
@@ -27,9 +30,39 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
.build() .build()
} }
/// [AR] Feature. Ely.by
#[tauri::command] #[tauri::command]
pub async fn get_artifact(downloadurl: &str, filename: &str, ostype: &str, autoupdatesupported: bool) -> Result<()> { pub async fn init_authlib_patching(
theseus::download::init_download(downloadurl, filename, ostype, autoupdatesupported).await; minecraft_version: &str,
is_mojang: bool,
) -> Result<bool> {
let result =
utils::init_authlib_patching(minecraft_version, is_mojang).await?;
Ok(result)
}
/// [AR] Migration. Patch
#[tauri::command]
pub async fn apply_migration_fix(eol: &str) -> Result<bool> {
let result = utils::apply_migration_fix(eol).await?;
Ok(result)
}
/// [AR] Feature. Updater
#[tauri::command]
pub async fn init_update_launcher(
download_url: &str,
filename: &str,
os_type: &str,
auto_update_supported: bool,
) -> Result<()> {
let _ = utils::init_update_launcher(
download_url,
filename,
os_type,
auto_update_supported,
)
.await;
Ok(()) Ok(())
} }

View File

@@ -34,9 +34,6 @@ async fn initialize_state(app: tauri::AppHandle) -> api::Result<()> {
// let update_fut = updater.check(); // let update_fut = updater.check();
// tracing::info!("Initializing app state...");
State::init().await?;
// let check_bar = theseus::init_loading( // let check_bar = theseus::init_loading(
// theseus::LoadingBarType::CheckingForUpdates, // theseus::LoadingBarType::CheckingForUpdates,
// 1.0, // 1.0,
@@ -87,7 +84,7 @@ async fn initialize_state(app: tauri::AppHandle) -> api::Result<()> {
// #[cfg(not(feature = "updater"))] // #[cfg(not(feature = "updater"))]
// { // {
// } // }
tracing::info!("Initializing app state...");
State::init().await?; State::init().await?;
tracing::info!("AstralRinth state successfully initialized."); tracing::info!("AstralRinth state successfully initialized.");
let state = State::get().await?; let state = State::get().await?;
@@ -160,14 +157,14 @@ fn main() {
*/ */
let _log_guard = theseus::start_logger(); let _log_guard = theseus::start_logger();
tracing::info!("Initialized tracing subscriber. Loading Modrinth App!"); tracing::info!("Initialized tracing subscriber. Loading AstralRinth App!");
let mut builder = tauri::Builder::default(); let mut builder = tauri::Builder::default();
#[cfg(feature = "updater")] // #[cfg(feature = "updater")]
{ // {
builder = builder.plugin(tauri_plugin_updater::Builder::new().build()); // builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
} // }
builder = builder builder = builder
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {

View File

@@ -41,7 +41,7 @@
] ]
}, },
"productName": "AstralRinth App", "productName": "AstralRinth App",
"version": "0.10.1", "version": "0.10.305",
"mainBinaryName": "AstralRinth App", "mainBinaryName": "AstralRinth App",
"identifier": "AstralRinthApp", "identifier": "AstralRinthApp",
"plugins": { "plugins": {
@@ -63,6 +63,7 @@
"height": 800, "height": 800,
"resizable": true, "resizable": true,
"title": "AstralRinth", "title": "AstralRinth",
"label": "main",
"width": 1280, "width": 1280,
"minHeight": 700, "minHeight": 700,
"minWidth": 1100, "minWidth": 1100,
@@ -86,7 +87,7 @@
"capabilities": ["core", "plugins"], "capabilities": ["core", "plugins"],
"csp": { "csp": {
"default-src": "'self' customprotocol: asset:", "default-src": "'self' customprotocol: asset:",
"connect-src": "https://git.astralium.su ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.posthog.com https://*.sentry.io https://api.mclo.gs 'self' data: blob:", "connect-src": "ipc: https://git.astralium.su https://authserver.ely.by http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.posthog.com https://*.sentry.io https://api.mclo.gs 'self' data: blob:",
"font-src": ["https://cdn-raw.modrinth.com/fonts/"], "font-src": ["https://cdn-raw.modrinth.com/fonts/"],
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:", "img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:",
"style-src": "'unsafe-inline' 'self'", "style-src": "'unsafe-inline' 'self'",

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,6 +59,7 @@
"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",
"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",

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

@@ -1,7 +1,10 @@
<template> <template>
<div <div
class="vue-notification-group experimental-styles-within" class="vue-notification-group experimental-styles-within"
:class="{ 'intercom-present': isIntercomPresent }" :class="{
'intercom-present': isIntercomPresent,
rightwards: moveNotificationsRight,
}"
> >
<transition-group name="notifs"> <transition-group name="notifs">
<div <div
@@ -82,6 +85,7 @@ import {
CopyIcon, CopyIcon,
} from "@modrinth/assets"; } from "@modrinth/assets";
const notifications = useNotifications(); const notifications = useNotifications();
const { isVisible: moveNotificationsRight } = useNotificationRightwards();
const isIntercomPresent = ref(false); const isIntercomPresent = ref(false);
@@ -160,6 +164,15 @@ function copyToClipboard(notif) {
bottom: 5rem; bottom: 5rem;
} }
&.rightwards {
right: unset !important;
left: 1.5rem;
@media screen and (max-width: 500px) {
left: 0.75rem;
}
}
.vue-notification-wrapper { .vue-notification-wrapper {
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;

View File

@@ -0,0 +1,116 @@
<template>
<NewModal ref="modal" header="Moderation shortcuts" :closable="true">
<div>
<div class="keybinds-sections">
<div class="grid grid-cols-2 gap-x-12 gap-y-3">
<div
v-for="keybind in keybinds"
:key="keybind.id"
class="keybind-item flex items-center justify-between gap-4"
:class="{
'col-span-2': keybinds.length % 2 === 1 && keybinds[keybinds.length - 1] === keybind,
}"
>
<span class="text-sm text-secondary">{{ keybind.description }}</span>
<div class="flex items-center gap-1">
<kbd
v-for="(key, index) in parseKeybindDisplay(keybind.keybind)"
:key="`${keybind.id}-key-${index}`"
class="keybind-key"
>
{{ key }}
</kbd>
</div>
</div>
</div>
</div>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import NewModal from "@modrinth/ui/src/components/modal/NewModal.vue";
import { keybinds, type KeybindListener, normalizeKeybind } from "@modrinth/moderation";
const modal = ref<InstanceType<typeof NewModal>>();
function parseKeybindDisplay(keybind: KeybindListener["keybind"]): string[] {
const keybinds = Array.isArray(keybind) ? keybind : [keybind];
const normalized = keybinds[0];
const def = normalizeKeybind(normalized);
const keys = [];
if (def.ctrl || def.meta) {
keys.push(isMac() ? "CMD" : "CTRL");
}
if (def.shift) keys.push("SHIFT");
if (def.alt) keys.push("ALT");
const mainKey = def.key
.replace("ArrowLeft", "←")
.replace("ArrowRight", "→")
.replace("ArrowUp", "↑")
.replace("ArrowDown", "↓")
.replace("Enter", "↵")
.replace("Space", "SPACE")
.replace("Escape", "ESC")
.toUpperCase();
keys.push(mainKey);
return keys;
}
function isMac() {
return navigator.platform.toUpperCase().indexOf("MAC") >= 0;
}
function show(event?: MouseEvent) {
modal.value?.show(event);
}
function hide() {
modal.value?.hide();
}
defineExpose({
show,
hide,
});
</script>
<style scoped lang="scss">
.keybind-key {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2rem;
padding: 0.25rem 0.5rem;
background-color: var(--color-bg);
border: 1px solid var(--color-divider);
border-radius: 0.375rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: var(--color-contrast);
+ .keybind-key {
margin-left: 0.25rem;
}
}
.keybind-item {
min-height: 2rem;
}
@media (max-width: 768px) {
.keybinds-sections {
.grid {
grid-template-columns: 1fr;
gap: 0.75rem;
}
}
}
</style>

View File

@@ -0,0 +1,513 @@
<template>
<div>
<h2 v-if="modPackData" class="m-0 mb-2 text-lg font-extrabold">
Modpack permissions ({{ Math.min(modPackData.length, currentIndex + 1) }} /
{{ modPackData.length }})
</h2>
<div v-if="!modPackData">Loading data...</div>
<div v-else-if="modPackData.length === 0">
<p>All permissions already obtained.</p>
</div>
<div v-else-if="!modPackData[currentIndex]">
<p>All permission checks complete!</p>
</div>
<div v-else>
<div v-if="modPackData[currentIndex].type === 'unknown'">
<p>What is the approval type of {{ modPackData[currentIndex].file_name }}?</p>
<div class="input-group">
<ButtonStyled
v-for="(option, index) in fileApprovalTypes"
:key="index"
:color="modPackData[currentIndex].status === option.id ? 'brand' : 'standard'"
@click="setStatus(currentIndex, option.id)"
>
<button>
{{ option.name }}
</button>
</ButtonStyled>
</div>
<div v-if="modPackData[currentIndex].status !== 'unidentified'" class="flex flex-col gap-1">
<label for="proof">
<span class="label__title">Proof</span>
</label>
<input
id="proof"
v-model="(modPackData[currentIndex] as ModerationUnknownModpackItem).proof"
type="text"
autocomplete="off"
placeholder="Enter proof of status..."
@input="persistAll()"
/>
<label for="link">
<span class="label__title">Link</span>
</label>
<input
id="link"
v-model="(modPackData[currentIndex] as ModerationUnknownModpackItem).url"
type="text"
autocomplete="off"
placeholder="Enter link of project..."
@input="persistAll()"
/>
<label for="title">
<span class="label__title">Title</span>
</label>
<input
id="title"
v-model="(modPackData[currentIndex] as ModerationUnknownModpackItem).title"
type="text"
autocomplete="off"
placeholder="Enter title of project..."
@input="persistAll()"
/>
</div>
</div>
<div v-else-if="modPackData[currentIndex].type === 'flame'">
<p>
What is the approval type of {{ modPackData[currentIndex].title }} (<a
:href="modPackData[currentIndex].url"
target="_blank"
class="text-link"
>{{ modPackData[currentIndex].url }}</a
>)?
</p>
<div class="input-group">
<ButtonStyled
v-for="(option, index) in fileApprovalTypes"
:key="index"
:color="modPackData[currentIndex].status === option.id ? 'brand' : 'standard'"
@click="setStatus(currentIndex, option.id)"
>
<button>
{{ option.name }}
</button>
</ButtonStyled>
</div>
</div>
<div
v-if="
['unidentified', 'no', 'with-attribution'].includes(
modPackData[currentIndex].status || '',
)
"
>
<p v-if="modPackData[currentIndex].status === 'unidentified'">
Does this project provide identification and permission for
<strong>{{ modPackData[currentIndex].file_name }}</strong
>?
</p>
<p v-else-if="modPackData[currentIndex].status === 'with-attribution'">
Does this project provide attribution for
<strong>{{ modPackData[currentIndex].file_name }}</strong
>?
</p>
<p v-else>
Does this project provide proof of permission for
<strong>{{ modPackData[currentIndex].file_name }}</strong
>?
</p>
<div class="input-group">
<ButtonStyled
v-for="(option, index) in filePermissionTypes"
:key="index"
:color="modPackData[currentIndex].approved === option.id ? 'brand' : 'standard'"
@click="setApproval(currentIndex, option.id)"
>
<button>
{{ option.name }}
</button>
</ButtonStyled>
</div>
</div>
</div>
<div class="mt-4 flex gap-2">
<ButtonStyled>
<button :disabled="currentIndex <= 0" @click="goToPrevious">
<LeftArrowIcon aria-hidden="true" />
Previous
</button>
</ButtonStyled>
<ButtonStyled v-if="modPackData && currentIndex < modPackData.length" color="blue">
<button :disabled="!canGoNext" @click="goToNext">
<RightArrowIcon aria-hidden="true" />
{{ currentIndex + 1 >= modPackData.length ? "Complete" : "Next" }}
</button>
</ButtonStyled>
</div>
</div>
</template>
<script setup lang="ts">
import { LeftArrowIcon, RightArrowIcon } from "@modrinth/assets";
import type {
ModerationJudgements,
ModerationModpackItem,
ModerationModpackResponse,
ModerationUnknownModpackItem,
ModerationFlameModpackItem,
ModerationModpackPermissionApprovalType,
ModerationPermissionType,
} from "@modrinth/utils";
import { ButtonStyled } from "@modrinth/ui";
import { ref, computed, watch, onMounted } from "vue";
import { useLocalStorage, useSessionStorage } from "@vueuse/core";
const props = defineProps<{
projectId: string;
modelValue?: ModerationJudgements;
}>();
const emit = defineEmits<{
complete: [];
"update:modelValue": [judgements: ModerationJudgements];
}>();
const persistedModPackData = useLocalStorage<ModerationModpackItem[] | null>(
`modpack-permissions-${props.projectId}`,
null,
{
serializer: {
read: (v: any) => (v ? JSON.parse(v) : null),
write: (v: any) => JSON.stringify(v),
},
},
);
const persistedIndex = useLocalStorage<number>(`modpack-permissions-index-${props.projectId}`, 0);
const modPackData = useSessionStorage<ModerationModpackItem[] | null>(
`modpack-permissions-data-${props.projectId}`,
null,
{
serializer: {
read: (v: any) => (v ? JSON.parse(v) : null),
write: (v: any) => JSON.stringify(v),
},
},
);
const permanentNoFiles = useSessionStorage<ModerationModpackItem[]>(
`modpack-permissions-permanent-no-${props.projectId}`,
[],
{
serializer: {
read: (v: any) => (v ? JSON.parse(v) : []),
write: (v: any) => JSON.stringify(v),
},
},
);
const currentIndex = ref(0);
const fileApprovalTypes: ModerationModpackPermissionApprovalType[] = [
{
id: "yes",
name: "Yes",
},
{
id: "with-attribution-and-source",
name: "With attribution and source",
},
{
id: "with-attribution",
name: "With attribution",
},
{
id: "no",
name: "No",
},
{
id: "permanent-no",
name: "Permanent no",
},
{
id: "unidentified",
name: "Unidentified",
},
];
const filePermissionTypes: ModerationPermissionType[] = [
{ id: "yes", name: "Yes" },
{ id: "no", name: "No" },
];
function persistAll() {
persistedModPackData.value = modPackData.value;
persistedIndex.value = currentIndex.value;
}
watch(
modPackData,
(newValue) => {
persistedModPackData.value = newValue;
},
{ deep: true },
);
watch(currentIndex, (newValue) => {
persistedIndex.value = newValue;
});
function loadPersistedData(): void {
if (persistedModPackData.value) {
modPackData.value = persistedModPackData.value;
}
currentIndex.value = persistedIndex.value;
}
function clearPersistedData(): void {
persistedModPackData.value = null;
persistedIndex.value = 0;
}
async function fetchModPackData(): Promise<void> {
try {
const data = (await useBaseFetch(`moderation/project/${props.projectId}`, {
internal: true,
})) as ModerationModpackResponse;
const permanentNoItems: ModerationModpackItem[] = Object.entries(data.identified || {})
.filter(([_, file]) => file.status === "permanent-no")
.map(
([sha1, file]): ModerationModpackItem => ({
sha1,
file_name: file.file_name,
type: "identified",
status: file.status,
approved: null,
}),
)
.sort((a, b) => a.file_name.localeCompare(b.file_name));
permanentNoFiles.value = permanentNoItems;
const sortedData: ModerationModpackItem[] = [
...Object.entries(data.identified || {})
.filter(
([_, file]) =>
file.status !== "yes" &&
file.status !== "with-attribution-and-source" &&
file.status !== "permanent-no",
)
.map(
([sha1, file]): ModerationModpackItem => ({
sha1,
file_name: file.file_name,
type: "identified",
status: file.status,
approved: null,
...(file.status === "unidentified" && {
proof: "",
url: "",
title: "",
}),
}),
)
.sort((a, b) => a.file_name.localeCompare(b.file_name)),
...Object.entries(data.unknown_files || {})
.map(
([sha1, fileName]): ModerationUnknownModpackItem => ({
sha1,
file_name: fileName,
type: "unknown",
status: null,
approved: null,
proof: "",
url: "",
title: "",
}),
)
.sort((a, b) => a.file_name.localeCompare(b.file_name)),
...Object.entries(data.flame_files || {})
.map(
([sha1, info]): ModerationFlameModpackItem => ({
sha1,
file_name: info.file_name,
type: "flame",
status: null,
approved: null,
id: info.id,
title: info.title || info.file_name,
url: info.url || `https://www.curseforge.com/minecraft/mc-mods/${info.id}`,
}),
)
.sort((a, b) => a.file_name.localeCompare(b.file_name)),
];
if (modPackData.value) {
const existingMap = new Map(modPackData.value.map((item) => [item.sha1, item]));
sortedData.forEach((item) => {
const existing = existingMap.get(item.sha1);
if (existing) {
Object.assign(item, {
status: existing.status,
approved: existing.approved,
...(item.type === "unknown" && {
proof: (existing as ModerationUnknownModpackItem).proof || "",
url: (existing as ModerationUnknownModpackItem).url || "",
title: (existing as ModerationUnknownModpackItem).title || "",
}),
...(item.type === "flame" && {
url: (existing as ModerationFlameModpackItem).url || item.url,
title: (existing as ModerationFlameModpackItem).title || item.title,
}),
});
}
});
}
modPackData.value = sortedData;
persistAll();
} catch (error) {
console.error("Failed to fetch modpack data:", error);
modPackData.value = [];
permanentNoFiles.value = [];
persistAll();
}
}
function goToPrevious(): void {
if (currentIndex.value > 0) {
currentIndex.value--;
persistAll();
}
}
watch(
modPackData,
(newValue) => {
persistedModPackData.value = newValue;
},
{ deep: true },
);
function goToNext(): void {
if (modPackData.value && currentIndex.value < modPackData.value.length) {
currentIndex.value++;
if (currentIndex.value >= modPackData.value.length) {
const judgements = getJudgements();
emit("update:modelValue", judgements);
emit("complete");
clearPersistedData();
} else {
persistAll();
}
}
}
function setStatus(index: number, status: ModerationModpackPermissionApprovalType["id"]): void {
if (modPackData.value && modPackData.value[index]) {
modPackData.value[index].status = status;
modPackData.value[index].approved = null;
persistAll();
emit("update:modelValue", getJudgements());
}
}
function setApproval(index: number, approved: ModerationPermissionType["id"]): void {
if (modPackData.value && modPackData.value[index]) {
modPackData.value[index].approved = approved;
persistAll();
emit("update:modelValue", getJudgements());
}
}
const canGoNext = computed(() => {
if (!modPackData.value || !modPackData.value[currentIndex.value]) return false;
const current = modPackData.value[currentIndex.value];
return current.status !== null;
});
function getJudgements(): ModerationJudgements {
if (!modPackData.value) return {};
const judgements: ModerationJudgements = {};
modPackData.value.forEach((item) => {
if (item.type === "flame") {
judgements[item.sha1] = {
type: "flame",
id: item.id,
status: item.status,
link: item.url,
title: item.title,
file_name: item.file_name,
};
} else if (item.type === "unknown") {
judgements[item.sha1] = {
type: "unknown",
status: item.status,
proof: item.proof,
link: item.url,
title: item.title,
file_name: item.file_name,
};
}
});
return judgements;
}
onMounted(() => {
loadPersistedData();
if (!modPackData.value) {
fetchModPackData();
}
});
watch(
modPackData,
(newValue) => {
if (newValue && newValue.length === 0) {
emit("complete");
clearPersistedData();
}
},
{ immediate: true },
);
watch(
() => props.projectId,
() => {
clearPersistedData();
loadPersistedData();
if (!modPackData.value) {
fetchModPackData();
}
},
);
function getModpackFiles(): {
interactive: ModerationModpackItem[];
permanentNo: ModerationModpackItem[];
} {
return {
interactive: modPackData.value || [],
permanentNo: permanentNoFiles.value,
};
}
defineExpose({
getModpackFiles,
});
</script>
<style scoped>
.input-group {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.modpack-buttons {
margin-top: 1rem;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -172,6 +172,7 @@ const flags = useFeatureFlags();
.markdown-body { .markdown-body {
grid-area: body; grid-area: body;
max-width: 100%;
} }
.reporter-info { .reporter-info {

View File

@@ -31,9 +31,9 @@
class="flex cursor-pointer items-center gap-1 bg-transparent p-0" class="flex cursor-pointer items-center gap-1 bg-transparent p-0"
@click=" @click="
versionFilter && versionFilter &&
(unlockFilterAccordion.isOpen (unlockFilterAccordion.isOpen
? unlockFilterAccordion.close() ? unlockFilterAccordion.close()
: unlockFilterAccordion.open()) : unlockFilterAccordion.open())
" "
> >
<TagItem <TagItem

View File

@@ -102,7 +102,7 @@ export class ModrinthServer {
try { try {
const fileData = await useServersFetch(`/download?path=/server-icon-original.png`, { const fileData = await useServersFetch(`/download?path=/server-icon-original.png`, {
override: auth, override: auth,
retry: false, retry: 1, // Reduce retries for optional resources
}); });
if (fileData instanceof Blob && import.meta.client) { if (fileData instanceof Blob && import.meta.client) {
@@ -124,8 +124,14 @@ export class ModrinthServer {
return dataURL; return dataURL;
} }
} catch (error) { } catch (error) {
if (error instanceof ModrinthServerError && error.statusCode === 404) { if (error instanceof ModrinthServerError) {
if (iconUrl) { if (error.statusCode && error.statusCode >= 500) {
console.debug("Service unavailable, skipping icon processing");
sharedImage.value = undefined;
return undefined;
}
if (error.statusCode === 404 && iconUrl) {
try { try {
const response = await fetch(iconUrl); const response = await fetch(iconUrl);
if (!response.ok) throw new Error("Failed to fetch icon"); if (!response.ok) throw new Error("Failed to fetch icon");
@@ -187,6 +193,44 @@ export class ModrinthServer {
return undefined; return undefined;
} }
async testNodeReachability(): Promise<boolean> {
if (!this.general?.node?.instance) {
console.warn("No node instance available for ping test");
return false;
}
const wsUrl = `wss://${this.general.node.instance}/pingtest`;
try {
return await new Promise((resolve) => {
const socket = new WebSocket(wsUrl);
const timeout = setTimeout(() => {
socket.close();
resolve(false);
}, 5000);
socket.onopen = () => {
clearTimeout(timeout);
socket.send(performance.now().toString());
};
socket.onmessage = () => {
clearTimeout(timeout);
socket.close();
resolve(true);
};
socket.onerror = () => {
clearTimeout(timeout);
resolve(false);
};
});
} catch (error) {
console.error(`Failed to ping node ${wsUrl}:`, error);
return false;
}
}
async refresh( async refresh(
modules: ModuleName[] = [], modules: ModuleName[] = [],
options?: { options?: {
@@ -200,6 +244,8 @@ export class ModrinthServer {
: (["general", "content", "backups", "network", "startup", "ws", "fs"] as ModuleName[]); : (["general", "content", "backups", "network", "startup", "ws", "fs"] as ModuleName[]);
for (const module of modulesToRefresh) { for (const module of modulesToRefresh) {
this.errors[module] = undefined;
try { try {
switch (module) { switch (module) {
case "general": { case "general": {
@@ -250,7 +296,7 @@ export class ModrinthServer {
continue; continue;
} }
if (error.statusCode === 503) { if (error.statusCode && error.statusCode >= 500) {
console.debug(`Temporary ${module} unavailable:`, error.message); console.debug(`Temporary ${module} unavailable:`, error.message);
continue; continue;
} }

View File

@@ -22,26 +22,49 @@ export class FSModule extends ServerModule {
this.opsQueuedForModification = []; this.opsQueuedForModification = [];
} }
private async retryWithAuth<T>(requestFn: () => Promise<T>): Promise<T> { private async retryWithAuth<T>(
requestFn: () => Promise<T>,
ignoreFailure: boolean = false,
): Promise<T> {
try { try {
return await requestFn(); return await requestFn();
} catch (error) { } catch (error) {
if (error instanceof ModrinthServerError && error.statusCode === 401) { if (error instanceof ModrinthServerError && error.statusCode === 401) {
console.debug("Auth failed, refreshing JWT and retrying");
await this.fetch(); // Refresh auth await this.fetch(); // Refresh auth
return await requestFn(); return await requestFn();
} }
const available = await this.server.testNodeReachability();
if (!available && !ignoreFailure) {
this.server.moduleErrors.general = {
error: new ModrinthServerError(
"Unable to reach node. FS operation failed and subsequent ping test failed.",
500,
error as Error,
"fs",
),
timestamp: Date.now(),
};
}
throw error; throw error;
} }
} }
listDirContents(path: string, page: number, pageSize: number): Promise<DirectoryResponse> { listDirContents(
path: string,
page: number,
pageSize: number,
ignoreFailure: boolean = false,
): Promise<DirectoryResponse> {
return this.retryWithAuth(async () => { return this.retryWithAuth(async () => {
const encodedPath = encodeURIComponent(path); const encodedPath = encodeURIComponent(path);
return await useServersFetch(`/list?path=${encodedPath}&page=${page}&page_size=${pageSize}`, { return await useServersFetch(`/list?path=${encodedPath}&page=${page}&page_size=${pageSize}`, {
override: this.auth, override: this.auth,
retry: false, retry: false,
}); });
}); }, ignoreFailure);
} }
createFileOrFolder(path: string, type: "file" | "directory"): Promise<void> { createFileOrFolder(path: string, type: "file" | "directory"): Promise<void> {
@@ -150,7 +173,7 @@ export class FSModule extends ServerModule {
}); });
} }
downloadFile(path: string, raw?: boolean): Promise<any> { downloadFile(path: string, raw: boolean = false, ignoreFailure: boolean = false): Promise<any> {
return this.retryWithAuth(async () => { return this.retryWithAuth(async () => {
const encodedPath = encodeURIComponent(path); const encodedPath = encodeURIComponent(path);
const fileData = await useServersFetch(`/download?path=${encodedPath}`, { const fileData = await useServersFetch(`/download?path=${encodedPath}`, {
@@ -161,7 +184,7 @@ export class FSModule extends ServerModule {
return raw ? fileData : await fileData.text(); return raw ? fileData : await fileData.text();
} }
return fileData; return fileData;
}); }, ignoreFailure);
} }
extractFile( extractFile(

View File

@@ -46,13 +46,18 @@ export class GeneralModule extends ServerModule implements ServerGeneral {
data.image = (await this.server.processImage(data.project?.icon_url)) ?? undefined; data.image = (await this.server.processImage(data.project?.icon_url)) ?? undefined;
} }
const motd = await this.getMotd(); try {
if (motd === "A Minecraft Server") { const motd = await this.getMotd();
await this.setMotd( if (motd === "A Minecraft Server") {
`§b${data.project?.title || data.loader + " " + data.mc_version} §f♦ §aModrinth Servers`, await this.setMotd(
); `§b${data.project?.title || data.loader + " " + data.mc_version} §f♦ §aModrinth Servers`,
);
}
data.motd = motd;
} catch {
console.error("[Modrinth Servers] [General] Failed to fetch MOTD.");
data.motd = undefined;
} }
data.motd = motd;
// Copy data to this module // Copy data to this module
Object.assign(this, data); Object.assign(this, data);
@@ -178,7 +183,7 @@ export class GeneralModule extends ServerModule implements ServerGeneral {
async getMotd(): Promise<string | undefined> { async getMotd(): Promise<string | undefined> {
try { try {
const props = await this.server.fs.downloadFile("/server.properties"); const props = await this.server.fs.downloadFile("/server.properties", false, true);
if (props) { if (props) {
const lines = props.split("\n"); const lines = props.split("\n");
for (const line of lines) { for (const line of lines) {

View File

@@ -42,6 +42,23 @@ export async function useServersFetch<T>(
retry = method === "GET" ? 3 : 0, retry = method === "GET" ? 3 : 0,
} = options; } = options;
const circuitBreakerKey = `${module || "default"}_${path}`;
const failureCount = useState<number>(`fetch_failures_${circuitBreakerKey}`, () => 0);
const lastFailureTime = useState<number>(`last_failure_${circuitBreakerKey}`, () => 0);
const now = Date.now();
if (failureCount.value >= 3 && now - lastFailureTime.value < 30000) {
const error = new ModrinthServersFetchError(
"[Modrinth Servers] Circuit breaker open - too many recent failures",
503,
);
throw new ModrinthServerError("Service temporarily unavailable", 503, error, module);
}
if (now - lastFailureTime.value > 30000) {
failureCount.value = 0;
}
const base = (import.meta.server ? config.pyroBaseUrl : config.public.pyroBaseUrl)?.replace( const base = (import.meta.server ? config.pyroBaseUrl : config.public.pyroBaseUrl)?.replace(
/\/$/, /\/$/,
"", "",
@@ -69,6 +86,7 @@ export async function useServersFetch<T>(
const headers: Record<string, string> = { const headers: Record<string, string> = {
"User-Agent": "Modrinth/1.0 (https://modrinth.com)", "User-Agent": "Modrinth/1.0 (https://modrinth.com)",
"X-Archon-Request": "true",
Vary: "Accept, Origin", Vary: "Accept, Origin",
}; };
@@ -94,10 +112,12 @@ export async function useServersFetch<T>(
const response = await $fetch<T>(fullUrl, { const response = await $fetch<T>(fullUrl, {
method, method,
headers, headers,
body: body && contentType === "application/json" ? JSON.stringify(body) : body ?? undefined, body:
body && contentType === "application/json" ? JSON.stringify(body) : (body ?? undefined),
timeout: 10000, timeout: 10000,
}); });
failureCount.value = 0;
return response; return response;
} catch (error) { } catch (error) {
lastError = error as Error; lastError = error as Error;
@@ -107,6 +127,11 @@ export async function useServersFetch<T>(
const statusCode = error.response?.status; const statusCode = error.response?.status;
const statusText = error.response?.statusText || "Unknown error"; const statusText = error.response?.statusText || "Unknown error";
if (statusCode && statusCode >= 500) {
failureCount.value++;
lastFailureTime.value = now;
}
let v1Error: V1ErrorInfo | undefined; let v1Error: V1ErrorInfo | undefined;
if (error.data?.error && error.data?.description) { if (error.data?.error && error.data?.description) {
v1Error = { v1Error = {
@@ -134,9 +159,11 @@ export async function useServersFetch<T>(
? errorMessages[statusCode] ? errorMessages[statusCode]
: `HTTP Error: ${statusCode || "unknown"} ${statusText}`; : `HTTP Error: ${statusCode || "unknown"} ${statusText}`;
const isRetryable = statusCode ? [408, 429, 500, 502, 504].includes(statusCode) : true; const isRetryable = statusCode ? [408, 429].includes(statusCode) : false;
const is5xxRetryable =
statusCode && statusCode >= 500 && statusCode < 600 && method === "GET" && attempts === 1;
if (!isRetryable || attempts >= maxAttempts) { if (!(isRetryable || is5xxRetryable) || attempts >= maxAttempts) {
console.error("Fetch error:", error); console.error("Fetch error:", error);
const fetchError = new ModrinthServersFetchError( const fetchError = new ModrinthServersFetchError(
@@ -147,7 +174,8 @@ export async function useServersFetch<T>(
throw new ModrinthServerError(error.message, statusCode, fetchError, module, v1Error); throw new ModrinthServerError(error.message, statusCode, fetchError, module, v1Error);
} }
const delay = Math.min(1000 * Math.pow(2, attempts - 1) + Math.random() * 1000, 10000); const baseDelay = statusCode && statusCode >= 500 ? 5000 : 1000;
const delay = Math.min(baseDelay * Math.pow(2, attempts - 1) + Math.random() * 1000, 15000);
console.warn(`Retrying request in ${delay}ms (attempt ${attempts}/${maxAttempts - 1})`); console.warn(`Retrying request in ${delay}ms (attempt ${attempts}/${maxAttempts - 1})`);
await new Promise((resolve) => setTimeout(resolve, delay)); await new Promise((resolve) => setTimeout(resolve, delay));
continue; continue;

View File

@@ -0,0 +1,12 @@
export const useNotificationRightwards = () => {
const isVisible = useState("moderation-checklist-notifications", () => false);
const setVisible = (visible: boolean) => {
isVisible.value = visible;
};
return {
isVisible: readonly(isVisible),
setVisible,
};
};

View File

@@ -700,7 +700,6 @@ import {
PackageOpenIcon, PackageOpenIcon,
DiscordIcon, DiscordIcon,
BlueskyIcon, BlueskyIcon,
TumblrIcon,
TwitterIcon, TwitterIcon,
MastodonIcon, MastodonIcon,
GithubIcon, GithubIcon,
@@ -1185,13 +1184,6 @@ const socialLinks = [
icon: MastodonIcon, icon: MastodonIcon,
rel: "me", rel: "me",
}, },
{
label: formatMessage(
defineMessage({ id: "layout.footer.social.tumblr", defaultMessage: "Tumblr" }),
),
href: "https://tumblr.com/modrinth",
icon: TumblrIcon,
},
{ {
label: formatMessage(defineMessage({ id: "layout.footer.social.x", defaultMessage: "X" })), label: formatMessage(defineMessage({ id: "layout.footer.social.x", defaultMessage: "X" })),
href: "https://x.com/modrinth", href: "https://x.com/modrinth",
@@ -1346,6 +1338,15 @@ const footerLinks = [
}), }),
), ),
}, },
{
href: "/legal/copyright",
label: formatMessage(
defineMessage({
id: "layout.footer.legal.copyright-policy",
defaultMessage: "Copyright Policy and DMCA",
}),
),
},
], ],
}, },
]; ];

View File

@@ -383,15 +383,15 @@
"layout.footer.about": { "layout.footer.about": {
"message": "About" "message": "About"
}, },
"layout.footer.about.news": {
"message": "News"
},
"layout.footer.about.careers": { "layout.footer.about.careers": {
"message": "Careers" "message": "Careers"
}, },
"layout.footer.about.changelog": { "layout.footer.about.changelog": {
"message": "Changelog" "message": "Changelog"
}, },
"layout.footer.about.news": {
"message": "News"
},
"layout.footer.about.rewards-program": { "layout.footer.about.rewards-program": {
"message": "Rewards Program" "message": "Rewards Program"
}, },
@@ -404,6 +404,9 @@
"layout.footer.legal-disclaimer": { "layout.footer.legal-disclaimer": {
"message": "NOT AN OFFICIAL MINECRAFT SERVICE. NOT APPROVED BY OR ASSOCIATED WITH MOJANG OR MICROSOFT." "message": "NOT AN OFFICIAL MINECRAFT SERVICE. NOT APPROVED BY OR ASSOCIATED WITH MOJANG OR MICROSOFT."
}, },
"layout.footer.legal.copyright-policy": {
"message": "Copyright Policy and DMCA"
},
"layout.footer.legal.privacy-policy": { "layout.footer.legal.privacy-policy": {
"message": "Privacy Policy" "message": "Privacy Policy"
}, },
@@ -458,9 +461,6 @@
"layout.footer.social.mastodon": { "layout.footer.social.mastodon": {
"message": "Mastodon" "message": "Mastodon"
}, },
"layout.footer.social.tumblr": {
"message": "Tumblr"
},
"layout.footer.social.x": { "layout.footer.social.x": {
"message": "X" "message": "X"
}, },

View File

@@ -29,12 +29,11 @@
class="settings-header__icon" class="settings-header__icon"
/> />
<div class="settings-header__text"> <div class="settings-header__text">
<h1 class="wrap-as-needed"> <h1 class="wrap-as-needed">{{ project.title }}</h1>
{{ project.title }}
</h1>
<ProjectStatusBadge :status="project.status" /> <ProjectStatusBadge :status="project.status" />
</div> </div>
</div> </div>
<h2>Project settings</h2> <h2>Project settings</h2>
<NavStack> <NavStack>
<NavStackItem <NavStackItem
@@ -111,6 +110,7 @@
</NavStack> </NavStack>
</aside> </aside>
</div> </div>
<div class="normal-page__content"> <div class="normal-page__content">
<ProjectMemberHeader <ProjectMemberHeader
v-if="currentMember" v-if="currentMember"
@@ -145,6 +145,7 @@
/> />
</div> </div>
</div> </div>
<div v-else class="experimental-styles-within"> <div v-else class="experimental-styles-within">
<NewModal ref="settingsModal"> <NewModal ref="settingsModal">
<template #title> <template #title>
@@ -174,9 +175,11 @@
<div <div
class="animation-ring-3 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-40" class="animation-ring-3 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-40"
></div> ></div>
<div <div
class="animation-ring-2 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-60" class="animation-ring-2 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-60"
></div> ></div>
<div <div
class="animation-ring-1 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight" class="animation-ring-1 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight"
> >
@@ -219,8 +222,7 @@
:href="`modrinth://mod/${project.slug}`" :href="`modrinth://mod/${project.slug}`"
@click="() => installWithApp()" @click="() => installWithApp()"
> >
<ModrinthIcon aria-hidden="true" /> <ModrinthIcon aria-hidden="true" /> Install with Modrinth App
Install with Modrinth App
<ExternalIcon aria-hidden="true" /> <ExternalIcon aria-hidden="true" />
</a> </a>
</ButtonStyled> </ButtonStyled>
@@ -240,6 +242,7 @@
<div class="flex h-[2px] w-full rounded-2xl bg-button-bg"></div> <div class="flex h-[2px] w-full rounded-2xl bg-button-bg"></div>
</div> </div>
</div> </div>
<div class="mx-auto flex w-fit flex-col gap-2"> <div class="mx-auto flex w-fit flex-col gap-2">
<ButtonStyled v-if="project.game_versions.length === 1"> <ButtonStyled v-if="project.game_versions.length === 1">
<div class="disabled button-like"> <div class="disabled button-like">
@@ -327,8 +330,7 @@
} }
" "
> >
{{ gameVersion }} {{ gameVersion }} <CheckIcon v-if="userSelectedGameVersion === gameVersion" />
<CheckIcon v-if="userSelectedGameVersion === gameVersion" />
</button> </button>
</ButtonStyled> </ButtonStyled>
</ScrollablePanel> </ScrollablePanel>
@@ -419,7 +421,6 @@
</ScrollablePanel> </ScrollablePanel>
</Accordion> </Accordion>
</div> </div>
<AutomaticAccordion div class="flex flex-col gap-2"> <AutomaticAccordion div class="flex flex-col gap-2">
<VersionSummary <VersionSummary
v-if="filteredRelease" v-if="filteredRelease"
@@ -470,10 +471,14 @@
class="new-page sidebar" class="new-page sidebar"
:class="{ :class="{
'alt-layout': cosmetics.leftContentLayout, 'alt-layout': cosmetics.leftContentLayout,
'ultimate-sidebar': 'checklist-open':
showModerationChecklist && showModerationChecklist &&
!collapsedModerationChecklist && !collapsedModerationChecklist &&
!flags.alwaysShowChecklistAsPopup, !flags.alwaysShowChecklistAsPopup,
'checklist-collapsed':
showModerationChecklist &&
collapsedModerationChecklist &&
!flags.alwaysShowChecklistAsPopup,
}" }"
> >
<div class="normal-page__header relative my-4"> <div class="normal-page__header relative my-4">
@@ -485,11 +490,11 @@
:color="route.name === 'type-id-version-version' ? `standard` : `brand`" :color="route.name === 'type-id-version-version' ? `standard` : `brand`"
> >
<button @click="(event) => downloadModal.show(event)"> <button @click="(event) => downloadModal.show(event)">
<DownloadIcon aria-hidden="true" /> <DownloadIcon aria-hidden="true" /> Download
Download
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
<div class="contents sm:hidden"> <div class="contents sm:hidden">
<ButtonStyled <ButtonStyled
size="large" size="large"
@@ -554,9 +559,11 @@
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
<p class="m-0 text-wrap text-sm font-medium leading-tight text-secondary"> <p class="m-0 text-wrap text-sm font-medium leading-tight text-secondary">
Modrinth Servers is the easiest way to play with your friends without hassle! Modrinth Servers is the easiest way to play with your friends without hassle!
</p> </p>
<p class="m-0 text-wrap text-sm font-bold text-primary"> <p class="m-0 text-wrap text-sm font-bold text-primary">
Starting at $5<span class="text-xs"> / month</span> Starting at $5<span class="text-xs"> / month</span>
</p> </p>
@@ -621,6 +628,7 @@
{{ option.name }} {{ option.name }}
</Checkbox> </Checkbox>
</div> </div>
<div v-else class="menu-text"> <div v-else class="menu-text">
<p class="popout-text">No collections found.</p> <p class="popout-text">No collections found.</p>
</div> </div>
@@ -628,8 +636,7 @@
class="btn collection-button" class="btn collection-button"
@click="(event) => $refs.modal_collection.show(event)" @click="(event) => $refs.modal_collection.show(event)"
> >
<PlusIcon aria-hidden="true" /> <PlusIcon aria-hidden="true" /> Create new collection
Create new collection
</button> </button>
</template> </template>
</PopoutMenu> </PopoutMenu>
@@ -712,25 +719,14 @@
:dropdown-id="`${baseId}-more-options`" :dropdown-id="`${baseId}-more-options`"
> >
<MoreVerticalIcon aria-hidden="true" /> <MoreVerticalIcon aria-hidden="true" />
<template #analytics> <template #analytics> <ChartIcon aria-hidden="true" /> Analytics </template>
<ChartIcon aria-hidden="true" />
Analytics
</template>
<template #moderation-checklist> <template #moderation-checklist>
<ScaleIcon aria-hidden="true" /> <ScaleIcon aria-hidden="true" /> Review project
Review project
</template>
<template #report>
<ReportIcon aria-hidden="true" />
Report
</template>
<template #copy-id>
<ClipboardCopyIcon aria-hidden="true" />
Copy ID
</template> </template>
<template #report> <ReportIcon aria-hidden="true" /> Report </template>
<template #copy-id> <ClipboardCopyIcon aria-hidden="true" /> Copy ID </template>
<template #copy-permalink> <template #copy-permalink>
<ClipboardCopyIcon aria-hidden="true" /> <ClipboardCopyIcon aria-hidden="true" /> Copy permanent link
Copy permanent link
</template> </template>
</OverflowMenu> </OverflowMenu>
</ButtonStyled> </ButtonStyled>
@@ -756,6 +752,7 @@
updates unless the author decides to unarchive the project. updates unless the author decides to unarchive the project.
</MessageBanner> </MessageBanner>
</div> </div>
<div class="normal-page__sidebar"> <div class="normal-page__sidebar">
<ProjectSidebarCompatibility <ProjectSidebarCompatibility
:project="project" :project="project"
@@ -785,6 +782,7 @@
/> />
<div class="card flex-card experimental-styles-within"> <div class="card flex-card experimental-styles-within">
<h2>{{ formatMessage(detailsMessages.title) }}</h2> <h2>{{ formatMessage(detailsMessages.title) }}</h2>
<div class="details-list"> <div class="details-list">
<div class="details-list__item"> <div class="details-list__item">
<BookTextIcon aria-hidden="true" /> <BookTextIcon aria-hidden="true" />
@@ -813,53 +811,48 @@
<span v-else>{{ licenseIdDisplay }}</span> <span v-else>{{ licenseIdDisplay }}</span>
</div> </div>
</div> </div>
<div <div
v-if="project.approved" v-if="project.approved"
v-tooltip="$dayjs(project.approved).format('MMMM D, YYYY [at] h:mm A')" v-tooltip="$dayjs(project.approved).format('MMMM D, YYYY [at] h:mm A')"
class="details-list__item" class="details-list__item"
> >
<CalendarIcon aria-hidden="true" /> <CalendarIcon aria-hidden="true" />
<div> <div>{{ formatMessage(detailsMessages.published, { date: publishedDate }) }}</div>
{{ formatMessage(detailsMessages.published, { date: publishedDate }) }}
</div>
</div> </div>
<div <div
v-else v-else
v-tooltip="$dayjs(project.published).format('MMMM D, YYYY [at] h:mm A')" v-tooltip="$dayjs(project.published).format('MMMM D, YYYY [at] h:mm A')"
class="details-list__item" class="details-list__item"
> >
<CalendarIcon aria-hidden="true" /> <CalendarIcon aria-hidden="true" />
<div> <div>{{ formatMessage(detailsMessages.created, { date: createdDate }) }}</div>
{{ formatMessage(detailsMessages.created, { date: createdDate }) }}
</div>
</div> </div>
<div <div
v-if="project.status === 'processing' && project.queued" v-if="project.status === 'processing' && project.queued"
v-tooltip="$dayjs(project.queued).format('MMMM D, YYYY [at] h:mm A')" v-tooltip="$dayjs(project.queued).format('MMMM D, YYYY [at] h:mm A')"
class="details-list__item" class="details-list__item"
> >
<ScaleIcon aria-hidden="true" /> <ScaleIcon aria-hidden="true" />
<div> <div>{{ formatMessage(detailsMessages.submitted, { date: submittedDate }) }}</div>
{{ formatMessage(detailsMessages.submitted, { date: submittedDate }) }}
</div>
</div> </div>
<div <div
v-if="versions.length > 0 && project.updated" v-if="versions.length > 0 && project.updated"
v-tooltip="$dayjs(project.updated).format('MMMM D, YYYY [at] h:mm A')" v-tooltip="$dayjs(project.updated).format('MMMM D, YYYY [at] h:mm A')"
class="details-list__item" class="details-list__item"
> >
<VersionIcon aria-hidden="true" /> <VersionIcon aria-hidden="true" />
<div> <div>{{ formatMessage(detailsMessages.updated, { date: updatedDate }) }}</div>
{{ formatMessage(detailsMessages.updated, { date: updatedDate }) }}
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="normal-page__content"> <div class="normal-page__content">
<div class="overflow-x-auto"> <div class="overflow-x-auto"><NavTabs :links="navLinks" class="mb-4" /></div>
<NavTabs :links="navLinks" class="mb-4" />
</div>
<NuxtPage <NuxtPage
v-model:project="project" v-model:project="project"
v-model:versions="versions" v-model:versions="versions"
@@ -877,8 +870,10 @@
@delete-version="deleteVersion" @delete-version="deleteVersion"
/> />
</div> </div>
<div class="normal-page__ultimate-sidebar"> <div class="normal-page__ultimate-sidebar">
<ModerationChecklist <!-- Uncomment this to enable the old moderation checklist. -->
<!-- <ModerationChecklist
v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist" v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist"
:project="project" :project="project"
:future-projects="futureProjects" :future-projects="futureProjects"
@@ -886,11 +881,25 @@
:collapsed="collapsedModerationChecklist" :collapsed="collapsedModerationChecklist"
@exit="showModerationChecklist = false" @exit="showModerationChecklist = false"
@toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist" @toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist"
/> /> -->
</div> </div>
</div> </div>
</div> </div>
<div
v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist"
class="moderation-checklist"
>
<NewModerationChecklist
:project="project"
:future-project-ids="futureProjectIds"
:collapsed="collapsedModerationChecklist"
@exit="showModerationChecklist = false"
@toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist"
/>
</div>
</template> </template>
<script setup> <script setup>
import { import {
BookmarkIcon, BookmarkIcon,
@@ -950,16 +959,16 @@ import {
isUnderReview, isUnderReview,
renderString, renderString,
} from "@modrinth/utils"; } from "@modrinth/utils";
import { navigateTo } from "#app";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { Tooltip } from "floating-vue"; import { Tooltip } from "floating-vue";
import { useLocalStorage } from "@vueuse/core";
import { navigateTo } from "#app";
import Accordion from "~/components/ui/Accordion.vue"; import Accordion from "~/components/ui/Accordion.vue";
// import AdPlaceholder from "~/components/ui/AdPlaceholder.vue"; // import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
import AutomaticAccordion from "~/components/ui/AutomaticAccordion.vue"; import AutomaticAccordion from "~/components/ui/AutomaticAccordion.vue";
import Breadcrumbs from "~/components/ui/Breadcrumbs.vue"; import Breadcrumbs from "~/components/ui/Breadcrumbs.vue";
import CollectionCreateModal from "~/components/ui/CollectionCreateModal.vue"; import CollectionCreateModal from "~/components/ui/CollectionCreateModal.vue";
import MessageBanner from "~/components/ui/MessageBanner.vue"; import MessageBanner from "~/components/ui/MessageBanner.vue";
import ModerationChecklist from "~/components/ui/ModerationChecklist.vue";
import NavStack from "~/components/ui/NavStack.vue"; import NavStack from "~/components/ui/NavStack.vue";
import NavStackItem from "~/components/ui/NavStackItem.vue"; import NavStackItem from "~/components/ui/NavStackItem.vue";
import NavTabs from "~/components/ui/NavTabs.vue"; import NavTabs from "~/components/ui/NavTabs.vue";
@@ -967,6 +976,7 @@ import ProjectMemberHeader from "~/components/ui/ProjectMemberHeader.vue";
import { userCollectProject } from "~/composables/user.js"; import { userCollectProject } from "~/composables/user.js";
import { reportProject } from "~/utils/report-helpers.ts"; import { reportProject } from "~/utils/report-helpers.ts";
import { saveFeatureFlags } from "~/composables/featureFlags.ts"; import { saveFeatureFlags } from "~/composables/featureFlags.ts";
import NewModerationChecklist from "~/components/ui/moderation/NewModerationChecklist.vue";
const data = useNuxtApp(); const data = useNuxtApp();
const route = useNativeRoute(); const route = useNativeRoute();
@@ -980,6 +990,7 @@ const flags = useFeatureFlags();
const cosmetics = useCosmetics(); const cosmetics = useCosmetics();
const { formatMessage } = useVIntl(); const { formatMessage } = useVIntl();
const { setVisible } = useNotificationRightwards();
const settingsModal = ref(); const settingsModal = ref();
const downloadModal = ref(); const downloadModal = ref();
@@ -1551,12 +1562,28 @@ async function copyPermalink() {
const collapsedChecklist = ref(false); const collapsedChecklist = ref(false);
const showModerationChecklist = ref(false); const showModerationChecklist = useLocalStorage(
const collapsedModerationChecklist = ref(false); `show-moderation-checklist-${project.value.id}`,
const futureProjects = ref([]); false,
);
const collapsedModerationChecklist = useLocalStorage("collapsed-moderation-checklist", false);
const futureProjectIds = useLocalStorage("moderation-future-projects", []);
watch(futureProjectIds, (newValue) => {
console.log("Future project IDs updated:", newValue);
});
watch(
showModerationChecklist,
(newValue) => {
setVisible(newValue);
},
{ immediate: true },
);
if (import.meta.client && history && history.state && history.state.showChecklist) { if (import.meta.client && history && history.state && history.state.showChecklist) {
showModerationChecklist.value = true; showModerationChecklist.value = true;
futureProjects.value = history.state.projects;
} }
function closeDownloadModal(event) { function closeDownloadModal(event) {
@@ -1626,6 +1653,7 @@ const navLinks = computed(() => {
]; ];
}); });
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.settings-header { .settings-header {
display: flex; display: flex;
@@ -1781,4 +1809,16 @@ const navLinks = computed(() => {
left: 18px; left: 18px;
} }
} }
.moderation-checklist {
position: fixed;
bottom: 1rem;
right: 1rem;
overflow-y: auto;
z-index: 50;
> div {
box-shadow: 0 0 15px rgba(0, 0, 0, 0.3);
}
}
</style> </style>

View File

@@ -705,9 +705,9 @@ export default defineNuxtComponent({
} }
.gallery-body { .gallery-body {
flex-grow: 1;
width: calc(100% - 2 * var(--spacing-card-md)); width: calc(100% - 2 * var(--spacing-card-md));
padding: var(--spacing-card-sm) var(--spacing-card-md); padding: var(--spacing-card-sm) var(--spacing-card-md);
overflow-wrap: anywhere;
.gallery-info { .gallery-info {
h2 { h2 {

View File

@@ -150,9 +150,26 @@
</template> </template>
</span> </span>
<span class="text-sm text-secondary"> <span class="text-sm text-secondary">
<span
v-if="charge.status === 'cancelled' && $dayjs(charge.due).isBefore($dayjs())"
class="font-bold"
>
Ended:
</span>
<span v-else-if="charge.status === 'cancelled'" class="font-bold">Ends:</span>
<span v-else-if="charge.type === 'refund'" class="font-bold">Issued:</span>
<span v-else class="font-bold">Due:</span>
{{ dayjs(charge.due).format("MMMM D, YYYY [at] h:mma") }} {{ dayjs(charge.due).format("MMMM D, YYYY [at] h:mma") }}
<span class="text-secondary">({{ formatRelativeTime(charge.due) }}) </span> <span class="text-secondary">({{ formatRelativeTime(charge.due) }}) </span>
</span> </span>
<span v-if="charge.last_attempt != null" class="text-sm text-secondary">
<span v-if="charge.status === 'failed'" class="font-bold">Last attempt:</span>
<span v-else class="font-bold">Charged:</span>
{{ dayjs(charge.last_attempt).format("MMMM D, YYYY [at] h:mma") }}
<span class="text-secondary"
>({{ formatRelativeTime(charge.last_attempt) }})
</span>
</span>
<div class="flex w-full items-center gap-1 text-xs text-secondary"> <div class="flex w-full items-center gap-1 text-xs text-secondary">
{{ charge.status }} {{ charge.status }}

View File

@@ -1,4 +1,4 @@
<script setup> <script setup lang="ts">
import { import {
TrashIcon, TrashIcon,
SearchIcon, SearchIcon,
@@ -17,76 +17,134 @@ import LatestNewsRow from "~/components/ui/news/LatestNewsRow.vue";
import { homePageProjects } from "~/generated/state.json"; import { homePageProjects } from "~/generated/state.json";
const os = ref(null); interface LauncherPlatform {
const downloadWindows = ref(null); install_urls: string[];
const downloadLinux = ref(null); }
const downloadSection = ref(null);
const windowsLink = ref(null);
const linuxLinks = {
appImage: null,
deb: null,
rpm: null,
thirdParty: "https://support.modrinth.com/en/articles/9298760",
};
const macLinks = {
universal: null,
};
let downloadLauncher; interface LauncherUpdates {
platforms: {
"darwin-aarch64": LauncherPlatform;
"windows-x86_64": LauncherPlatform;
"linux-x86_64": LauncherPlatform;
};
}
type OSType = "Mac" | "Windows" | "Linux" | null;
const downloadWindows = ref<HTMLAnchorElement | null>(null);
const downloadLinux = ref<HTMLAnchorElement | null>(null);
const downloadSection = ref<HTMLElement | null>(null);
const windowsLink = ref<string | null>(null);
const linuxLinks = reactive({
appImage: null as string | null,
deb: null as string | null,
rpm: null as string | null,
thirdParty: "https://support.modrinth.com/en/articles/9298760",
});
const macLinks = reactive({
universal: null as string | null,
});
const newProjects = homePageProjects.slice(0, 40); const newProjects = homePageProjects.slice(0, 40);
const val = Math.ceil(newProjects.length / 6); const val = Math.ceil(newProjects.length / 6);
const rows = ref([ const rows = [
newProjects.slice(0, val), newProjects.slice(0, val),
newProjects.slice(val, val * 2), newProjects.slice(val, val * 2),
newProjects.slice(val * 2, val * 3), newProjects.slice(val * 2, val * 3),
newProjects.slice(val * 3, val * 4), newProjects.slice(val * 3, val * 4),
newProjects.slice(val * 4, val * 5), newProjects.slice(val * 4, val * 5),
]); ];
const [{ data: launcherUpdates }] = await Promise.all([ const { data: launcherUpdates } = await useFetch<LauncherUpdates>(
await useAsyncData("launcherUpdates", () => "https://launcher-files.modrinth.com/updates.json?new",
$fetch("https://launcher-files.modrinth.com/updates.json"), {
), server: false,
]); getCachedData(key, nuxtApp) {
const cached = (nuxtApp.ssrContext?.cache as any)?.[key] || nuxtApp.payload.data[key];
if (!cached) return;
macLinks.universal = launcherUpdates.value.platforms["darwin-aarch64"].install_urls[0]; const now = Date.now();
windowsLink.value = launcherUpdates.value.platforms["windows-x86_64"].install_urls[0]; const cacheTime = cached._cacheTime || 0;
linuxLinks.appImage = launcherUpdates.value.platforms["linux-x86_64"].install_urls[1]; const maxAge = 5 * 60 * 1000;
linuxLinks.deb = launcherUpdates.value.platforms["linux-x86_64"].install_urls[0];
linuxLinks.rpm = launcherUpdates.value.platforms["linux-x86_64"].install_urls[2];
onMounted(() => { if (now - cacheTime > maxAge) {
os.value = navigator?.platform.toString(); return null;
os.value = os.value?.includes("Mac") }
? "Mac"
: os.value?.includes("Win")
? "Windows"
: os.value?.includes("Linux")
? "Linux"
: null;
return cached;
},
transform(data) {
return {
...data,
_cacheTime: Date.now(),
};
},
},
);
const platform = computed<string>(() => {
if (import.meta.server) {
const headers = useRequestHeaders();
return headers["user-agent"] || "";
} else {
return navigator.userAgent || "";
}
});
const os = computed<OSType>(() => {
if (platform.value.includes("Mac")) {
return "Mac";
} else if (platform.value.includes("Win")) {
return "Windows";
} else if (platform.value.includes("Linux")) {
return "Linux";
} else {
return null;
}
});
const downloadLauncher = computed(() => {
if (os.value === "Windows") { if (os.value === "Windows") {
downloadLauncher = () => { return () => {
downloadWindows.value.click(); downloadWindows.value?.click();
}; };
} else if (os.value === "Linux") { } else if (os.value === "Linux") {
downloadLauncher = () => { return () => {
downloadLinux.value.click(); downloadLinux.value?.click();
}; };
} else { } else {
downloadLauncher = () => { return () => {
scrollToSection(); scrollToSection();
}; };
} }
}); });
const handleDownload = () => {
downloadLauncher.value();
};
watch(
launcherUpdates,
(newData) => {
if (newData?.platforms) {
macLinks.universal = newData.platforms["darwin-aarch64"]?.install_urls[0] || null;
windowsLink.value = newData.platforms["windows-x86_64"]?.install_urls[0] || null;
linuxLinks.appImage = newData.platforms["linux-x86_64"]?.install_urls[1] || null;
linuxLinks.deb = newData.platforms["linux-x86_64"]?.install_urls[0] || null;
linuxLinks.rpm = newData.platforms["linux-x86_64"]?.install_urls[2] || null;
}
},
{ immediate: true },
);
const scrollToSection = () => { const scrollToSection = () => {
nextTick(() => { nextTick(() => {
window.scrollTo({ if (downloadSection.value) {
top: downloadSection.value.offsetTop, window.scrollTo({
behavior: "smooth", top: downloadSection.value.offsetTop,
}); behavior: "smooth",
});
}
}); });
}; };
@@ -119,7 +177,7 @@ useSeoMeta({
v-if="os" v-if="os"
class="iconified-button brand-button btn btn-large" class="iconified-button brand-button btn btn-large"
rel="noopener nofollow" rel="noopener nofollow"
@click="downloadLauncher" @click="handleDownload"
> >
<svg <svg
v-if="os === 'Linux'" v-if="os === 'Linux'"
@@ -485,7 +543,7 @@ useSeoMeta({
class="project button-animation gradient-border" class="project button-animation gradient-border"
:to="`/${project.project_type}/${project.slug ? project.slug : project.id}`" :to="`/${project.project_type}/${project.slug ? project.slug : project.id}`"
> >
<Avatar :src="project.icon_url" :alt="project.title" size="sm" loading="lazy" /> <Avatar :src="project.icon_url!" :alt="project.title" size="sm" />
<div class="project-info"> <div class="project-info">
<span class="title"> <span class="title">
{{ project.title }} {{ project.title }}
@@ -596,9 +654,7 @@ useSeoMeta({
</div> </div>
<div class="description"> <div class="description">
Modrinths launcher is fully open source. You can view the source code on our Modrinths launcher is fully open source. You can view the source code on our
<a href="https://github.com/modrinth/theseus" rel="noopener" :target="$external()" <a href="https://github.com/modrinth/theseus" rel="noopener" target="_blank">GitHub</a>!
>GitHub</a
>!
</div> </div>
</div> </div>
<div class="point"> <div class="point">
@@ -788,7 +844,7 @@ useSeoMeta({
Windows Windows
</div> </div>
<div class="description"> <div class="description">
<a ref="downloadWindows" :href="windowsLink" download=""> <a ref="downloadWindows" :href="windowsLink || undefined" download="">
<DownloadIcon /> <DownloadIcon />
<span> Download the beta </span> <span> Download the beta </span>
</a> </a>
@@ -812,7 +868,7 @@ useSeoMeta({
Mac Mac
</div> </div>
<div class="description apple"> <div class="description apple">
<a :href="macLinks.universal" download=""> <a :href="macLinks.universal || undefined" download="">
<DownloadIcon /> <DownloadIcon />
<span> Download the beta </span> <span> Download the beta </span>
</a> </a>
@@ -849,19 +905,19 @@ useSeoMeta({
Linux Linux
</div> </div>
<div class="description apple"> <div class="description apple">
<a ref="downloadLinux" :href="linuxLinks.appImage" download=""> <a ref="downloadLinux" :href="linuxLinks.appImage || undefined" download="">
<DownloadIcon /> <DownloadIcon />
<span> Download the AppImage </span> <span> Download the AppImage </span>
</a> </a>
<a :href="linuxLinks.deb" download=""> <a :href="linuxLinks.deb || undefined" download="">
<DownloadIcon /> <DownloadIcon />
<span> Download the DEB </span> <span> Download the DEB </span>
</a> </a>
<a :href="linuxLinks.rpm" download=""> <a :href="linuxLinks.rpm || undefined" download="">
<DownloadIcon /> <DownloadIcon />
<span> Download the RPM </span> <span> Download the RPM </span>
</a> </a>
<a :href="linuxLinks.thirdParty" download=""> <a :href="linuxLinks.thirdParty || undefined" download="">
<LinkIcon /> <LinkIcon />
<span> Third-party packages </span> <span> Third-party packages </span>
</a> </a>
@@ -1365,7 +1421,8 @@ useSeoMeta({
width: 25rem; width: 25rem;
height: 25rem; height: 25rem;
opacity: 0.75; opacity: 0.75;
background: radial-gradient( background:
radial-gradient(
50% 50% at 50% 50%, 50% 50% at 50% 50%,
rgba(5, 206, 69, 0.19) 0%, rgba(5, 206, 69, 0.19) 0%,
rgba(15, 19, 49, 0.25) 100% rgba(15, 19, 49, 0.25) 100%

View File

@@ -1,6 +1,12 @@
<template> <template>
<div> <div v-if="subtleLauncherRedirectUri">
<template v-if="flow"> <iframe
:src="subtleLauncherRedirectUri"
class="fixed left-0 top-0 z-[9999] m-0 h-full w-full border-0 p-0"
></iframe>
</div>
<div v-else>
<template v-if="flow && !subtleLauncherRedirectUri">
<label for="two-factor-code"> <label for="two-factor-code">
<span class="label__title">{{ formatMessage(messages.twoFactorCodeLabel) }}</span> <span class="label__title">{{ formatMessage(messages.twoFactorCodeLabel) }}</span>
<span class="label__description"> <span class="label__description">
@@ -189,6 +195,7 @@ const auth = await useAuth();
const route = useNativeRoute(); const route = useNativeRoute();
const redirectTarget = route.query.redirect || ""; const redirectTarget = route.query.redirect || "";
const subtleLauncherRedirectUri = ref();
if (route.query.code && !route.fullPath.includes("new_account=true")) { if (route.query.code && !route.fullPath.includes("new_account=true")) {
await finishSignIn(); await finishSignIn();
@@ -262,7 +269,32 @@ async function begin2FASignIn() {
async function finishSignIn(token) { async function finishSignIn(token) {
if (route.query.launcher) { if (route.query.launcher) {
await navigateTo(`https://launcher-files.modrinth.com/?code=${token}`, { external: true }); if (!token) {
token = auth.value.token;
}
const usesLocalhostRedirectionScheme =
["4", "6"].includes(route.query.ipver) && Number(route.query.port) < 65536;
const redirectUrl = usesLocalhostRedirectionScheme
? `http://${route.query.ipver === "4" ? "127.0.0.1" : "[::1]"}:${route.query.port}/?code=${token}`
: `https://launcher-files.modrinth.com/?code=${token}`;
if (usesLocalhostRedirectionScheme) {
// When using this redirection scheme, the auth token is very visible in the URL to the user.
// While we could make it harder to find with a POST request, such is security by obscurity:
// the user and other applications would still be able to sniff the token in the request body.
// So, to make the UX a little better by not changing the displayed URL, while keeping the
// token hidden from very casual observation and keeping the protocol as close to OAuth's
// standard flows as possible, let's execute the redirect within an iframe that visually
// covers the entire page.
subtleLauncherRedirectUri.value = redirectUrl;
} else {
await navigateTo(redirectUrl, {
external: true,
});
}
return; return;
} }

View File

@@ -247,16 +247,14 @@ async function createAccount() {
}, },
}); });
if (route.query.launcher) {
await navigateTo(`https://launcher-files.modrinth.com/?code=${res.session}`, {
external: true,
});
return;
}
await useAuth(res.session); await useAuth(res.session);
await useUser(); await useUser();
if (route.query.launcher) {
await navigateTo({ path: "/auth/sign-in", query: route.query });
return;
}
if (route.query.redirect) { if (route.query.redirect) {
await navigateTo(route.query.redirect); await navigateTo(route.query.redirect);
} else { } else {

View File

@@ -266,12 +266,12 @@ const getRangeOfMethod = (method) => {
const maxWithdrawAmount = computed(() => { const maxWithdrawAmount = computed(() => {
const interval = selectedMethod.value.interval; const interval = selectedMethod.value.interval;
return interval?.standard ? interval.standard.max : interval?.fixed?.values.slice(-1)[0] ?? 0; return interval?.standard ? interval.standard.max : (interval?.fixed?.values.slice(-1)[0] ?? 0);
}); });
const minWithdrawAmount = computed(() => { const minWithdrawAmount = computed(() => {
const interval = selectedMethod.value.interval; const interval = selectedMethod.value.interval;
return interval?.standard ? interval.standard.min : interval?.fixed?.values?.[0] ?? fees.value; return interval?.standard ? interval.standard.min : (interval?.fixed?.values?.[0] ?? fees.value);
}); });
const withdrawAccount = computed(() => { const withdrawAccount = computed(() => {

View File

@@ -212,6 +212,10 @@ if (projects.value) {
async function goToProjects() { async function goToProjects() {
const project = projectsFiltered.value[0]; const project = projectsFiltered.value[0];
const remainingProjectIds = projectsFiltered.value.slice(1).map((p) => p.id);
localStorage.setItem("moderation-future-projects", JSON.stringify(remainingProjectIds));
await router.push({ await router.push({
name: "type-id", name: "type-id",
params: { params: {
@@ -220,7 +224,6 @@ async function goToProjects() {
}, },
state: { state: {
showChecklist: true, showChecklist: true,
projects: projectsFiltered.value.slice(1).map((x) => (x.slug ? x.slug : x.id)),
}, },
}); });
} }

View File

@@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ButtonStyled } from "@modrinth/ui"; import { Avatar, ButtonStyled } from "@modrinth/ui";
import { RssIcon, GitGraphIcon } from "@modrinth/assets"; import { RssIcon, GitGraphIcon } from "@modrinth/assets";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { articles as rawArticles } from "@modrinth/blog"; import { articles as rawArticles } from "@modrinth/blog";
import { computed } from "vue"; import { computed } from "vue";
import type { User } from "@modrinth/utils";
import ShareArticleButtons from "~/components/ui/ShareArticleButtons.vue"; import ShareArticleButtons from "~/components/ui/ShareArticleButtons.vue";
import NewsletterButton from "~/components/ui/NewsletterButton.vue"; import NewsletterButton from "~/components/ui/NewsletterButton.vue";
@@ -20,7 +21,21 @@ if (!rawArticle) {
}); });
} }
const html = await rawArticle.html(); const authorsUrl = `users?ids=${JSON.stringify(rawArticle.authors)}`;
const [authors, html] = await Promise.all([
rawArticle.authors
? useAsyncData(authorsUrl, () => useBaseFetch(authorsUrl)).then((data) => {
const users = data.data as Ref<User[]>;
users.value.sort((a, b) => {
return rawArticle.authors.indexOf(a.id) - rawArticle.authors.indexOf(b.id);
});
return users;
})
: Promise.resolve(),
rawArticle.html(),
]);
const article = computed(() => ({ const article = computed(() => ({
...rawArticle, ...rawArticle,
@@ -34,6 +49,8 @@ const article = computed(() => ({
html, html,
})); }));
const authorCount = computed(() => authors?.value?.length ?? 0);
const articleTitle = computed(() => article.value.title); const articleTitle = computed(() => article.value.title);
const articleUrl = computed(() => `https://modrinth.com/news/article/${route.params.slug}`); const articleUrl = computed(() => `https://modrinth.com/news/article/${route.params.slug}`);
@@ -83,9 +100,35 @@ useSeoMeta({
<article class="mt-6 flex flex-col gap-4 px-6"> <article class="mt-6 flex flex-col gap-4 px-6">
<h2 class="m-0 text-2xl font-extrabold leading-tight sm:text-4xl">{{ article.title }}</h2> <h2 class="m-0 text-2xl font-extrabold leading-tight sm:text-4xl">{{ article.title }}</h2>
<p class="m-0 text-base leading-tight sm:text-lg">{{ article.summary }}</p> <p class="m-0 text-base leading-tight sm:text-lg">{{ article.summary }}</p>
<div class="mt-auto text-sm text-secondary sm:text-base"> <div class="mt-auto flex flex-wrap items-center gap-1 text-sm text-secondary sm:text-base">
Posted on {{ dayjsDate.format("MMMM D, YYYY") }} <template v-for="(author, index) in authors" :key="`author-${author.id}`">
<span v-if="authorCount - 1 === index && authorCount > 1">and</span>
<span class="flex items-center">
<nuxt-link
:to="`/user/${author.id}`"
class="inline-flex items-center gap-1 font-semibold hover:underline hover:brightness-[--hover-brightness]"
>
<Avatar :src="author.avatar_url" circle size="24px" />
{{ author.username }}
</nuxt-link>
<span v-if="(authors?.length ?? 0) > 2 && index !== authorCount - 1">,</span>
</span>
</template>
<template v-if="!authors || authorCount === 0">
<nuxt-link
to="/organization/modrinth"
class="inline-flex items-center gap-1 font-semibold hover:underline hover:brightness-[--hover-brightness]"
>
<Avatar src="https://cdn-raw.modrinth.com/modrinth-icon-96.webp" size="24px" />
Modrinth Team
</nuxt-link>
</template>
<span class="hidden md:block"></span>
<span class="hidden md:block"> {{ dayjsDate.format("MMMM D, YYYY") }}</span>
</div> </div>
<span class="text-sm text-secondary sm:text-base md:hidden">
Posted on {{ dayjsDate.format("MMMM D, YYYY") }}</span
>
<ShareArticleButtons :title="article.title" :url="articleUrl" /> <ShareArticleButtons :title="article.title" :url="articleUrl" />
<img <img
:src="article.thumbnail" :src="article.thumbnail"

View File

@@ -149,7 +149,8 @@ onMounted(() => {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.main-hero { .main-hero {
background: linear-gradient(360deg, rgba(199, 138, 255, 0.2) 10.92%, var(--color-bg) 100%), background:
linear-gradient(360deg, rgba(199, 138, 255, 0.2) 10.92%, var(--color-bg) 100%),
var(--color-accent-contrast); var(--color-accent-contrast);
margin-top: -5rem; margin-top: -5rem;
padding: 11.25rem 1rem 8rem; padding: 11.25rem 1rem 8rem;

View File

@@ -45,8 +45,9 @@
<h2 <h2
class="relative m-0 max-w-2xl text-base font-normal leading-[155%] text-secondary md:text-[1.2rem]" class="relative m-0 max-w-2xl text-base font-normal leading-[155%] text-secondary md:text-[1.2rem]"
> >
Modrinth Servers is the easiest way to host your own Minecraft server. Seamlessly install Modrinth Servers is the easiest way to host your own Minecraft: Java Edition server.
and play your favorite mods and modpacks, all within the Modrinth platform. Seamlessly install and play your favorite mods and modpacks, all within the Modrinth
platform.
</h2> </h2>
<div class="relative flex w-full flex-wrap items-center gap-8 align-middle sm:w-fit"> <div class="relative flex w-full flex-wrap items-center gap-8 align-middle sm:w-fit">
<div <div
@@ -427,11 +428,8 @@
Do Modrinth Servers have DDoS protection? Do Modrinth Servers have DDoS protection?
</summary> </summary>
<p class="m-0 ml-6 leading-[160%]"> <p class="m-0 ml-6 leading-[160%]">
Yes. All Modrinth Servers come with DDoS protection powered by Yes. All Modrinth Servers come with DDoS protection, with up to 17Tbps capacity in
<a href="https://us.ovhcloud.com/security/anti-ddos/" target="_blank" some locations.
>OVHcloud® Anti-DDoS infrastructure</a
>
which has over 17Tbps capacity. Your server is safe on Modrinth.
</p> </p>
</details> </details>
@@ -443,8 +441,9 @@
Where are Modrinth Servers located? Can I choose a region? Where are Modrinth Servers located? Can I choose a region?
</summary> </summary>
<p class="m-0 ml-6 leading-[160%]"> <p class="m-0 ml-6 leading-[160%]">
We have servers in both North America in Vint Hill, Virginia, and Europe in Limburg, We have servers available in North America and Europe at the moment that you can
Germany. More regions to come in the future! choose upon purchase. More regions to come in the future! If you'd like to switch
your region, please contact support.
</p> </p>
</details> </details>
@@ -461,7 +460,7 @@
</p> </p>
</details> </details>
<details pyro-hash="players" class="group" :open="$route.hash === '#players'"> <details pyro-hash="performance" class="group" :open="$route.hash === '#performance'">
<summary class="flex cursor-pointer items-center py-3 font-medium text-contrast"> <summary class="flex cursor-pointer items-center py-3 font-medium text-contrast">
<span class="mr-2 transition-transform duration-200 group-open:rotate-90"> <span class="mr-2 transition-transform duration-200 group-open:rotate-90">
<RightArrowIcon /> <RightArrowIcon />
@@ -482,7 +481,7 @@
</p> </p>
</details> </details>
<details pyro-hash="players" class="group" :open="$route.hash === '#prices'"> <details pyro-hash="prices" class="group" :open="$route.hash === '#prices'">
<summary class="flex cursor-pointer items-center py-3 font-medium text-contrast"> <summary class="flex cursor-pointer items-center py-3 font-medium text-contrast">
<span class="mr-2 transition-transform duration-200 group-open:rotate-90"> <span class="mr-2 transition-transform duration-200 group-open:rotate-90">
<RightArrowIcon /> <RightArrowIcon />
@@ -493,6 +492,24 @@
All prices are listed in United States Dollars (USD). All prices are listed in United States Dollars (USD).
</p> </p>
</details> </details>
<details pyro-hash="versions" class="group" :open="$route.hash === '#versions'">
<summary class="flex cursor-pointer items-center py-3 font-medium text-contrast">
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
<RightArrowIcon />
</span>
What Minecraft versions and loaders can be used?
</summary>
<p class="m-0 ml-6 leading-[160%]">
Modrinth Servers can run any version of Minecraft: Java Edition going all the way
back to version 1.2.5, including snapshot versions.
</p>
<p class="m-0 ml-6 mt-3 leading-[160%]">
We also support a wide range of mod and plugin loaders, including Fabric, Quilt,
Forge, and NeoForge for mods, as well as Paper and Purpur for plugins. Availability
depends on whether the mod or plugin loader supports the selected Minecraft version.
</p>
</details>
</div> </div>
</div> </div>
</div> </div>
@@ -719,31 +736,32 @@ async function fetchCapacityStatuses(customProduct = null) {
product.metadata.ram < min.metadata.ram ? product : min, product.metadata.ram < min.metadata.ram ? product : min,
), ),
]; ];
const capacityChecks = productsToCheck.map((product) => const capacityChecks = [];
useServersFetch("stock", { for (const product of productsToCheck) {
method: "POST", capacityChecks.push(
body: { useServersFetch("stock", {
cpu: product.metadata.cpu, method: "POST",
memory_mb: product.metadata.ram, body: {
swap_mb: product.metadata.swap, cpu: product.metadata.cpu,
storage_mb: product.metadata.storage, memory_mb: product.metadata.ram,
}, swap_mb: product.metadata.swap,
bypassAuth: true, storage_mb: product.metadata.storage,
}), },
); bypassAuth: true,
}),
const results = await Promise.all(capacityChecks); );
}
if (customProduct?.metadata) { if (customProduct?.metadata) {
return { return {
custom: results[0], custom: await capacityChecks[0],
}; };
} else { } else {
return { return {
small: results[0], small: await capacityChecks[0],
medium: results[1], medium: await capacityChecks[1],
large: results[2], large: await capacityChecks[2],
custom: results[3], custom: await capacityChecks[3],
}; };
} }
} catch (error) { } catch (error) {
@@ -760,6 +778,11 @@ async function fetchCapacityStatuses(customProduct = null) {
const { data: capacityStatuses, refresh: refreshCapacity } = await useAsyncData( const { data: capacityStatuses, refresh: refreshCapacity } = await useAsyncData(
"ServerCapacityAll", "ServerCapacityAll",
fetchCapacityStatuses, fetchCapacityStatuses,
{
getCachedData() {
return null; // Dont cache stock data.
},
},
); );
const isSmallAtCapacity = computed(() => capacityStatuses.value?.small?.available === 0); const isSmallAtCapacity = computed(() => capacityStatuses.value?.small?.available === 0);

View File

@@ -55,7 +55,7 @@
/> />
</div> </div>
<div <div
v-else-if="server.moduleErrors?.general?.error.statusCode === 503" v-else-if="server.moduleErrors?.general?.error || !nodeAccessible"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast" class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
> >
<ErrorInformationCard <ErrorInformationCard
@@ -68,22 +68,22 @@
<template #description> <template #description>
<div class="text-md space-y-4"> <div class="text-md space-y-4">
<p class="leading-[170%] text-secondary"> <p class="leading-[170%] text-secondary">
Your server's node, where your Modrinth Server is physically hosted, is experiencing Your server's node, where your Modrinth Server is physically hosted, is not accessible
issues. We are working with our datacenter to resolve the issue as quickly as possible. at the moment. We are working to resolve the issue as quickly as possible.
</p> </p>
<p class="leading-[170%] text-secondary"> <p class="leading-[170%] text-secondary">
Your data is safe and will not be lost, and your server will be back online as soon as Your data is safe and will not be lost, and your server will be back online as soon as
the issue is resolved. the issue is resolved.
</p> </p>
<p class="leading-[170%] text-secondary"> <p class="leading-[170%] text-secondary">
For updates, please join the Modrinth Discord or contact Modrinth Support via the chat If reloading does not work initially, please contact Modrinth Support via the chat
bubble in the bottom right corner and we'll be happy to help. bubble in the bottom right corner and we'll be happy to help.
</p> </p>
</div> </div>
</template> </template>
</ErrorInformationCard> </ErrorInformationCard>
</div> </div>
<div <!-- <div
v-else-if="server.moduleErrors?.general?.error" v-else-if="server.moduleErrors?.general?.error"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast" class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
> >
@@ -96,19 +96,14 @@
> >
<template #description> <template #description>
<div class="space-y-4"> <div class="space-y-4">
<div class="text-center text-secondary">
{{
formattedTime == "00" ? "Reconnecting..." : `Retrying in ${formattedTime} seconds...`
}}
</div>
<p class="text-lg text-secondary"> <p class="text-lg text-secondary">
Something went wrong, and we couldn't connect to your server. This is likely due to a Something went wrong, and we couldn't connect to your server. This is likely due to a
temporary network issue. You'll be reconnected automatically. temporary network issue.
</p> </p>
</div> </div>
</template> </template>
</ErrorInformationCard> </ErrorInformationCard>
</div> </div> -->
<!-- SERVER START --> <!-- SERVER START -->
<div <div
v-else-if="serverData" v-else-if="serverData"
@@ -355,7 +350,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, type Reactive } from "vue"; import { ref, computed, onMounted, onUnmounted, type Reactive } from "vue";
import { import {
SettingsIcon, SettingsIcon,
CopyIcon, CopyIcon,
@@ -371,15 +366,15 @@ import DOMPurify from "dompurify";
import { ButtonStyled, ErrorInformationCard, ServerNotice } from "@modrinth/ui"; import { ButtonStyled, ErrorInformationCard, ServerNotice } from "@modrinth/ui";
import { Intercom, shutdown } from "@intercom/messenger-js-sdk"; import { Intercom, shutdown } from "@intercom/messenger-js-sdk";
import type { MessageDescriptor } from "@vintl/vintl"; import type { MessageDescriptor } from "@vintl/vintl";
import type { import {
ServerState, type ServerState,
Stats, type Stats,
WSEvent, type WSEvent,
WSInstallationResultEvent, type WSInstallationResultEvent,
Backup, type Backup,
PowerAction, type PowerAction,
} from "@modrinth/utils"; } from "@modrinth/utils";
import { reloadNuxtApp, navigateTo } from "#app"; import { reloadNuxtApp } from "#app";
import { useModrinthServersConsole } from "~/store/console.ts"; import { useModrinthServersConsole } from "~/store/console.ts";
import { useServersFetch } from "~/composables/servers/servers-fetch.ts"; import { useServersFetch } from "~/composables/servers/servers-fetch.ts";
import { ModrinthServer, useModrinthServers } from "~/composables/servers/modrinth-servers.ts"; import { ModrinthServer, useModrinthServers } from "~/composables/servers/modrinth-servers.ts";
@@ -392,7 +387,6 @@ const socket = ref<WebSocket | null>(null);
const isReconnecting = ref(false); const isReconnecting = ref(false);
const isLoading = ref(true); const isLoading = ref(true);
const reconnectInterval = ref<ReturnType<typeof setInterval> | null>(null); const reconnectInterval = ref<ReturnType<typeof setInterval> | null>(null);
const isFirstMount = ref(true);
const isMounted = ref(true); const isMounted = ref(true);
const flags = useFeatureFlags(); const flags = useFeatureFlags();
@@ -422,18 +416,6 @@ const loadModulesPromise = Promise.resolve().then(() => {
provide("modulesLoaded", loadModulesPromise); provide("modulesLoaded", loadModulesPromise);
watch(
() => [server.moduleErrors?.general, server.moduleErrors?.ws],
([generalError, wsError]) => {
if (server.general?.status === "suspended") return;
const error = generalError?.error || wsError?.error;
if (error && error.statusCode !== 403) {
startPolling();
}
},
);
const errorTitle = ref("Error"); const errorTitle = ref("Error");
const errorMessage = ref("An unexpected error occurred."); const errorMessage = ref("An unexpected error occurred.");
const errorLog = ref(""); const errorLog = ref("");
@@ -697,7 +679,6 @@ const startUptimeUpdates = () => {
const stopUptimeUpdates = () => { const stopUptimeUpdates = () => {
if (uptimeIntervalId) { if (uptimeIntervalId) {
clearInterval(uptimeIntervalId); clearInterval(uptimeIntervalId);
pollingIntervalId = null;
} }
}; };
@@ -836,8 +817,6 @@ const handleInstallationResult = async (data: WSInstallationResultEvent) => {
case "ok": { case "ok": {
if (!serverData.value) break; if (!serverData.value) break;
stopPolling();
try { try {
await new Promise((resolve) => setTimeout(resolve, 2000)); await new Promise((resolve) => setTimeout(resolve, 2000));
@@ -992,14 +971,6 @@ const notifyError = (title: string, text: string) => {
}); });
}; };
let pollingIntervalId: ReturnType<typeof setInterval> | null = null;
const countdown = ref(15);
const formattedTime = computed(() => {
const seconds = countdown.value % 60;
return `${seconds.toString().padStart(2, "0")}`;
});
export type BackupInProgressReason = { export type BackupInProgressReason = {
type: string; type: string;
tooltip: MessageDescriptor; tooltip: MessageDescriptor;
@@ -1035,54 +1006,6 @@ const backupInProgress = computed(() => {
return undefined; return undefined;
}); });
const stopPolling = () => {
if (pollingIntervalId) {
clearTimeout(pollingIntervalId);
pollingIntervalId = null;
}
};
const startPolling = () => {
stopPolling();
let retryCount = 0;
const maxRetries = 10;
const poll = async () => {
try {
await server.refresh(["general", "ws"]);
if (!server.moduleErrors?.general?.error) {
stopPolling();
connectWebSocket();
return;
}
retryCount++;
if (retryCount >= maxRetries) {
console.error("Max retries reached, stopping polling");
stopPolling();
return;
}
// Exponential backoff: 3s, 6s, 12s, 24s, etc.
const delay = Math.min(3000 * Math.pow(2, retryCount - 1), 60000);
pollingIntervalId = setTimeout(poll, delay);
} catch (error) {
console.error("Polling failed:", error);
retryCount++;
if (retryCount < maxRetries) {
const delay = Math.min(3000 * Math.pow(2, retryCount - 1), 60000);
pollingIntervalId = setTimeout(poll, delay);
}
}
};
poll();
};
const nodeUnavailableDetails = computed(() => [ const nodeUnavailableDetails = computed(() => [
{ {
label: "Server ID", label: "Server ID",
@@ -1091,9 +1014,16 @@ const nodeUnavailableDetails = computed(() => [
}, },
{ {
label: "Node", label: "Node",
value: server.general?.datacenter ?? "Unknown! Please contact support!", value: server.general?.datacenter ?? "Unknown",
type: "inline" as const, type: "inline" as const,
}, },
{
label: "Error message",
value: nodeAccessible.value
? (server.moduleErrors?.general?.error.message ?? "Unknown")
: "Unable to reach node. Ping test failed.",
type: "block" as const,
},
]); ]);
const suspendedDescription = computed(() => { const suspendedDescription = computed(() => {
@@ -1160,16 +1090,10 @@ const generalErrorAction = computed(() => ({
})); }));
const nodeUnavailableAction = computed(() => ({ const nodeUnavailableAction = computed(() => ({
label: "Join Modrinth Discord",
onClick: () => navigateTo("https://discord.modrinth.com", { external: true }),
color: "standard" as const,
}));
const connectionLostAction = computed(() => ({
label: "Reload", label: "Reload",
onClick: () => reloadNuxtApp(), onClick: () => reloadNuxtApp(),
color: "brand" as const, color: "brand" as const,
disabled: formattedTime.value !== "00", disabled: false,
})); }));
const copyServerDebugInfo = () => { const copyServerDebugInfo = () => {
@@ -1193,7 +1117,6 @@ const cleanup = () => {
shutdown(); shutdown();
stopPolling();
stopUptimeUpdates(); stopUptimeUpdates();
if (reconnectInterval.value) { if (reconnectInterval.value) {
clearInterval(reconnectInterval.value); clearInterval(reconnectInterval.value);
@@ -1236,16 +1159,31 @@ async function dismissNotice(noticeId: number) {
await server.refresh(["general"]); await server.refresh(["general"]);
} }
const nodeAccessible = ref(true);
onMounted(() => { onMounted(() => {
isMounted.value = true; isMounted.value = true;
if (server.general?.status === "suspended") { if (server.general?.status === "suspended") {
isLoading.value = false; isLoading.value = false;
return; return;
} }
server
.testNodeReachability()
.then((result) => {
nodeAccessible.value = result;
if (!nodeAccessible.value) {
isLoading.value = false;
}
})
.catch((err) => {
console.error("Error testing node reachability:", err);
nodeAccessible.value = false;
isLoading.value = false;
});
if (server.moduleErrors.general?.error) { if (server.moduleErrors.general?.error) {
if (!server.moduleErrors.general?.error?.message?.includes("Forbidden")) { isLoading.value = false;
startPolling();
}
} else { } else {
connectWebSocket(); connectWebSocket();
} }
@@ -1297,21 +1235,6 @@ onUnmounted(() => {
cleanup(); cleanup();
}); });
watch(
() => serverData.value?.status,
(newStatus, oldStatus) => {
if (isFirstMount.value) {
isFirstMount.value = false;
return;
}
if (newStatus === "installing" && oldStatus !== "installing") {
countdown.value = 15;
startPolling();
}
},
);
definePageMeta({ definePageMeta({
middleware: "auth", middleware: "auth",
}); });
@@ -1354,7 +1277,8 @@ useHead({
background-repeat: no-repeat; background-repeat: no-repeat;
filter: blur(1rem); filter: blur(1rem);
content: ""; content: "";
background-image: linear-gradient( background-image:
linear-gradient(
to bottom, to bottom,
rgba(from var(--color-raised-bg) r g b / 0.2), rgba(from var(--color-raised-bg) r g b / 0.2),
rgb(from var(--color-raised-bg) r g b / 0.8) rgb(from var(--color-raised-bg) r g b / 0.8)

View File

@@ -101,7 +101,7 @@
<span :class="{ invisible: 'current_file' in op && !op.current_file }"> <span :class="{ invisible: 'current_file' in op && !op.current_file }">
{{ {{
"current_file" in op "current_file" in op
? op.current_file?.split("/")?.pop() ?? "unknown" ? (op.current_file?.split("/")?.pop() ?? "unknown")
: "unknown" : "unknown"
}} }}
</span> </span>

View File

@@ -0,0 +1,6 @@
Contact: mailto:jai@modrinth.com
Expires: 2025-12-31T00:00:00.000Z
Preferred-Languages: en
Canonical: https://modrinth.com/.well-known/security.txt
Policy: https://modrinth.com/legal/security
Hiring: https://careers.modrinth.com/

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -1,5 +1,12 @@
{ {
"articles": [ "articles": [
{
"title": "Skins — Now in Modrinth App!",
"summary": "Customize your look, save your favorite skins, and swap them out in a flash, all within Modrinth App.",
"thumbnail": "https://modrinth.com/news/article/skins-now-in-modrinth-app/thumbnail.webp",
"date": "2025-07-06T23:45:00.000Z",
"link": "https://modrinth.com/news/article/skins-now-in-modrinth-app"
},
{ {
"title": "Creator Updates, July 2025", "title": "Creator Updates, July 2025",
"summary": "Addressing recent growth and growing pains that have been affecting creators.", "summary": "Addressing recent growth and growing pains that have been affecting creators.",
@@ -9,7 +16,7 @@
}, },
{ {
"title": "A Pride Month Success: Over $8,400 Raised for The Trevor Project!", "title": "A Pride Month Success: Over $8,400 Raised for The Trevor Project!",
"summary": "A reflection on our Pride Month fundraiser campaign, which raised thousands for LGBTQ+ youth.", "summary": "Reflecting on our Pride Month fundraiser campaign for LGBTQ+ youth.",
"thumbnail": "https://modrinth.com/news/article/pride-campaign-2025/thumbnail.webp", "thumbnail": "https://modrinth.com/news/article/pride-campaign-2025/thumbnail.webp",
"date": "2025-07-01T18:00:00.000Z", "date": "2025-07-01T18:00:00.000Z",
"link": "https://modrinth.com/news/article/pride-campaign-2025" "link": "https://modrinth.com/news/article/pride-campaign-2025"
@@ -91,13 +98,6 @@
"date": "2023-02-01T20:00:00.000Z", "date": "2023-02-01T20:00:00.000Z",
"link": "https://modrinth.com/news/article/accelerating-development" "link": "https://modrinth.com/news/article/accelerating-development"
}, },
{
"title": "Two years of Modrinth: a retrospective",
"summary": "The history of Modrinth as we know it from December 2020 to December 2022.",
"thumbnail": "https://modrinth.com/news/default.webp",
"date": "2023-01-07T00:00:00.000Z",
"link": "https://modrinth.com/news/article/two-years-of-modrinth-history"
},
{ {
"title": "Modrinth's Anniversary Update", "title": "Modrinth's Anniversary Update",
"summary": "Marking two years of Modrinth and discussing our New Year's Resolutions for 2023.", "summary": "Marking two years of Modrinth and discussing our New Year's Resolutions for 2023.",
@@ -105,16 +105,23 @@
"date": "2023-01-07T00:00:00.000Z", "date": "2023-01-07T00:00:00.000Z",
"link": "https://modrinth.com/news/article/two-years-of-modrinth" "link": "https://modrinth.com/news/article/two-years-of-modrinth"
}, },
{
"title": "Two years of Modrinth: a retrospective",
"summary": "The history of Modrinth as we know it from December 2020 to December 2022.",
"thumbnail": "https://modrinth.com/news/default.webp",
"date": "2023-01-07T00:00:00.000Z",
"link": "https://modrinth.com/news/article/two-years-of-modrinth-history"
},
{ {
"title": "Creators can now make money on Modrinth!", "title": "Creators can now make money on Modrinth!",
"summary": "Yes, you read the title correctly: Modrinth's creator monetization program, also known as payouts, is now in an open beta phase. Read on for more information!", "summary": "Introducing the Creator Monetization Program allowing creators to earn revenue from their projects.",
"thumbnail": "https://modrinth.com/news/article/creator-monetization/thumbnail.webp", "thumbnail": "https://modrinth.com/news/article/creator-monetization/thumbnail.webp",
"date": "2022-11-12T00:00:00.000Z", "date": "2022-11-12T00:00:00.000Z",
"link": "https://modrinth.com/news/article/creator-monetization" "link": "https://modrinth.com/news/article/creator-monetization"
}, },
{ {
"title": "Modrinth's Carbon Ads experiment", "title": "Modrinth's Carbon Ads experiment",
"summary": "As a step towards implementing author payouts, we're experimenting with a couple different ad providers to see which one works the best for us.", "summary": "Experimenting with a different ad providers to find one which one works for us.",
"thumbnail": "https://modrinth.com/news/article/carbon-ads/thumbnail.webp", "thumbnail": "https://modrinth.com/news/article/carbon-ads/thumbnail.webp",
"date": "2022-09-08T00:00:00.000Z", "date": "2022-09-08T00:00:00.000Z",
"link": "https://modrinth.com/news/article/carbon-ads" "link": "https://modrinth.com/news/article/carbon-ads"
@@ -142,14 +149,14 @@
}, },
{ {
"title": "This week in Modrinth development: Filters and Fixes", "title": "This week in Modrinth development: Filters and Fixes",
"summary": "After a great first week since Modrinth launched out of beta, we have continued to improve the user interface based on feedback.", "summary": "Continuing to improve the user interface after a great first week since Modrinth launched out of beta.",
"thumbnail": "https://modrinth.com/news/article/knossos-v2.1.0/thumbnail.webp", "thumbnail": "https://modrinth.com/news/article/knossos-v2.1.0/thumbnail.webp",
"date": "2022-03-09T00:00:00.000Z", "date": "2022-03-09T00:00:00.000Z",
"link": "https://modrinth.com/news/article/knossos-v2.1.0" "link": "https://modrinth.com/news/article/knossos-v2.1.0"
}, },
{ {
"title": "Now showing on Modrinth: A new look!", "title": "Now showing on Modrinth: A new look!",
"summary": "After months of relatively quiet development, Modrinth has released many new features and improvements, including a redesign. Read on to learn more!", "summary": "Releasing many new features and improvements, including a redesign!",
"thumbnail": "https://modrinth.com/news/article/redesign/thumbnail.webp", "thumbnail": "https://modrinth.com/news/article/redesign/thumbnail.webp",
"date": "2022-02-27T00:00:00.000Z", "date": "2022-02-27T00:00:00.000Z",
"link": "https://modrinth.com/news/article/redesign" "link": "https://modrinth.com/news/article/redesign"

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "db_name": "PostgreSQL",
"query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n WHERE subscription_id = $1 AND (status = 'open' OR status = 'cancelled')", "query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n WHERE subscription_id = $1 AND (status = 'open' OR status = 'cancelled' OR status = 'failed')",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -102,5 +102,5 @@
true true
] ]
}, },
"hash": "99cca53fd3f35325e2da3b671532bf98b8c7ad8e7cb9158e4eb9c5bac66d20b2" "hash": "cf020daa52a1316e5f60d197196b880b72c0b2a576e470d9fd7182558103d055"
} }

View File

@@ -36,7 +36,7 @@ paste.workspace = true
meilisearch-sdk = { workspace = true, features = ["reqwest"] } meilisearch-sdk = { workspace = true, features = ["reqwest"] }
rust-s3.workspace = true rust-s3.workspace = true
reqwest = { workspace = true, features = ["http2", "rustls-tls-webpki-roots", "json", "multipart"] } reqwest = { workspace = true, features = ["http2", "rustls-tls-webpki-roots", "json", "multipart"] }
hyper-tls.workspace = true hyper-rustls.workspace = true
hyper-util.workspace = true hyper-util.workspace = true
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }

View File

@@ -1,11 +1,21 @@
# 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/labrinth WORKDIR /usr/src/labrinth
COPY . . COPY . .
COPY apps/labrinth/.sqlx/ .sqlx/ RUN --mount=type=cache,target=/usr/src/labrinth/target \
RUN cargo build --release --package labrinth --mount=type=cache,target=/usr/local/cargo/git/db \
--mount=type=cache,target=/usr/local/cargo/registry \
SQLX_OFFLINE=true cargo build --release --package labrinth
FROM build AS artifacts
RUN --mount=type=cache,target=/usr/src/labrinth/target \
mkdir /labrinth \
&& cp /usr/src/labrinth/target/release/labrinth /labrinth/labrinth \
&& cp -r /usr/src/labrinth/apps/labrinth/migrations /labrinth \
&& cp -r /usr/src/labrinth/apps/labrinth/assets /labrinth
FROM debian:bookworm-slim FROM debian:bookworm-slim
@@ -14,16 +24,11 @@ LABEL org.opencontainers.image.description="Modrinth API"
LABEL org.opencontainers.image.licenses=AGPL-3.0 LABEL org.opencontainers.image.licenses=AGPL-3.0
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates openssl dumb-init \ && apt-get install -y --no-install-recommends ca-certificates dumb-init curl \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN update-ca-certificates COPY --from=artifacts /labrinth /labrinth
COPY --from=build /usr/src/labrinth/target/release/labrinth /labrinth/labrinth
COPY --from=build /usr/src/labrinth/apps/labrinth/migrations/* /labrinth/migrations/
COPY --from=build /usr/src/labrinth/apps/labrinth/assets /labrinth/assets
WORKDIR /labrinth WORKDIR /labrinth
ENTRYPOINT ["dumb-init", "--"] ENTRYPOINT ["dumb-init", "--"]
CMD ["/labrinth/labrinth"] CMD ["/labrinth/labrinth"]

View File

@@ -43,7 +43,9 @@ pub enum AuthenticationError {
InvalidAuthMethod, InvalidAuthMethod,
#[error("GitHub Token from incorrect Client ID")] #[error("GitHub Token from incorrect Client ID")]
InvalidClientId, InvalidClientId,
#[error("User email/account is already registered on Modrinth")] #[error(
"User email is already registered on Modrinth. Try 'Forgot password' to access your account."
)]
DuplicateUser, DuplicateUser,
#[error("Invalid state sent, you probably need to get a new websocket")] #[error("Invalid state sent, you probably need to get a new websocket")]
SocketError, SocketError,

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