Compare commits
190 Commits
ARF-v0.9.2
...
ARF-v0.9.5
| Author | SHA1 | Date | |
|---|---|---|---|
| 84bbb0d68f | |||
| 0a9dc5edfe | |||
| e5410e9c8e | |||
| 21f2c0e7aa | |||
| 7b234c7f55 | |||
|
|
dbde3c4669 | ||
|
|
ba4fecb0cb | ||
|
|
ef04dcc37b | ||
|
|
65126b3a23 | ||
|
|
58495e6276 | ||
|
|
706976439d | ||
|
|
0a9ffd3dc8 | ||
|
|
fb30c0ba2b | ||
|
|
97e4d8e132 | ||
|
|
5bdff3929b | ||
|
|
a08562bfe2 | ||
|
|
2b4319ea55 | ||
|
|
c32405720d | ||
|
|
b9ba3cd3e8 | ||
|
|
31381c860b | ||
|
|
e410a07cac | ||
|
|
9f93cd8705 | ||
|
|
dd391be095 | ||
|
|
f84f8c1c2b | ||
|
|
301967d204 | ||
|
|
c9b98a6154 | ||
|
|
ab8e474339 | ||
|
|
8a26011e76 | ||
|
|
d4de1dc9a1 | ||
|
|
4e3bd4e282 | ||
|
|
d24528f6a6 | ||
|
|
1b1d41605b | ||
|
|
6955731def | ||
|
|
4386891716 | ||
|
|
6741aba880 | ||
|
|
ee8ee7af82 | ||
|
|
a2e323c9ee | ||
|
|
f8fb23e05f | ||
|
|
a3839461cf | ||
|
|
858c7e393f | ||
|
|
0278241006 | ||
|
|
3afb682fc6 | ||
|
|
06f1df1995 | ||
|
|
3489771d2e | ||
|
|
448ae5a2b7 | ||
|
|
72340790e5 | ||
|
|
c9423fe478 | ||
|
|
02a850ae63 | ||
|
|
ede6d0c3cc | ||
|
|
7685989a8c | ||
|
|
4e8ebb5e5c | ||
|
|
3f77ab19ed | ||
|
|
d3d0c8c523 | ||
|
|
4e093131f3 | ||
|
|
6ca8a4e5fd | ||
|
|
63b15ded60 | ||
|
|
85e65aeffe | ||
|
|
ad44398492 | ||
|
|
a4ba41bf15 | ||
|
|
4441be5380 | ||
|
|
c0accb42fa | ||
|
|
7223c2b197 | ||
|
|
7b535a1c2a | ||
|
|
0aa76567a6 | ||
|
|
6fa1369c49 | ||
|
|
b66d99c59c | ||
|
|
a9cfc37aac | ||
|
|
be37f077d3 | ||
|
|
f52d020a3c | ||
| d7302f01d0 | |||
| 0e2447f41b | |||
| 3c03cd6091 | |||
|
|
74cf3f076e | ||
|
|
84adf79564 | ||
|
|
dc0d923cee | ||
|
|
2ffd7476aa | ||
|
|
034fd06284 | ||
|
|
49aac6bdca | ||
|
|
4e4a7be7ef | ||
|
|
9c1bdf16e4 | ||
|
|
9e527ff141 | ||
|
|
c6022ad977 | ||
|
|
ea9a3539eb | ||
|
|
e4f0dddf82 | ||
|
|
be425cff6f | ||
|
|
e225bc9f66 | ||
|
|
f19643095e | ||
|
|
37cc81a36d | ||
|
|
863bf62f8d | ||
|
|
9a6390bb4d | ||
|
|
62de07e4e6 | ||
|
|
6e46317a37 | ||
|
|
b59f208e91 | ||
|
|
8fdc7403b1 | ||
|
|
895cd11e30 | ||
|
|
58ce3a4967 | ||
|
|
bdd4deb302 | ||
|
|
ec2a56cd73 | ||
|
|
10a5864a47 | ||
|
|
16766be82f | ||
|
|
1884410e0d | ||
|
|
6d57da2053 | ||
|
|
e4adbb9469 | ||
|
|
8ee621295c | ||
|
|
32920dd825 | ||
|
|
2d5d2d5df8 | ||
|
|
8dd32bbe98 | ||
|
|
f71830e0fa | ||
|
|
bd0d6a9ac0 | ||
|
|
dfef0df464 | ||
|
|
9821737431 | ||
|
|
f932ce7706 | ||
|
|
de3019e92b | ||
|
|
20b616a7c4 | ||
|
|
4180544e0a | ||
|
|
9168d349fc | ||
|
|
3dad6b317f | ||
|
|
4a2605bc1e | ||
|
|
41543e3af0 | ||
|
|
6003f1a10e | ||
|
|
3d9be0cc3f | ||
|
|
5e7444f115 | ||
|
|
20fcf70e90 | ||
|
|
0508f13cb6 | ||
|
|
2f68c62b3a | ||
|
|
ea64e08791 | ||
|
|
6f485d62ad | ||
|
|
362fc11c81 | ||
|
|
9e9fb0a9fc | ||
|
|
ff4c7f47b2 | ||
|
|
25016053ca | ||
|
|
f9c0c1bc53 | ||
|
|
73e54a5fbb | ||
|
|
6f902e2107 | ||
| 881ce11ab8 | |||
|
|
ecb1379585 | ||
|
|
068711e7a9 | ||
|
|
f695fe0ee7 | ||
| 3d3038323c | |||
|
|
6cdc07406d | ||
| c63dca6c2b | |||
|
|
daf6999111 | ||
|
|
42731521f1 | ||
|
|
182119aedf | ||
|
|
59e18b3104 | ||
|
|
5c1f198397 | ||
|
|
3cd6718384 | ||
|
|
1903980b71 | ||
|
|
84a28e045b | ||
|
|
d0aef27f7b | ||
|
|
d6a74b0cfe | ||
|
|
0c43eb0d22 | ||
|
|
f8494030aa | ||
|
|
817151e47c | ||
|
|
d5dfb609cf | ||
|
|
09aae0edc9 | ||
|
|
6aa6db4e8c | ||
|
|
76be502e16 | ||
|
|
04659a8198 | ||
|
|
6c16688ca9 | ||
|
|
f2ec89e62b | ||
|
|
edd09b0b16 | ||
|
|
59edc8d618 | ||
|
|
56520572b2 | ||
|
|
487bdd1e48 | ||
|
|
8ad5e011ca | ||
|
|
6f43fc272b | ||
|
|
e008b657a5 | ||
|
|
365367dd16 | ||
| 5c09f31383 | |||
| d527c3e1ba | |||
|
|
36367e475e | ||
|
|
13f2961e43 | ||
|
|
69b70d70a8 | ||
|
|
d0d0dcf09f | ||
|
|
41b9729b9b | ||
|
|
a2009cae39 | ||
|
|
fab086b3e1 | ||
|
|
f379126242 | ||
|
|
8e0d9f2da6 | ||
|
|
e931b5c8ef | ||
|
|
84617d0c49 | ||
|
|
0908cf4e94 | ||
|
|
916f27c5ab | ||
|
|
9b442d04d9 | ||
|
|
9024b2eec5 | ||
|
|
4624a29332 | ||
|
|
3d2cef40d5 | ||
|
|
e8f8be1940 | ||
|
|
49faba6ad2 |
@@ -1,6 +1,6 @@
|
||||
# Windows has stack overflows when calling from Tauri, so we increase compiler size
|
||||
# Windows has stack overflows when calling from Tauri, so we increase the default stack size used by the compiler
|
||||
[target.'cfg(windows)']
|
||||
rustflags = ["-C", "link-args=/STACK:16777220"]
|
||||
|
||||
[build]
|
||||
rustflags = ["--cfg", "tokio_unstable"]
|
||||
rustflags = ["--cfg", "tokio_unstable"]
|
||||
|
||||
BIN
.github/assets/api_cover.png
vendored
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
BIN
.github/assets/app_cover.png
vendored
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 17 KiB |
BIN
.github/assets/monorepo_cover.png
vendored
|
Before Width: | Height: | Size: 417 KiB After Width: | Height: | Size: 262 KiB |
BIN
.github/assets/web_cover.png
vendored
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 24 KiB |
10
.github/workflows/theseus-release.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: 'AstralRinth App build'
|
||||
name: 'AstralRinth App Build'
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
@@ -73,10 +73,10 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-rust-target-
|
||||
|
||||
- name: Use Node.js
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Install pnpm via corepack
|
||||
shell: bash
|
||||
@@ -102,7 +102,7 @@ jobs:
|
||||
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
|
||||
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 frontend dependencies
|
||||
run: pnpm install
|
||||
@@ -125,7 +125,7 @@ jobs:
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
|
||||
- name: upload ${{ matrix.platform }}
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: ${{ matrix.platform }}
|
||||
path: |
|
||||
|
||||
6
.idea/vcs.xml
generated
@@ -1,5 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CommitMessageInspectionProfile">
|
||||
<profile version="1.0">
|
||||
<inspection_tool class="CommitFormat" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
<inspection_tool class="CommitNamingConvention" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
</profile>
|
||||
</component>
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
|
||||
1
.vscode/settings.json
vendored
@@ -2,6 +2,7 @@
|
||||
"prettier.endOfLine": "lf",
|
||||
"editor.formatOnSave": true,
|
||||
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"],
|
||||
"editor.detectIndentation": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
}
|
||||
|
||||
4823
Cargo.lock
generated
233
Cargo.toml
@@ -1,25 +1,226 @@
|
||||
[workspace]
|
||||
resolver = '2'
|
||||
resolver = "2"
|
||||
members = [
|
||||
'./packages/app-lib',
|
||||
'./apps/app-playground',
|
||||
'./apps/app',
|
||||
'./apps/labrinth',
|
||||
'./apps/daedalus_client',
|
||||
'./packages/daedalus',
|
||||
'./packages/ariadne',
|
||||
"apps/app",
|
||||
"apps/app-playground",
|
||||
"apps/daedalus_client",
|
||||
"apps/labrinth",
|
||||
"packages/app-lib",
|
||||
"packages/ariadne",
|
||||
"packages/daedalus",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
|
||||
[workspace.dependencies]
|
||||
actix-cors = "0.7.1"
|
||||
actix-files = "0.6.6"
|
||||
actix-http = "3.11.0"
|
||||
actix-multipart = "0.7.2"
|
||||
actix-rt = "2.10.0"
|
||||
actix-web = "4.11.0"
|
||||
actix-web-prom = "0.10.0"
|
||||
actix-ws = "0.3.0"
|
||||
argon2 = { version = "0.5.3", features = ["std"] }
|
||||
ariadne = { path = "packages/ariadne" }
|
||||
async_zip = "0.0.17"
|
||||
async-compression = { version = "0.4.24", default-features = false }
|
||||
async-recursion = "1.1.1"
|
||||
async-stripe = { version = "0.41.0", default-features = false, features = [
|
||||
"runtime-tokio-hyper-rustls",
|
||||
] }
|
||||
async-trait = "0.1.88"
|
||||
async-tungstenite = { version = "0.29.1", default-features = false, features = [
|
||||
"futures-03-sink",
|
||||
] }
|
||||
async-walkdir = "2.1.0"
|
||||
base64 = "0.22.1"
|
||||
bitflags = "2.9.1"
|
||||
bytes = "1.10.1"
|
||||
censor = "0.3.0"
|
||||
chardetng = "0.1.17"
|
||||
chrono = "0.4.41"
|
||||
clap = "4.5.40"
|
||||
clickhouse = "0.13.3"
|
||||
color-thief = "0.2.2"
|
||||
console-subscriber = "0.4.1"
|
||||
daedalus = { path = "packages/daedalus" }
|
||||
dashmap = "6.1.0"
|
||||
deadpool-redis = "0.21.1"
|
||||
dirs = "6.0.0"
|
||||
discord-rich-presence = "0.2.5"
|
||||
dotenv-build = "0.1.1"
|
||||
dotenvy = "0.15.7"
|
||||
dunce = "1.0.5"
|
||||
either = "1.15.0"
|
||||
encoding_rs = "0.8.35"
|
||||
enumset = "1.1.6"
|
||||
flate2 = "1.1.2"
|
||||
fs4 = { version = "0.13.1", default-features = false }
|
||||
futures = { version = "0.3.31", default-features = false }
|
||||
futures-util = "0.3.31"
|
||||
hex = "0.4.3"
|
||||
hickory-resolver = "0.25.2"
|
||||
hmac = "0.12.1"
|
||||
hyper-tls = "0.6.0"
|
||||
hyper-util = "0.1.14"
|
||||
iana-time-zone = "0.1.63"
|
||||
image = { version = "0.25.6", default-features = false, features = ["rayon"] }
|
||||
indexmap = "2.9.0"
|
||||
indicatif = "0.17.11"
|
||||
itertools = "0.14.0"
|
||||
jemalloc_pprof = "0.7.0"
|
||||
json-patch = { version = "4.0.0", default-features = false }
|
||||
lettre = { version = "0.11.17", default-features = false, features = [
|
||||
"builder",
|
||||
"hostname",
|
||||
"pool",
|
||||
"ring",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"smtp-transport",
|
||||
] }
|
||||
maxminddb = "0.26.0"
|
||||
meilisearch-sdk = { version = "0.28.0", default-features = false }
|
||||
murmur2 = "0.1.0"
|
||||
native-dialog = "0.9.0"
|
||||
notify = { version = "8.0.0", default-features = false }
|
||||
notify-debouncer-mini = { version = "0.6.0", default-features = false }
|
||||
p256 = "0.13.2"
|
||||
paste = "1.0.15"
|
||||
prometheus = "0.14.0"
|
||||
quartz_nbt = "0.2.9"
|
||||
quick-xml = "0.37.5"
|
||||
rand = "=0.8.5" # Locked on 0.8 until argon2 and p256 update to 0.9
|
||||
rand_chacha = "=0.3.1" # Locked on 0.3 until we can update rand to 0.9
|
||||
redis = "=0.31.0" # Locked on 0.31 until deadpool-redis updates to 0.32
|
||||
regex = "1.11.1"
|
||||
reqwest = { version = "0.12.19", default-features = false }
|
||||
rust_decimal = { version = "1.37.1", features = [
|
||||
"serde-with-float",
|
||||
"serde-with-str",
|
||||
] }
|
||||
rust_iso3166 = "0.1.14"
|
||||
rust-s3 = { version = "0.35.1", default-features = false, features = [
|
||||
"fail-on-err",
|
||||
"tags",
|
||||
"tokio-rustls-tls",
|
||||
] }
|
||||
rusty-money = "0.4.1"
|
||||
sentry = { version = "0.38.1", default-features = false, features = [
|
||||
"backtrace",
|
||||
"contexts",
|
||||
"debug-images",
|
||||
"panic",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
] }
|
||||
sentry-actix = "0.38.1"
|
||||
serde = "1.0.219"
|
||||
serde_bytes = "0.11.17"
|
||||
serde_cbor = "0.11.2"
|
||||
serde_ini = "0.2.0"
|
||||
serde_json = "1.0.140"
|
||||
serde_with = "3.12.0"
|
||||
serde-xml-rs = "0.8.1" # Also an XML (de)serializer, consider dropping yaserde in favor of this
|
||||
sha1 = "0.10.6"
|
||||
sha1_smol = { version = "1.0.1", features = ["std"] }
|
||||
sha2 = "0.10.9"
|
||||
spdx = "0.10.8"
|
||||
sqlx = { version = "0.8.6", default-features = false }
|
||||
sysinfo = { version = "0.35.2", default-features = false }
|
||||
tar = "0.4.44"
|
||||
tauri = "2.5.1"
|
||||
tauri-build = "2.2.0"
|
||||
tauri-plugin-deep-link = "2.3.0"
|
||||
tauri-plugin-dialog = "2.2.2"
|
||||
tauri-plugin-opener = "2.2.7"
|
||||
tauri-plugin-os = "2.2.1"
|
||||
tauri-plugin-single-instance = "2.2.4"
|
||||
tauri-plugin-updater = { version = "2.7.1", default-features = false, features = [
|
||||
"rustls-tls",
|
||||
"zip",
|
||||
] }
|
||||
tauri-plugin-window-state = "2.2.2"
|
||||
tempfile = "3.20.0"
|
||||
theseus = { path = "packages/app-lib" }
|
||||
thiserror = "2.0.12"
|
||||
tikv-jemalloc-ctl = "0.6.0"
|
||||
tikv-jemallocator = "0.6.0"
|
||||
tokio = "1.45.1"
|
||||
tokio-stream = "0.1.17"
|
||||
tokio-util = "0.7.15"
|
||||
totp-rs = "5.7.0"
|
||||
tracing = "0.1.41"
|
||||
tracing-actix-web = "0.7.18"
|
||||
tracing-error = "0.2.1"
|
||||
tracing-subscriber = "0.3.19"
|
||||
url = "2.5.4"
|
||||
urlencoding = "2.1.3"
|
||||
uuid = "1.17.0"
|
||||
validator = "0.20.0"
|
||||
webp = { version = "0.3.0", default-features = false }
|
||||
whoami = "1.6.0"
|
||||
winreg = "0.55.0"
|
||||
woothee = "0.13.0"
|
||||
yaserde = "0.12.0"
|
||||
zip = { version = "4.0.0", default-features = false, features = [
|
||||
"bzip2",
|
||||
"deflate",
|
||||
"deflate64",
|
||||
"zstd",
|
||||
] }
|
||||
zxcvbn = "3.1.0"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
bool_to_int_with_if = "warn"
|
||||
borrow_as_ptr = "warn"
|
||||
cfg_not_test = "warn"
|
||||
clear_with_drain = "warn"
|
||||
cloned_instead_of_copied = "warn"
|
||||
collection_is_never_read = "warn"
|
||||
dbg_macro = "warn"
|
||||
default_trait_access = "warn"
|
||||
explicit_iter_loop = "warn"
|
||||
filter_map_next = "warn"
|
||||
flat_map_option = "warn"
|
||||
format_push_string = "warn"
|
||||
get_unwrap = "warn"
|
||||
large_include_file = "warn"
|
||||
large_stack_arrays = "warn"
|
||||
manual_assert = "warn"
|
||||
manual_instant_elapsed = "warn"
|
||||
manual_is_variant_and = "warn"
|
||||
manual_let_else = "warn"
|
||||
map_unwrap_or = "warn"
|
||||
match_bool = "warn"
|
||||
needless_collect = "warn"
|
||||
negative_feature_names = "warn"
|
||||
non_std_lazy_statics = "warn"
|
||||
pathbuf_init_then_push = "warn"
|
||||
read_zero_byte_vec = "warn"
|
||||
redundant_clone = "warn"
|
||||
redundant_feature_names = "warn"
|
||||
redundant_type_annotations = "warn"
|
||||
todo = "warn"
|
||||
unnested_or_patterns = "warn"
|
||||
wildcard_dependencies = "warn"
|
||||
|
||||
[workspace.lints.rust]
|
||||
# Turn warnings into errors by default
|
||||
warnings = "deny"
|
||||
|
||||
[patch.crates-io]
|
||||
wry = { git = "https://github.com/modrinth/wry", rev = "cafdaa9" }
|
||||
|
||||
# Optimize for speed and reduce size on release builds
|
||||
[profile.release]
|
||||
panic = "abort" # Strip expensive panic clean-up logic
|
||||
codegen-units = 1 # Compile crates one after another so the compiler can optimize better
|
||||
lto = true # Enables link to optimizations
|
||||
opt-level = "s" # Optimize for binary size
|
||||
strip = true # Remove debug symbols
|
||||
opt-level = "s" # Optimize for binary size
|
||||
strip = true # Remove debug symbols
|
||||
lto = true # Enables link to optimizations
|
||||
panic = "abort" # Strip expensive panic clean-up logic
|
||||
codegen-units = 1 # Compile crates one after another so the compiler can optimize better
|
||||
|
||||
[profile.dev.package.sqlx-macros]
|
||||
opt-level = 3
|
||||
|
||||
[patch.crates-io]
|
||||
wry = { git = "https://github.com/modrinth/wry", rev = "51907c6" }
|
||||
|
||||
@@ -17,7 +17,7 @@ Introducing AstralRinth, a specialized variant of Theseus dedicated to implement
|
||||
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
|
||||
- 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://github.com/DIDIRUS4/AstralRinth/releases)
|
||||
- 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
|
||||
@@ -45,7 +45,7 @@ Dive into the extraordinary world of AstralRinth, a fork of the original project
|
||||
- Using the launcher is intuitive, any user can figure it out.
|
||||
|
||||
### Update notifies
|
||||
- We have implemented notifications about the release of new updates on our Github. The launcher can also download them for you and try to install them.
|
||||
- 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.
|
||||
|
||||
### Enhancements
|
||||
- Custom .SVG vectors for a personalized touch.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@modrinth/app-frontend",
|
||||
"private": true,
|
||||
"version": "0.9.3",
|
||||
"version": "0.9.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -9,19 +9,21 @@
|
||||
"tsc:check": "vue-tsc --noEmit",
|
||||
"lint": "eslint . && prettier --check .",
|
||||
"fix": "eslint . --fix && prettier --write .",
|
||||
"intl:extract": "formatjs extract \"{,src/components,src/composables,src/helpers,src/pages,src/store}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore '**/*.d.ts' --ignore 'node_modules' --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace"
|
||||
"intl:extract": "formatjs extract \"{,src/components,src/composables,src/helpers,src/pages,src/store}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore '**/*.d.ts' --ignore 'node_modules' --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
|
||||
"test": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@geometrically/minecraft-motd-parser": "^1.1.4",
|
||||
"@modrinth/assets": "workspace:*",
|
||||
"@modrinth/ui": "workspace:*",
|
||||
"@modrinth/utils": "workspace:*",
|
||||
"@sentry/vue": "^8.27.0",
|
||||
"@tauri-apps/api": "^2.1.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.2.0",
|
||||
"@tauri-apps/plugin-os": "^2.2.0",
|
||||
"@tauri-apps/plugin-opener": "^2.2.1",
|
||||
"@tauri-apps/plugin-updater": "^2.3.0",
|
||||
"@tauri-apps/plugin-window-state": "^2.2.0",
|
||||
"@tauri-apps/api": "^2.5.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.2.1",
|
||||
"@tauri-apps/plugin-opener": "^2.2.6",
|
||||
"@tauri-apps/plugin-os": "^2.2.1",
|
||||
"@tauri-apps/plugin-updater": "^2.7.1",
|
||||
"@tauri-apps/plugin-window-state": "^2.2.2",
|
||||
"@vintl/vintl": "^4.4.1",
|
||||
"dayjs": "^1.11.10",
|
||||
"floating-vue": "^5.2.2",
|
||||
@@ -38,11 +40,12 @@
|
||||
"@eslint/compat": "^1.1.1",
|
||||
"@formatjs/cli": "^6.2.12",
|
||||
"@nuxt/eslint-config": "^0.5.6",
|
||||
"@taijased/vue-render-tracker": "^1.0.7",
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^9.9.1",
|
||||
"eslint-config-custom": "workspace:*",
|
||||
"eslint-plugin-turbo": "^2.1.1",
|
||||
"eslint-plugin-turbo": "^2.5.4",
|
||||
"postcss": "^8.4.39",
|
||||
"prettier": "^3.2.5",
|
||||
"sass": "^1.74.1",
|
||||
|
||||
@@ -16,14 +16,22 @@ import {
|
||||
RestoreIcon,
|
||||
RightArrowIcon,
|
||||
SettingsIcon,
|
||||
WorldIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Avatar, Button, ButtonStyled, Notifications, OverflowMenu } from '@modrinth/ui'
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
ButtonStyled,
|
||||
Notifications,
|
||||
OverflowMenu,
|
||||
useRelativeTime,
|
||||
} from '@modrinth/ui'
|
||||
import { useLoading, useTheming } from '@/store/state'
|
||||
// import ModrinthAppLogo from '@/assets/modrinth_app.svg?component'
|
||||
import AccountsCard from '@/components/ui/AccountsCard.vue'
|
||||
import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue'
|
||||
import { get, set } from '@/helpers/settings'
|
||||
import { get, set } from '@/helpers/settings.ts'
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
|
||||
import RunningAppBar from '@/components/ui/RunningAppBar.vue'
|
||||
import SplashScreen from '@/components/ui/SplashScreen.vue'
|
||||
@@ -61,6 +69,8 @@ import FriendsList from '@/components/ui/friends/FriendsList.vue'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
|
||||
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
|
||||
const themeStore = useTheming()
|
||||
|
||||
const news = ref([])
|
||||
@@ -177,15 +187,21 @@ async function setupApp() {
|
||||
}),
|
||||
)
|
||||
|
||||
// useFetch(
|
||||
// `https://api.modrinth.com/appCriticalAnnouncement.json?version=${version}`,
|
||||
// 'criticalAnnouncements',
|
||||
// true,
|
||||
// ).then((res) => {
|
||||
// if (res && res.header && res.body) {
|
||||
// criticalErrorMessage.value = res
|
||||
// }
|
||||
// })
|
||||
//useFetch(
|
||||
// `https://api.modrinth.com/appCriticalAnnouncement.json?version=${version}`,
|
||||
// 'criticalAnnouncements',
|
||||
// true,
|
||||
//)
|
||||
// .then((res) => {
|
||||
// if (res && res.header && res.body) {
|
||||
// criticalErrorMessage.value = res
|
||||
// }
|
||||
// })
|
||||
// .catch(() => {
|
||||
// console.log(
|
||||
// `No critical announcement found at https://api.modrinth.com/appCriticalAnnouncement.json?version=${version}`,
|
||||
// )
|
||||
// })
|
||||
|
||||
useFetch(`https://modrinth.com/blog/news.json`, 'news', true).then((res) => {
|
||||
if (res && res.articles) {
|
||||
@@ -374,7 +390,7 @@ function handleAuxClick(e) {
|
||||
<template>
|
||||
<SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region />
|
||||
<div id="teleports"></div>
|
||||
<div v-if="stateInitialized" class="app-grid-layout relative">
|
||||
<div v-if="stateInitialized" class="app-grid-layout experimental-styles-within relative">
|
||||
<Suspense>
|
||||
<AppSettingsModal ref="settingsModal" />
|
||||
</Suspense>
|
||||
@@ -387,6 +403,9 @@ function handleAuxClick(e) {
|
||||
<NavButton v-tooltip.right="'Home'" to="/">
|
||||
<HomeIcon />
|
||||
</NavButton>
|
||||
<NavButton v-if="themeStore.featureFlags.worlds_tab" v-tooltip.right="'Worlds'" to="/worlds">
|
||||
<WorldIcon />
|
||||
</NavButton>
|
||||
<NavButton
|
||||
v-tooltip.right="'Discover content'"
|
||||
to="/browse/modpack"
|
||||
@@ -488,7 +507,7 @@ function handleAuxClick(e) {
|
||||
<RunningAppBar />
|
||||
</Suspense>
|
||||
</div>
|
||||
<section v-if="!nativeDecorations" class="window-controls">
|
||||
<section v-if="!nativeDecorations" class="window-controls" data-tauri-drag-region-exclude>
|
||||
<Button class="titlebar-button" icon-only @click="() => getCurrentWindow().minimize()">
|
||||
<MinimizeIcon />
|
||||
</Button>
|
||||
@@ -595,7 +614,7 @@ function handleAuxClick(e) {
|
||||
</h4>
|
||||
<p class="my-1 text-sm text-secondary leading-tight">{{ item.summary }}</p>
|
||||
<p class="text-right text-sm text-secondary opacity-60 leading-tight m-0">
|
||||
{{ dayjs(item.date).fromNow() }}
|
||||
{{ formatRelativeTime(dayjs(item.date).toISOString()) }}
|
||||
</p>
|
||||
</a>
|
||||
<hr
|
||||
@@ -719,6 +738,14 @@ function handleAuxClick(e) {
|
||||
grid-area: status;
|
||||
}
|
||||
|
||||
[data-tauri-drag-region] {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
[data-tauri-drag-region-exclude] {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.app-contents {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
|
||||
BIN
apps/app-frontend/src/assets/external/gdlauncher.png
vendored
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 8.1 KiB |
BIN
apps/app-frontend/src/assets/font/minecraft_font.ttf
Normal file
|
Before Width: | Height: | Size: 937 KiB After Width: | Height: | Size: 270 KiB |
@@ -2,8 +2,44 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@font-face {
|
||||
font-family: 'bundled-minecraft-font-mrapp';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 400;
|
||||
src: url('https://cdn-raw.modrinth.com/fonts/minecraft/regular.otf') format('opentype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'bundled-minecraft-font-mrapp';
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
font-weight: 400;
|
||||
src: url('https://cdn-raw.modrinth.com/fonts/minecraft/italic.otf') format('opentype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'bundled-minecraft-font-mrapp';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 600;
|
||||
src: url('https://cdn-raw.modrinth.com/fonts/minecraft/bold.otf') format('opentype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'bundled-minecraft-font-mrapp';
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
font-weight: 600;
|
||||
src: url('https://cdn-raw.modrinth.com/fonts/minecraft/bold-italic.otf') format('opentype');
|
||||
}
|
||||
|
||||
.font-minecraft {
|
||||
font-family: 'bundled-minecraft-font-mrapp', monospace;
|
||||
}
|
||||
|
||||
:root {
|
||||
font-family: var(--font-standard);
|
||||
font-family: var(--font-standard, sans-serif), sans-serif;
|
||||
color-scheme: dark;
|
||||
--view-width: calc(100% - 5rem);
|
||||
--expanded-view-width: calc(100% - 13rem);
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
StopCircleIcon,
|
||||
ExternalIcon,
|
||||
EyeIcon,
|
||||
ChevronRightIcon,
|
||||
} from '@modrinth/assets'
|
||||
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
|
||||
import Instance from '@/components/ui/Instance.vue'
|
||||
@@ -26,6 +25,7 @@ import { trackEvent } from '@/helpers/analytics'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
import { install as installVersion } from '@/store/install.js'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { HeadingLink } from '@modrinth/ui'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -44,7 +44,9 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const actualInstances = computed(() =>
|
||||
props.instances.filter((x) => x && x.instances && x.instances[0]),
|
||||
props.instances.filter(
|
||||
(x) => (x && x.instances && x.instances[0] && x.show === undefined) || x.show,
|
||||
),
|
||||
)
|
||||
|
||||
const modsRow = ref(null)
|
||||
@@ -181,6 +183,10 @@ const maxInstancesPerRow = ref(1)
|
||||
const maxProjectsPerRow = ref(1)
|
||||
|
||||
const calculateCardsPerRow = () => {
|
||||
if (rows.value.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate how many cards fit in one row
|
||||
const containerWidth = rows.value[0].clientWidth
|
||||
// Convert container width from pixels to rem
|
||||
@@ -204,16 +210,21 @@ const calculateCardsPerRow = () => {
|
||||
|
||||
const rowContainer = ref(null)
|
||||
const resizeObserver = ref(null)
|
||||
|
||||
onMounted(() => {
|
||||
calculateCardsPerRow()
|
||||
resizeObserver.value = new ResizeObserver(calculateCardsPerRow)
|
||||
resizeObserver.value.observe(rowContainer.value)
|
||||
if (rowContainer.value) {
|
||||
resizeObserver.value.observe(rowContainer.value)
|
||||
}
|
||||
window.addEventListener('resize', calculateCardsPerRow)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', calculateCardsPerRow)
|
||||
resizeObserver.value.unobserve(rowContainer.value)
|
||||
if (rowContainer.value) {
|
||||
resizeObserver.value.unobserve(rowContainer.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -227,17 +238,10 @@ onUnmounted(() => {
|
||||
@proceed="deleteProfile"
|
||||
/>
|
||||
<div ref="rowContainer" class="flex flex-col gap-4">
|
||||
<div v-for="(row, rowIndex) in actualInstances" ref="rows" :key="row.label" class="row">
|
||||
<router-link
|
||||
class="flex mb-3 leading-none items-center gap-1 text-primary text-lg font-bold hover:underline group"
|
||||
:class="{ 'mt-1': rowIndex > 0 }"
|
||||
:to="row.route"
|
||||
>
|
||||
<div v-for="row in actualInstances" ref="rows" :key="row.label" class="row">
|
||||
<HeadingLink class="mt-1" :to="row.route">
|
||||
{{ row.label }}
|
||||
<ChevronRightIcon
|
||||
class="h-5 w-5 stroke-[3px] group-hover:translate-x-1 transition-transform group-hover:text-brand"
|
||||
/>
|
||||
</router-link>
|
||||
</HeadingLink>
|
||||
<section
|
||||
v-if="row.instance"
|
||||
ref="modsRow"
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
path: breadcrumb.link.replace('{id}', encodeURIComponent($route.params.id)),
|
||||
query: breadcrumb.query,
|
||||
}"
|
||||
class="text-primary"
|
||||
>{{
|
||||
breadcrumb.name.charAt(0) === '?'
|
||||
? breadcrumbData.getName(breadcrumb.name.slice(1))
|
||||
|
||||
@@ -14,7 +14,7 @@ import { ref, computed } from 'vue'
|
||||
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
|
||||
import { handleError } from '@/store/notifications.js'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
import { cancel_directory_change } from '@/helpers/settings.js'
|
||||
import { cancel_directory_change } from '@/helpers/settings.ts'
|
||||
import { install } from '@/helpers/profile.js'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
StopCircleIcon,
|
||||
TimerIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Avatar, ButtonStyled } from '@modrinth/ui'
|
||||
import { Avatar, ButtonStyled, useRelativeTime } from '@modrinth/ui'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { finish_install, kill, run } from '@/helpers/profile'
|
||||
import { get_by_profile_path } from '@/helpers/process'
|
||||
@@ -19,10 +19,9 @@ import { showProfileInFolder } from '@/helpers/utils.js'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import { formatCategory } from '@modrinth/utils'
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
|
||||
const props = defineProps({
|
||||
instance: {
|
||||
@@ -173,7 +172,9 @@ onUnmounted(() => unlisten())
|
||||
</div>
|
||||
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold">
|
||||
<TimerIcon />
|
||||
<span class="text-sm"> Played {{ dayjs(instance.last_played).fromNow() }} </span>
|
||||
<span class="text-sm">
|
||||
Played {{ formatRelativeTime(dayjs(instance.last_played).toISOString()) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<ModalWrapper ref="modal" header="Create instance">
|
||||
<ModalWrapper ref="modal" header="Creating an instance">
|
||||
<div class="modal-header">
|
||||
<Chips v-model="creationType" :items="['custom', 'from file', 'import from launcher']" />
|
||||
</div>
|
||||
|
||||
@@ -70,7 +70,7 @@ const onHide = () => {
|
||||
v-for="version in filteredVersions"
|
||||
:key="version.id"
|
||||
class="table-row with-columns selectable"
|
||||
@click="$router.push(`/project/${$route.params.id}/version/${version.id}`)"
|
||||
@click="$router.push(`/project/${version.project_id}/version/${version.id}`)"
|
||||
>
|
||||
<div class="table-cell table-text">
|
||||
<Button
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
'router-link-active': isPrimary && isPrimary(route),
|
||||
'subpage-active': isSubpage && isSubpage(route),
|
||||
}"
|
||||
class="w-12 h-12 rounded-full flex items-center justify-center text-2xl transition-all bg-transparent hover:bg-button-bg hover:text-contrast"
|
||||
class="w-12 h-12 text-primary rounded-full flex items-center justify-center text-2xl transition-all bg-transparent hover:bg-button-bg hover:text-contrast"
|
||||
>
|
||||
<slot />
|
||||
</RouterLink>
|
||||
|
||||
@@ -14,7 +14,10 @@
|
||||
<div v-if="selectedProcess" class="status">
|
||||
<span class="circle running" />
|
||||
<div ref="profileButton" class="running-text">
|
||||
<router-link :to="`/instance/${encodeURIComponent(selectedProcess.profile.path)}`">
|
||||
<router-link
|
||||
class="text-primary"
|
||||
:to="`/instance/${encodeURIComponent(selectedProcess.profile.path)}`"
|
||||
>
|
||||
{{ selectedProcess.profile.name }}
|
||||
</router-link>
|
||||
<div v-if="currentProcesses.length > 1" class="arrow button-base" :class="{ rotate: showProfiles }"
|
||||
@@ -50,13 +53,14 @@
|
||||
<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>Warning:</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>
|
||||
|
||||
@@ -124,8 +124,11 @@ import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import { ref, computed } from 'vue'
|
||||
import { install as installVersion } from '@/store/install.js'
|
||||
import { useRouter } from 'vue-router'
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps({
|
||||
backgroundImage: {
|
||||
type: String,
|
||||
@@ -168,6 +171,9 @@ async function install() {
|
||||
installing.value = false
|
||||
emit('install', props.project.project_id ?? props.project.id)
|
||||
},
|
||||
(profile) => {
|
||||
router.push(`/instance/${profile}`)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { Avatar, ButtonStyled, OverflowMenu } from '@modrinth/ui'
|
||||
import { Avatar, ButtonStyled, OverflowMenu, useRelativeTime } from '@modrinth/ui'
|
||||
import {
|
||||
UserPlusIcon,
|
||||
MoreVerticalIcon,
|
||||
@@ -18,6 +18,8 @@ import type { Dayjs } from 'dayjs'
|
||||
import dayjs from 'dayjs'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
|
||||
const props = defineProps<{
|
||||
credentials: unknown | null
|
||||
signIn: () => void
|
||||
@@ -205,7 +207,9 @@ onUnmounted(() => {
|
||||
You sent <span class="font-bold">{{ friend.username }}</span> a friend request
|
||||
</template>
|
||||
</p>
|
||||
<p class="m-0 text-sm text-secondary">{{ friend.created.fromNow() }}</p>
|
||||
<p class="m-0 text-sm text-secondary">
|
||||
{{ formatRelativeTime(friend.created.toISOString()) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<template v-if="friend.id === userCredentials.user_id">
|
||||
|
||||
@@ -13,15 +13,17 @@ const confirmModal = ref(null)
|
||||
const installing = ref(false)
|
||||
|
||||
const onInstall = ref(() => {})
|
||||
const onCreateInstance = ref(() => {})
|
||||
|
||||
defineExpose({
|
||||
show: (projectVal, versionIdVal, callback) => {
|
||||
show: (projectVal, versionIdVal, callback, createInstanceCallback) => {
|
||||
project.value = projectVal
|
||||
versionId.value = versionIdVal
|
||||
installing.value = false
|
||||
confirmModal.value.show()
|
||||
|
||||
onInstall.value = callback
|
||||
onCreateInstance.value = createInstanceCallback
|
||||
|
||||
trackEvent('PackInstallStart')
|
||||
},
|
||||
@@ -36,6 +38,7 @@ async function install() {
|
||||
versionId.value,
|
||||
project.value.title,
|
||||
project.value.icon_url,
|
||||
onCreateInstance.value,
|
||||
).catch(handleError)
|
||||
trackEvent('PackInstall', {
|
||||
id: project.value.id,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Checkbox } from '@modrinth/ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { handleError } from '@/store/notifications'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import { get } from '@/helpers/settings'
|
||||
import { get } from '@/helpers/settings.ts'
|
||||
import { edit } from '@/helpers/profile'
|
||||
import type { InstanceSettingsTabProps, AppSettings, Hooks } from '../../../helpers/types'
|
||||
|
||||
@@ -25,9 +25,8 @@ const editProfileObject = computed(() => {
|
||||
hooks?: Hooks
|
||||
} = {}
|
||||
|
||||
if (overrideHooks.value) {
|
||||
editProfile.hooks = hooks.value
|
||||
}
|
||||
// When hooks are not overridden per-instance, we want to clear them
|
||||
editProfile.hooks = overrideHooks.value ? hooks.value : {}
|
||||
|
||||
return editProfile
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ import { handleError } from '@/store/notifications'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import JavaSelector from '@/components/ui/JavaSelector.vue'
|
||||
import { get_max_memory } from '@/helpers/jre'
|
||||
import { get } from '@/helpers/settings'
|
||||
import { get } from '@/helpers/settings.ts'
|
||||
import type { InstanceSettingsTabProps, AppSettings, MemorySettings } from '../../../helpers/types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Checkbox, Toggle } from '@modrinth/ui'
|
||||
import { computed, ref, type Ref, watch } from 'vue'
|
||||
import { handleError } from '@/store/notifications'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import { get } from '@/helpers/settings'
|
||||
import { get } from '@/helpers/settings.ts'
|
||||
import { edit } from '@/helpers/profile'
|
||||
import type { AppSettings, InstanceSettingsTabProps } from '../../../helpers/types'
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import { version as getOsVersion, platform as getOsPlatform } from '@tauri-apps/
|
||||
import { useTheming } from '@/store/state'
|
||||
import FeatureFlagSettings from '@/components/ui/settings/FeatureFlagSettings.vue'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { get, set } from '@/helpers/settings'
|
||||
import { get, set } from '@/helpers/settings.ts'
|
||||
|
||||
const themeStore = useTheming()
|
||||
|
||||
|
||||
@@ -41,6 +41,10 @@ const props = defineProps({
|
||||
// type: Boolean,
|
||||
// default: true,
|
||||
// },
|
||||
markdown: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['proceed'])
|
||||
@@ -79,6 +83,7 @@ function proceed() {
|
||||
:on-hide="onModalHide"
|
||||
:noblur="!themeStore.advancedRendering"
|
||||
:danger="danger"
|
||||
:markdown="markdown"
|
||||
@proceed="proceed"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronRightIcon } from '@modrinth/assets'
|
||||
import { Avatar } from '@modrinth/ui'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
defineProps<{
|
||||
instance: GameInstance
|
||||
}>()
|
||||
</script>
|
||||
<template>
|
||||
<span class="flex items-center gap-2 text-lg font-semibold text-primary">
|
||||
<Avatar
|
||||
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : undefined"
|
||||
size="24px"
|
||||
:tint-by="instance.path"
|
||||
/>
|
||||
{{ instance.name }} <ChevronRightIcon />
|
||||
</span>
|
||||
</template>
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { TeleportDropdownMenu, ThemeSelector, Toggle } from '@modrinth/ui'
|
||||
import { useTheming } from '@/store/state'
|
||||
import { get, set } from '@/helpers/settings'
|
||||
import { get, set } from '@/helpers/settings.ts'
|
||||
import { ref, watch } from 'vue'
|
||||
import { getOS } from '@/helpers/utils'
|
||||
import type { ColorTheme } from '@/store/theme.ts'
|
||||
|
||||
const themeStore = useTheming()
|
||||
|
||||
@@ -24,13 +25,13 @@ watch(
|
||||
|
||||
<ThemeSelector
|
||||
:update-color-theme="
|
||||
(theme) => {
|
||||
(theme: ColorTheme) => {
|
||||
themeStore.setThemeState(theme)
|
||||
settings.theme = theme
|
||||
}
|
||||
"
|
||||
:current-theme="settings.theme"
|
||||
:theme-options="themeStore.themeOptions"
|
||||
:theme-options="themeStore.getThemeOptions()"
|
||||
system-theme-color="system"
|
||||
/>
|
||||
|
||||
@@ -80,10 +81,28 @@ watch(
|
||||
id="opening-page"
|
||||
v-model="settings.default_page"
|
||||
name="Opening page dropdown"
|
||||
class="w-40"
|
||||
:options="['Home', 'Library']"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-extrabold text-contrast">Jump back into worlds</h2>
|
||||
<p class="m-0 mt-1">Includes recent worlds in the "Jump back in" section on the Home page.</p>
|
||||
</div>
|
||||
<Toggle
|
||||
:model-value="themeStore.getFeatureFlag('worlds_in_home')"
|
||||
@update:model-value="
|
||||
() => {
|
||||
const newValue = !themeStore.getFeatureFlag('worlds_in_home')
|
||||
themeStore.featureFlags['worlds_in_home'] = newValue
|
||||
settings.feature_flags['worlds_in_home'] = newValue
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-extrabold text-contrast">Toggle sidebar</h2>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { get, set } from '@/helpers/settings'
|
||||
import { get, set } from '@/helpers/settings.ts'
|
||||
import { ref, watch } from 'vue'
|
||||
import { get_max_memory } from '@/helpers/jre'
|
||||
import { handleError } from '@/store/notifications'
|
||||
|
||||
@@ -2,18 +2,15 @@
|
||||
import { Toggle } from '@modrinth/ui'
|
||||
import { useTheming } from '@/store/state'
|
||||
import { ref, watch } from 'vue'
|
||||
import { get, set } from '@/helpers/settings'
|
||||
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
|
||||
import { DEFAULT_FEATURE_FLAGS, type FeatureFlag } from '@/store/theme.ts'
|
||||
|
||||
const themeStore = useTheming()
|
||||
|
||||
const settings = ref(await get())
|
||||
const options = ref(['project_background', 'page_path'])
|
||||
const settings = ref(await getSettings())
|
||||
const options = ref<FeatureFlag[]>(Object.keys(DEFAULT_FEATURE_FLAGS))
|
||||
|
||||
function getStoreValue(key: string) {
|
||||
return themeStore.featureFlags[key] ?? false
|
||||
}
|
||||
|
||||
function setStoreValue(key: string, value: boolean) {
|
||||
function setFeatureFlag(key: string, value: boolean) {
|
||||
themeStore.featureFlags[key] = value
|
||||
settings.value.feature_flags[key] = value
|
||||
}
|
||||
@@ -21,7 +18,7 @@ function setStoreValue(key: string, value: boolean) {
|
||||
watch(
|
||||
settings,
|
||||
async () => {
|
||||
await set(settings.value)
|
||||
await setSettings(settings.value)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
@@ -30,14 +27,14 @@ watch(
|
||||
<div v-for="option in options" :key="option" class="mt-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-extrabold text-contrast capitalize">
|
||||
{{ option }}
|
||||
{{ option.replaceAll('_', ' ') }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<Toggle
|
||||
id="advanced-rendering"
|
||||
:model-value="getStoreValue(option)"
|
||||
@update:model-value="() => setStoreValue(option, !themeStore.featureFlags[option])"
|
||||
:model-value="themeStore.getFeatureFlag(option)"
|
||||
@update:model-value="() => setFeatureFlag(option, !themeStore.getFeatureFlag(option))"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { get, set } from '@/helpers/settings'
|
||||
import { get, set } from '@/helpers/settings.ts'
|
||||
import { Toggle } from '@modrinth/ui'
|
||||
import { optInAnalytics, optOutAnalytics } from '@/helpers/analytics'
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { Button, Slider } from '@modrinth/ui'
|
||||
import { ref, watch } from 'vue'
|
||||
import { get, set } from '@/helpers/settings.js'
|
||||
import { get, set } from '@/helpers/settings.ts'
|
||||
import { purge_cache_types } from '@/helpers/cache.js'
|
||||
import { handleError } from '@/store/notifications.js'
|
||||
import { BoxIcon, FolderSearchIcon, TrashIcon } from '@modrinth/assets'
|
||||
|
||||
228
apps/app-frontend/src/components/ui/world/InstanceItem.vue
Normal file
@@ -0,0 +1,228 @@
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs'
|
||||
import {
|
||||
EyeIcon,
|
||||
FolderOpenIcon,
|
||||
MoreVerticalIcon,
|
||||
PlayIcon,
|
||||
SpinnerIcon,
|
||||
StopCircleIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
OverflowMenu,
|
||||
SmartClickable,
|
||||
useRelativeTime,
|
||||
} from '@modrinth/ui'
|
||||
import { useVIntl } from '@vintl/vintl'
|
||||
import { computed, nextTick, ref, onMounted, onUnmounted } from 'vue'
|
||||
import { showProfileInFolder } from '@/helpers/utils'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { get_project } from '@/helpers/cache'
|
||||
import { capitalizeString } from '@modrinth/utils'
|
||||
import { kill, run } from '@/helpers/profile'
|
||||
import { handleSevereError } from '@/store/error'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { get_by_profile_path } from '@/helpers/process'
|
||||
import { handleError } from '@/store/notifications'
|
||||
import { process_listener } from '@/helpers/events'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'play' | 'stop'): void
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
instance: GameInstance
|
||||
}>()
|
||||
|
||||
const loadingModpack = ref(!!props.instance.linked_data)
|
||||
|
||||
const modpack = ref()
|
||||
|
||||
if (props.instance.linked_data) {
|
||||
nextTick().then(async () => {
|
||||
modpack.value = await get_project(props.instance.linked_data?.project_id, 'must_revalidate')
|
||||
loadingModpack.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const instanceIcon = computed(() => props.instance.icon_path)
|
||||
|
||||
const loader = computed(() => {
|
||||
if (props.instance.loader === 'vanilla') {
|
||||
return 'Minecraft'
|
||||
} else if (props.instance.loader === 'neoforge') {
|
||||
return 'NeoForge'
|
||||
} else {
|
||||
return capitalizeString(props.instance.loader)
|
||||
}
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const playing = ref(false)
|
||||
|
||||
const play = async (event: MouseEvent) => {
|
||||
event?.stopPropagation()
|
||||
loading.value = true
|
||||
await run(props.instance.path)
|
||||
.catch((err) => handleSevereError(err, { profilePath: props.instance.path }))
|
||||
.finally(() => {
|
||||
trackEvent('InstancePlay', {
|
||||
loader: props.instance.loader,
|
||||
game_version: props.instance.game_version,
|
||||
source: 'InstanceItem',
|
||||
})
|
||||
})
|
||||
emit('play')
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
const stop = async (event: MouseEvent) => {
|
||||
event?.stopPropagation()
|
||||
loading.value = true
|
||||
await kill(props.instance.path).catch(handleError)
|
||||
trackEvent('InstanceStop', {
|
||||
loader: props.instance.loader,
|
||||
game_version: props.instance.game_version,
|
||||
source: 'InstanceItem',
|
||||
})
|
||||
emit('stop')
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
const unlistenProcesses = await process_listener(async () => {
|
||||
await checkProcess()
|
||||
})
|
||||
|
||||
const checkProcess = async () => {
|
||||
const runningProcesses = await get_by_profile_path(props.instance.path).catch(handleError)
|
||||
|
||||
playing.value = runningProcesses.length > 0
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkProcess()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
unlistenProcesses()
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<SmartClickable>
|
||||
<template #clickable>
|
||||
<router-link
|
||||
class="no-click-animation"
|
||||
:to="`/instance/${encodeURIComponent(instance.path)}`"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
class="grid grid-cols-[auto_minmax(0,3fr)_minmax(0,4fr)_auto] items-center gap-2 p-3 bg-bg-raised rounded-xl smart-clickable:highlight-on-hover"
|
||||
>
|
||||
<Avatar
|
||||
:src="instanceIcon ? convertFileSrc(instanceIcon) : undefined"
|
||||
:tint-by="instance.path"
|
||||
size="48px"
|
||||
/>
|
||||
<div class="flex flex-col col-span-2 justify-between h-full">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="text-lg text-contrast font-bold truncate smart-clickable:underline-on-hover">
|
||||
{{ instance.name }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm text-secondary">
|
||||
<div
|
||||
v-tooltip="
|
||||
instance.last_played
|
||||
? dayjs(instance.last_played).format('MMMM D, YYYY [at] h:mm A')
|
||||
: null
|
||||
"
|
||||
class="w-fit shrink-0"
|
||||
:class="{ 'cursor-help smart-clickable:allow-pointer-events': instance.last_played }"
|
||||
>
|
||||
<template v-if="instance.last_played">
|
||||
{{
|
||||
formatMessage(commonMessages.playedLabel, {
|
||||
time: formatRelativeTime(instance.last_played.toISOString()),
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
<template v-else> Not played yet </template>
|
||||
</div>
|
||||
•
|
||||
<span v-if="modpack" class="flex items-center gap-1 truncate text-secondary">
|
||||
<router-link
|
||||
class="inline-flex items-center gap-1 truncate hover:underline text-secondary smart-clickable:allow-pointer-events"
|
||||
:to="`/project/${modpack.id}`"
|
||||
>
|
||||
<Avatar :src="modpack.icon_url" size="16px" class="shrink-0" />
|
||||
<span class="truncate">{{ modpack.title }}</span>
|
||||
</router-link>
|
||||
({{ loader }} {{ instance.game_version }})
|
||||
</span>
|
||||
<span v-else-if="loadingModpack" class="flex items-center gap-1 truncate text-secondary">
|
||||
<SpinnerIcon class="animate-spin shrink-0" />
|
||||
<span class="truncate">Loading modpack...</span>
|
||||
</span>
|
||||
<span v-else class="flex items-center gap-1 truncate text-secondary">
|
||||
{{ loader }}
|
||||
{{ instance.game_version }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-1 justify-end smart-clickable:allow-pointer-events">
|
||||
<ButtonStyled v-if="playing && !loading" color="red">
|
||||
<button @click="stop">
|
||||
<StopCircleIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.stopButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else>
|
||||
<button
|
||||
v-tooltip="playing ? 'Instance is already open' : null"
|
||||
:disabled="playing || loading"
|
||||
@click="play"
|
||||
>
|
||||
<SpinnerIcon v-if="loading" class="animate-spin" />
|
||||
<PlayIcon v-else aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.playButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular type="transparent">
|
||||
<OverflowMenu
|
||||
:options="[
|
||||
{
|
||||
id: 'open-instance',
|
||||
shown: !!instance.path,
|
||||
action: () => router.push(encodeURI(`/instance/${instance.path}`)),
|
||||
},
|
||||
{
|
||||
id: 'open-folder',
|
||||
action: () => showProfileInFolder(instance.path),
|
||||
},
|
||||
]"
|
||||
>
|
||||
<MoreVerticalIcon aria-hidden="true" />
|
||||
<template #open-instance>
|
||||
<EyeIcon aria-hidden="true" />
|
||||
View instance
|
||||
</template>
|
||||
<template #open-folder>
|
||||
<FolderOpenIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.openFolderButton) }}
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</SmartClickable>
|
||||
</template>
|
||||
304
apps/app-frontend/src/components/ui/world/RecentWorldsList.vue
Normal file
@@ -0,0 +1,304 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
type ServerWorld,
|
||||
type ServerData,
|
||||
type WorldWithProfile,
|
||||
get_recent_worlds,
|
||||
getWorldIdentifier,
|
||||
get_profile_protocol_version,
|
||||
refreshServerData,
|
||||
start_join_server,
|
||||
start_join_singleplayer_world,
|
||||
} from '@/helpers/worlds.ts'
|
||||
import { HeadingLink, GAME_MODES } from '@modrinth/ui'
|
||||
import WorldItem from '@/components/ui/world/WorldItem.vue'
|
||||
import InstanceItem from '@/components/ui/world/InstanceItem.vue'
|
||||
import { watch, onMounted, onUnmounted, ref, computed } from 'vue'
|
||||
import type { Dayjs } from 'dayjs'
|
||||
import dayjs from 'dayjs'
|
||||
import { useTheming } from '@/store/theme.ts'
|
||||
import { kill, run } from '@/helpers/profile'
|
||||
import { handleError } from '@/store/notifications'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { process_listener, profile_listener } from '@/helpers/events'
|
||||
import { get_all } from '@/helpers/process'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { handleSevereError } from '@/store/error'
|
||||
|
||||
const props = defineProps<{
|
||||
recentInstances: GameInstance[]
|
||||
}>()
|
||||
|
||||
const theme = useTheming()
|
||||
|
||||
const jumpBackInItems = ref<JumpBackInItem[]>([])
|
||||
const serverData = ref<Record<string, ServerData>>({})
|
||||
const protocolVersions = ref<Record<string, number | null>>({})
|
||||
|
||||
const MIN_JUMP_BACK_IN = 3
|
||||
const MAX_JUMP_BACK_IN = 6
|
||||
const TWO_WEEKS_AGO = dayjs().subtract(14, 'day')
|
||||
|
||||
type BaseJumpBackInItem = {
|
||||
last_played: Dayjs
|
||||
instance: GameInstance
|
||||
}
|
||||
|
||||
type InstanceJumpBackInItem = BaseJumpBackInItem & {
|
||||
type: 'instance'
|
||||
}
|
||||
|
||||
type WorldJumpBackInItem = BaseJumpBackInItem & {
|
||||
type: 'world'
|
||||
world: WorldWithProfile
|
||||
}
|
||||
|
||||
type JumpBackInItem = InstanceJumpBackInItem | WorldJumpBackInItem
|
||||
|
||||
const showWorlds = computed(() => theme.getFeatureFlag('worlds_in_home'))
|
||||
|
||||
watch([() => props.recentInstances, () => showWorlds.value], async () => {
|
||||
await populateJumpBackIn().catch(() => {
|
||||
console.error('Failed to populate jump back in')
|
||||
})
|
||||
})
|
||||
|
||||
await populateJumpBackIn().catch(() => {
|
||||
console.error('Failed to populate jump back in')
|
||||
})
|
||||
|
||||
async function populateJumpBackIn() {
|
||||
console.info('Repopulating jump back in...')
|
||||
|
||||
const worldItems: WorldJumpBackInItem[] = []
|
||||
|
||||
if (showWorlds.value) {
|
||||
const worlds = await get_recent_worlds(MAX_JUMP_BACK_IN, ['normal', 'favorite'])
|
||||
|
||||
worlds.forEach((world) => {
|
||||
const instance = props.recentInstances.find((instance) => instance.path === world.profile)
|
||||
|
||||
if (!instance || !world.last_played) {
|
||||
return
|
||||
}
|
||||
|
||||
worldItems.push({
|
||||
type: 'world',
|
||||
last_played: dayjs(world.last_played),
|
||||
world: world,
|
||||
instance: instance,
|
||||
})
|
||||
})
|
||||
|
||||
const servers: {
|
||||
instancePath: string
|
||||
address: string
|
||||
}[] = worldItems
|
||||
.filter((item) => item.world.type === 'server' && item.instance)
|
||||
.map((item) => ({
|
||||
instancePath: item.instance.path,
|
||||
address: (item.world as ServerWorld).address,
|
||||
}))
|
||||
|
||||
// fetch protocol versions for all unique MC versions with server worlds
|
||||
const uniqueServerInstances = new Set<string>(servers.map((x) => x.instancePath))
|
||||
await Promise.all(
|
||||
[...uniqueServerInstances].map((path) =>
|
||||
get_profile_protocol_version(path)
|
||||
.then((protoVer) => (protocolVersions.value[path] = protoVer))
|
||||
.catch(() => {
|
||||
console.error(`Failed to get profile protocol for: ${path} `)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
// initialize server data
|
||||
servers.forEach(({ address }) => {
|
||||
if (!serverData.value[address]) {
|
||||
serverData.value[address] = {
|
||||
refreshing: true,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// fetch each server's data
|
||||
Promise.all(
|
||||
servers.map(({ instancePath, address }) =>
|
||||
refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const instanceItems: InstanceJumpBackInItem[] = []
|
||||
for (const instance of props.recentInstances) {
|
||||
const worldItem = worldItems.find((item) => item.instance.path === instance.path)
|
||||
if ((worldItem && worldItem.last_played.isAfter(TWO_WEEKS_AGO)) || !instance.last_played) {
|
||||
continue
|
||||
}
|
||||
|
||||
instanceItems.push({
|
||||
type: 'instance',
|
||||
last_played: dayjs(instance.last_played),
|
||||
instance: instance,
|
||||
})
|
||||
}
|
||||
|
||||
const items: JumpBackInItem[] = [...worldItems, ...instanceItems]
|
||||
items.sort((a, b) => dayjs(b.last_played).diff(dayjs(a.last_played)))
|
||||
jumpBackInItems.value = items
|
||||
.filter((item, index) => index < MIN_JUMP_BACK_IN || item.last_played.isAfter(TWO_WEEKS_AGO))
|
||||
.slice(0, MAX_JUMP_BACK_IN)
|
||||
}
|
||||
|
||||
async function refreshServer(address: string, instancePath: string) {
|
||||
await refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address)
|
||||
}
|
||||
|
||||
async function joinWorld(world: WorldWithProfile) {
|
||||
console.log(`Joining world ${getWorldIdentifier(world)}`)
|
||||
if (world.type === 'server') {
|
||||
await start_join_server(world.profile, world.address).catch(handleError)
|
||||
} else if (world.type === 'singleplayer') {
|
||||
await start_join_singleplayer_world(world.profile, world.path).catch(handleError)
|
||||
}
|
||||
}
|
||||
|
||||
async function playInstance(instance: GameInstance) {
|
||||
await run(instance.path)
|
||||
.catch((err) => handleSevereError(err, { profilePath: instance.path }))
|
||||
.finally(() => {
|
||||
trackEvent('InstancePlay', {
|
||||
loader: instance.loader,
|
||||
game_version: instance.game_version,
|
||||
source: 'WorldItem',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function stopInstance(path: string) {
|
||||
await kill(path).catch(handleError)
|
||||
trackEvent('InstanceStop', {
|
||||
source: 'RecentWorldsList',
|
||||
})
|
||||
}
|
||||
|
||||
const currentProfile = ref<string>()
|
||||
const currentWorld = ref<string>()
|
||||
|
||||
const unlistenProcesses = await process_listener(async () => {
|
||||
await checkProcesses()
|
||||
})
|
||||
|
||||
const unlistenProfiles = await profile_listener(async () => {
|
||||
await populateJumpBackIn().catch(() => {
|
||||
console.error('Failed to populate jump back in')
|
||||
})
|
||||
})
|
||||
|
||||
const runningInstances = ref<string[]>([])
|
||||
|
||||
type ProcessMetadata = {
|
||||
uuid: string
|
||||
profile_path: string
|
||||
start_time: string
|
||||
}
|
||||
|
||||
const checkProcesses = async () => {
|
||||
const runningProcesses: ProcessMetadata[] = await get_all().catch(handleError)
|
||||
|
||||
const runningPaths = runningProcesses.map((x) => x.profile_path)
|
||||
|
||||
const stoppedInstances = runningInstances.value.filter((x) => !runningPaths.includes(x))
|
||||
if (currentProfile.value && stoppedInstances.includes(currentProfile.value)) {
|
||||
currentProfile.value = undefined
|
||||
currentWorld.value = undefined
|
||||
}
|
||||
|
||||
runningInstances.value = runningPaths
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkProcesses()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
unlistenProcesses()
|
||||
unlistenProfiles()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="jumpBackInItems.length > 0" class="flex flex-col gap-2">
|
||||
<HeadingLink v-if="theme.getFeatureFlag('worlds_tab')" to="/worlds" class="mt-1">
|
||||
Jump back in
|
||||
</HeadingLink>
|
||||
<span
|
||||
v-else
|
||||
class="flex mt-1 mb-3 leading-none items-center gap-1 text-primary text-lg font-bold"
|
||||
>
|
||||
Jump back in
|
||||
</span>
|
||||
<div class="grid-when-huge flex flex-col w-full gap-2">
|
||||
<template
|
||||
v-for="item in jumpBackInItems"
|
||||
:key="`${item.instance.path}-${item.type === 'world' ? getWorldIdentifier(item.world) : 'instance'}`"
|
||||
>
|
||||
<WorldItem
|
||||
v-if="item.type === 'world'"
|
||||
:world="item.world"
|
||||
:playing-instance="runningInstances.includes(item.instance.path)"
|
||||
:playing-world="
|
||||
currentProfile === item.instance.path && currentWorld === getWorldIdentifier(item.world)
|
||||
"
|
||||
:refreshing="
|
||||
item.world.type === 'server'
|
||||
? serverData[item.world.address].refreshing && !serverData[item.world.address].status
|
||||
: undefined
|
||||
"
|
||||
supports-quick-play
|
||||
:server-status="
|
||||
item.world.type === 'server' ? serverData[item.world.address].status : undefined
|
||||
"
|
||||
:rendered-motd="
|
||||
item.world.type === 'server' ? serverData[item.world.address].renderedMotd : undefined
|
||||
"
|
||||
:current-protocol="protocolVersions[item.instance.path]"
|
||||
:game-mode="
|
||||
item.world.type === 'singleplayer' ? GAME_MODES[item.world.game_mode] : undefined
|
||||
"
|
||||
:instance-path="item.instance.path"
|
||||
:instance-name="item.instance.name"
|
||||
:instance-icon="item.instance.icon_path"
|
||||
@refresh="
|
||||
() =>
|
||||
item.world.type === 'server'
|
||||
? refreshServer(item.world.address, item.instance.path)
|
||||
: {}
|
||||
"
|
||||
@update="() => populateJumpBackIn()"
|
||||
@play="
|
||||
() => {
|
||||
currentProfile = item.instance.path
|
||||
currentWorld = getWorldIdentifier(item.world)
|
||||
joinWorld(item.world)
|
||||
}
|
||||
"
|
||||
@play-instance="
|
||||
() => {
|
||||
currentProfile = item.instance.path
|
||||
playInstance(item.instance)
|
||||
}
|
||||
"
|
||||
@stop="() => stopInstance(item.instance.path)"
|
||||
/>
|
||||
<InstanceItem v-else :instance="item.instance" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped lang="scss">
|
||||
.grid-when-huge {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(670px, 1fr));
|
||||
}
|
||||
</style>
|
||||
506
apps/app-frontend/src/components/ui/world/WorldItem.vue
Normal file
@@ -0,0 +1,506 @@
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs'
|
||||
import type { ServerStatus, ServerWorld, SingleplayerWorld, World } from '@/helpers/worlds.ts'
|
||||
import { set_world_display_status, getWorldIdentifier } from '@/helpers/worlds.ts'
|
||||
import { formatNumber, getPingLevel } from '@modrinth/utils'
|
||||
import {
|
||||
useRelativeTime,
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
OverflowMenu,
|
||||
SmartClickable,
|
||||
} from '@modrinth/ui'
|
||||
import {
|
||||
IssuesIcon,
|
||||
EyeIcon,
|
||||
ClipboardCopyIcon,
|
||||
EditIcon,
|
||||
FolderOpenIcon,
|
||||
MoreVerticalIcon,
|
||||
NoSignalIcon,
|
||||
PlayIcon,
|
||||
SignalIcon,
|
||||
SkullIcon,
|
||||
SpinnerIcon,
|
||||
StopCircleIcon,
|
||||
TrashIcon,
|
||||
UpdatedIcon,
|
||||
UserIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import type { MessageDescriptor } from '@vintl/vintl'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import type { Component } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { copyToClipboard } from '@/helpers/utils'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Tooltip } from 'floating-vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'play' | 'play-instance' | 'update' | 'stop' | 'refresh' | 'edit' | 'delete'): void
|
||||
(e: 'open-folder', world: SingleplayerWorld): void
|
||||
}>()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
world: World
|
||||
playingInstance?: boolean
|
||||
playingWorld?: boolean
|
||||
startingInstance?: boolean
|
||||
supportsQuickPlay?: boolean
|
||||
currentProtocol?: number | null
|
||||
highlighted?: boolean
|
||||
|
||||
// Server only
|
||||
refreshing?: boolean
|
||||
serverStatus?: ServerStatus
|
||||
renderedMotd?: string
|
||||
|
||||
// Singleplayer only
|
||||
gameMode?: {
|
||||
icon: Component
|
||||
message: MessageDescriptor
|
||||
}
|
||||
|
||||
// Instance
|
||||
instancePath?: string
|
||||
instanceName?: string
|
||||
instanceIcon?: string
|
||||
}>(),
|
||||
{
|
||||
playingInstance: false,
|
||||
playingWorld: false,
|
||||
startingInstance: false,
|
||||
supportsQuickPlay: false,
|
||||
currentProtocol: null,
|
||||
|
||||
refreshing: false,
|
||||
serverStatus: undefined,
|
||||
renderedMotd: undefined,
|
||||
|
||||
gameMode: undefined,
|
||||
|
||||
instancePath: undefined,
|
||||
instanceName: undefined,
|
||||
instanceIcon: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const playingOtherWorld = computed(() => props.playingInstance && !props.playingWorld)
|
||||
const hasPlayersTooltip = computed(
|
||||
() => !!props.serverStatus?.players?.sample && props.serverStatus.players?.sample?.length > 0,
|
||||
)
|
||||
const serverIncompatible = computed(
|
||||
() =>
|
||||
!!props.serverStatus &&
|
||||
!!props.serverStatus.version?.protocol &&
|
||||
!!props.currentProtocol &&
|
||||
props.serverStatus.version.protocol !== props.currentProtocol,
|
||||
)
|
||||
|
||||
const locked = computed(() => props.world.type === 'singleplayer' && props.world.locked)
|
||||
|
||||
const messages = defineMessages({
|
||||
hardcore: {
|
||||
id: 'instance.worlds.hardcore',
|
||||
defaultMessage: 'Hardcore mode',
|
||||
},
|
||||
cantConnect: {
|
||||
id: 'instance.worlds.cant_connect',
|
||||
defaultMessage: "Can't connect to server",
|
||||
},
|
||||
aMinecraftServer: {
|
||||
id: 'instance.worlds.a_minecraft_server',
|
||||
defaultMessage: 'A Minecraft Server',
|
||||
},
|
||||
noQuickPlay: {
|
||||
id: 'instance.worlds.no_quick_play',
|
||||
defaultMessage: 'You can only jump straight into worlds on Minecraft 1.20+',
|
||||
},
|
||||
gameAlreadyOpen: {
|
||||
id: 'instance.worlds.game_already_open',
|
||||
defaultMessage: 'Instance is already open',
|
||||
},
|
||||
copyAddress: {
|
||||
id: 'instance.worlds.copy_address',
|
||||
defaultMessage: 'Copy address',
|
||||
},
|
||||
viewInstance: {
|
||||
id: 'instance.worlds.view_instance',
|
||||
defaultMessage: 'View instance',
|
||||
},
|
||||
playAnyway: {
|
||||
id: 'instance.worlds.play_anyway',
|
||||
defaultMessage: 'Play anyway',
|
||||
},
|
||||
playInstance: {
|
||||
id: 'instance.worlds.play_instance',
|
||||
defaultMessage: 'Play instance',
|
||||
},
|
||||
worldInUse: {
|
||||
id: 'instance.worlds.world_in_use',
|
||||
defaultMessage: 'World is in use',
|
||||
},
|
||||
dontShowOnHome: {
|
||||
id: 'instance.worlds.dont_show_on_home',
|
||||
defaultMessage: `Don't show on Home`,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<SmartClickable>
|
||||
<template v-if="instancePath" #clickable>
|
||||
<router-link
|
||||
class="no-click-animation"
|
||||
:to="`/instance/${encodeURIComponent(instancePath)}/worlds?highlight=${encodeURIComponent(getWorldIdentifier(world))}`"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
class="grid grid-cols-[auto_minmax(0,3fr)_minmax(0,4fr)_auto] items-center gap-2 p-3 bg-bg-raised smart-clickable:highlight-on-hover rounded-xl"
|
||||
:class="{
|
||||
'world-item-highlighted': highlighted,
|
||||
}"
|
||||
>
|
||||
<Avatar
|
||||
:src="
|
||||
world.type === 'server' && serverStatus ? serverStatus.favicon ?? world.icon : world.icon
|
||||
"
|
||||
size="48px"
|
||||
/>
|
||||
<div class="flex flex-col justify-between h-full">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="text-lg text-contrast font-bold truncate smart-clickable:underline-on-hover">
|
||||
{{ world.name }}
|
||||
</div>
|
||||
<div
|
||||
v-if="world.type === 'singleplayer'"
|
||||
class="text-sm text-secondary flex items-center gap-1 font-semibold"
|
||||
>
|
||||
<UserIcon
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 text-secondary shrink-0"
|
||||
stroke-width="3px"
|
||||
/>
|
||||
{{ formatMessage(commonMessages.singleplayerLabel) }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="world.type === 'server'"
|
||||
class="text-sm text-secondary flex items-center gap-1 font-semibold flex-nowrap whitespace-nowrap"
|
||||
>
|
||||
<template v-if="refreshing">
|
||||
<SpinnerIcon aria-hidden="true" class="animate-spin shrink-0" />
|
||||
Loading...
|
||||
</template>
|
||||
<template v-else-if="serverStatus">
|
||||
<template v-if="serverIncompatible">
|
||||
<IssuesIcon class="shrink-0 text-orange" aria-hidden="true" />
|
||||
<span class="text-orange">
|
||||
Incompatible version {{ serverStatus.version?.name }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<SignalIcon
|
||||
v-tooltip="serverStatus ? `${serverStatus.ping}ms` : null"
|
||||
aria-hidden="true"
|
||||
:style="`--_signal-${getPingLevel(serverStatus.ping || 0)}: var(--color-green)`"
|
||||
stroke-width="3px"
|
||||
class="shrink-0"
|
||||
:class="{
|
||||
'smart-clickable:allow-pointer-events': serverStatus,
|
||||
}"
|
||||
/>
|
||||
<Tooltip :disabled="!hasPlayersTooltip">
|
||||
<span :class="{ 'cursor-help': hasPlayersTooltip }">
|
||||
{{ formatNumber(serverStatus.players?.online, false) }} online
|
||||
</span>
|
||||
<template #popper>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span v-for="player in serverStatus.players?.sample" :key="player.name">
|
||||
{{ player.name }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</Tooltip>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<NoSignalIcon aria-hidden="true" stroke-width="3px" class="shrink-0" /> Offline
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm text-secondary">
|
||||
<div
|
||||
v-tooltip="
|
||||
world.last_played ? dayjs(world.last_played).format('MMMM D, YYYY [at] h:mm A') : null
|
||||
"
|
||||
class="w-fit shrink-0"
|
||||
:class="{ 'cursor-help smart-clickable:allow-pointer-events': world.last_played }"
|
||||
>
|
||||
<template v-if="world.last_played">
|
||||
{{
|
||||
formatMessage(commonMessages.playedLabel, {
|
||||
time: formatRelativeTime(dayjs(world.last_played).toISOString()),
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
<template v-else> Not played yet </template>
|
||||
</div>
|
||||
<template v-if="instancePath">
|
||||
•
|
||||
<router-link
|
||||
class="flex items-center gap-1 truncate hover:underline text-secondary smart-clickable:allow-pointer-events"
|
||||
:to="`/instance/${instancePath}`"
|
||||
>
|
||||
<Avatar
|
||||
:src="instanceIcon ? convertFileSrc(instanceIcon) : undefined"
|
||||
size="16px"
|
||||
:tint-by="instancePath"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<span class="truncate">{{ instanceName }}</span>
|
||||
</router-link>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="font-semibold flex items-center gap-1 justify-center text-center"
|
||||
:class="world.type === 'singleplayer' && world.hardcore ? `text-red` : 'text-secondary'"
|
||||
>
|
||||
<template v-if="world.type === 'server'">
|
||||
<template v-if="refreshing">
|
||||
<SpinnerIcon aria-hidden="true" class="animate-spin" />
|
||||
{{ formatMessage(commonMessages.loadingLabel) }}
|
||||
</template>
|
||||
<div
|
||||
v-else-if="renderedMotd"
|
||||
class="motd-renderer font-normal font-minecraft line-clamp-2 text-secondary leading-5"
|
||||
v-html="renderedMotd"
|
||||
/>
|
||||
<div v-else-if="!serverStatus" class="font-normal font-minecraft text-red leading-5">
|
||||
{{ formatMessage(messages.cantConnect) }}
|
||||
</div>
|
||||
<div v-else class="font-normal font-minecraft text-secondary leading-5">
|
||||
{{ formatMessage(messages.aMinecraftServer) }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="world.type === 'singleplayer' && gameMode">
|
||||
<template v-if="world.hardcore">
|
||||
<SkullIcon aria-hidden="true" class="h-4 w-4 shrink-0" />
|
||||
{{ formatMessage(messages.hardcore) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<component :is="gameMode.icon" aria-hidden="true" class="h-4 w-4 shrink-0" />
|
||||
{{ formatMessage(gameMode.message) }}
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex gap-1 justify-end smart-clickable:allow-pointer-events">
|
||||
<template v-if="world.type === 'singleplayer' || serverStatus">
|
||||
<ButtonStyled
|
||||
v-if="(playingWorld || (locked && playingInstance)) && !startingInstance"
|
||||
color="red"
|
||||
>
|
||||
<button @click="emit('stop')">
|
||||
<StopCircleIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.stopButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else>
|
||||
<button
|
||||
v-tooltip="
|
||||
serverIncompatible
|
||||
? 'Server is incompatible'
|
||||
: !supportsQuickPlay
|
||||
? formatMessage(messages.noQuickPlay)
|
||||
: playingOtherWorld || locked
|
||||
? formatMessage(messages.gameAlreadyOpen)
|
||||
: null
|
||||
"
|
||||
:disabled="!supportsQuickPlay || playingOtherWorld || startingInstance"
|
||||
@click="emit('play')"
|
||||
>
|
||||
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
|
||||
<PlayIcon v-else aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.playButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<ButtonStyled v-else>
|
||||
<button class="invisible">
|
||||
<PlayIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.playButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular type="transparent">
|
||||
<OverflowMenu
|
||||
:options="[
|
||||
{
|
||||
id: 'play-instance',
|
||||
shown: !!instancePath,
|
||||
disabled: playingInstance,
|
||||
action: () => emit('play-instance'),
|
||||
},
|
||||
{
|
||||
id: 'play-anyway',
|
||||
shown: serverIncompatible && !playingInstance && supportsQuickPlay,
|
||||
action: () => emit('play'),
|
||||
},
|
||||
{
|
||||
id: 'open-instance',
|
||||
shown: !!instancePath,
|
||||
action: () => router.push(encodeURI(`/instance/${instancePath}`)),
|
||||
},
|
||||
{
|
||||
id: 'refresh',
|
||||
shown: world.type === 'server',
|
||||
action: () => emit('refresh'),
|
||||
},
|
||||
{
|
||||
id: 'copy-address',
|
||||
shown: world.type === 'server',
|
||||
action: () => copyToClipboard((world as ServerWorld).address),
|
||||
},
|
||||
{
|
||||
id: 'edit',
|
||||
action: () => emit('edit'),
|
||||
shown: !instancePath,
|
||||
disabled: locked,
|
||||
tooltip: locked ? formatMessage(messages.worldInUse) : undefined,
|
||||
},
|
||||
{
|
||||
id: 'open-folder',
|
||||
shown: world.type === 'singleplayer',
|
||||
action: () => (world.type === 'singleplayer' ? emit('open-folder', world) : {}),
|
||||
},
|
||||
{
|
||||
divider: true,
|
||||
shown: !!instancePath,
|
||||
},
|
||||
{
|
||||
id: 'dont-show-on-home',
|
||||
shown: !!instancePath,
|
||||
action: () => {
|
||||
set_world_display_status(
|
||||
instancePath,
|
||||
world.type,
|
||||
getWorldIdentifier(world),
|
||||
'hidden',
|
||||
).then(() => {
|
||||
emit('update')
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
divider: true,
|
||||
shown: !instancePath,
|
||||
},
|
||||
{
|
||||
id: 'delete',
|
||||
color: 'red',
|
||||
hoverFilled: true,
|
||||
action: () => emit('delete'),
|
||||
shown: !instancePath,
|
||||
disabled: locked,
|
||||
tooltip: locked ? formatMessage(messages.worldInUse) : undefined,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<MoreVerticalIcon aria-hidden="true" />
|
||||
<template #play-instance>
|
||||
<PlayIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.playInstance) }}
|
||||
</template>
|
||||
<template #play-anyway>
|
||||
<PlayIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.playAnyway) }}
|
||||
</template>
|
||||
<template #open-instance>
|
||||
<EyeIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.viewInstance) }}
|
||||
</template>
|
||||
<template #edit>
|
||||
<EditIcon aria-hidden="true" /> {{ formatMessage(commonMessages.editButton) }}
|
||||
</template>
|
||||
<template #open-folder>
|
||||
<FolderOpenIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.openFolderButton) }}
|
||||
</template>
|
||||
<template #copy-address>
|
||||
<ClipboardCopyIcon aria-hidden="true" /> {{ formatMessage(messages.copyAddress) }}
|
||||
</template>
|
||||
<template #refresh>
|
||||
<UpdatedIcon aria-hidden="true" /> {{ formatMessage(commonMessages.refreshButton) }}
|
||||
</template>
|
||||
<template #dont-show-on-home>
|
||||
<XIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.dontShowOnHome) }}
|
||||
</template>
|
||||
<template #delete>
|
||||
<TrashIcon aria-hidden="true" />
|
||||
{{
|
||||
formatMessage(
|
||||
world.type === 'server'
|
||||
? commonMessages.removeButton
|
||||
: commonMessages.deleteLabel,
|
||||
)
|
||||
}}
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</SmartClickable>
|
||||
</template>
|
||||
<style scoped lang="scss">
|
||||
.world-item-highlighted {
|
||||
position: relative;
|
||||
animation: fade-highlight 4s ease-out;
|
||||
filter: brightness(1);
|
||||
|
||||
&::before {
|
||||
@apply rounded-xl inset-0 absolute;
|
||||
|
||||
animation: fade-opacity 4s ease-out;
|
||||
|
||||
content: '';
|
||||
box-shadow: 0 0 8px 2px var(--color-brand);
|
||||
border: 1.5px solid var(--color-brand);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-highlight {
|
||||
0% {
|
||||
filter: brightness(1.25);
|
||||
}
|
||||
75% {
|
||||
filter: brightness(1.25);
|
||||
}
|
||||
100% {
|
||||
filter: brightness(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-opacity {
|
||||
0% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
75% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.light-mode .motd-renderer {
|
||||
filter: brightness(0.75);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { PlayIcon, PlusIcon, XIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, commonMessages } from '@modrinth/ui'
|
||||
import { ref } from 'vue'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import InstanceModalTitlePrefix from '@/components/ui/modal/InstanceModalTitlePrefix.vue'
|
||||
import { add_server_to_profile, type ServerPackStatus, type ServerWorld } from '@/helpers/worlds.ts'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import { handleError } from '@/store/notifications'
|
||||
import ServerModalBody from '@/components/ui/world/modal/ServerModalBody.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [server: ServerWorld, play: boolean]
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
instance: GameInstance
|
||||
}>()
|
||||
|
||||
const modal = ref()
|
||||
|
||||
const name = ref()
|
||||
const address = ref()
|
||||
const resourcePack = ref<ServerPackStatus>('enabled')
|
||||
|
||||
async function addServer(play: boolean) {
|
||||
const serverName = name.value ? name.value : address.value
|
||||
const resourcePackStatus = resourcePack.value
|
||||
const index =
|
||||
(await add_server_to_profile(
|
||||
props.instance.path,
|
||||
serverName,
|
||||
address.value,
|
||||
resourcePackStatus,
|
||||
).catch(handleError)) ?? 0
|
||||
emit(
|
||||
'submit',
|
||||
{
|
||||
name: serverName,
|
||||
type: 'server',
|
||||
index,
|
||||
address: address.value,
|
||||
pack_status: resourcePackStatus,
|
||||
},
|
||||
play,
|
||||
)
|
||||
hide()
|
||||
}
|
||||
|
||||
function show() {
|
||||
name.value = ''
|
||||
address.value = ''
|
||||
resourcePack.value = 'enabled'
|
||||
modal.value.show()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value.hide()
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'instance.add-server.title',
|
||||
defaultMessage: 'Add a server',
|
||||
},
|
||||
addServer: {
|
||||
id: 'instance.add-server.add-server',
|
||||
defaultMessage: 'Add server',
|
||||
},
|
||||
addAndPlay: {
|
||||
id: 'instance.add-server.add-and-play',
|
||||
defaultMessage: 'Add and play',
|
||||
},
|
||||
})
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
<template>
|
||||
<ModalWrapper ref="modal">
|
||||
<template #title>
|
||||
<span class="flex items-center gap-2 text-lg font-semibold text-primary">
|
||||
<InstanceModalTitlePrefix :instance="instance" />
|
||||
<span class="font-extrabold text-contrast">{{ formatMessage(messages.title) }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<ServerModalBody
|
||||
v-model:name="name"
|
||||
v-model:address="address"
|
||||
v-model:resource-pack="resourcePack"
|
||||
/>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!address" @click="addServer(true)">
|
||||
<PlayIcon />
|
||||
{{ formatMessage(messages.addAndPlay) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button :disabled="!address" @click="addServer(false)">
|
||||
<PlusIcon />
|
||||
{{ formatMessage(messages.addServer) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
@@ -0,0 +1,118 @@
|
||||
<script setup lang="ts">
|
||||
import { SaveIcon, XIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, commonMessages } from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import {
|
||||
type ServerPackStatus,
|
||||
edit_server_in_profile,
|
||||
type ServerWorld,
|
||||
set_world_display_status,
|
||||
type DisplayStatus,
|
||||
} from '@/helpers/worlds.ts'
|
||||
import { defineMessage, useVIntl } from '@vintl/vintl'
|
||||
import { handleError } from '@/store/notifications'
|
||||
import ServerModalBody from '@/components/ui/world/modal/ServerModalBody.vue'
|
||||
import HideFromHomeOption from '@/components/ui/world/modal/HideFromHomeOption.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [server: ServerWorld]
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
instance: GameInstance
|
||||
}>()
|
||||
|
||||
const modal = ref()
|
||||
|
||||
const name = ref<string>('')
|
||||
const address = ref<string>('')
|
||||
const resourcePack = ref<ServerPackStatus>('enabled')
|
||||
const index = ref<number>(0)
|
||||
const displayStatus = ref<DisplayStatus>('normal')
|
||||
const hideFromHome = ref(false)
|
||||
|
||||
const newDisplayStatus = computed(() => (hideFromHome.value ? 'hidden' : 'normal'))
|
||||
|
||||
async function saveServer() {
|
||||
const serverName = name.value ? name.value : address.value
|
||||
const resourcePackStatus = resourcePack.value
|
||||
await edit_server_in_profile(
|
||||
props.instance.path,
|
||||
index.value,
|
||||
serverName,
|
||||
address.value,
|
||||
resourcePackStatus,
|
||||
).catch(handleError)
|
||||
|
||||
if (newDisplayStatus.value !== displayStatus.value) {
|
||||
await set_world_display_status(
|
||||
props.instance.path,
|
||||
'server',
|
||||
address.value,
|
||||
newDisplayStatus.value,
|
||||
).catch(handleError)
|
||||
}
|
||||
|
||||
emit('submit', {
|
||||
name: serverName,
|
||||
type: 'server',
|
||||
index: index.value,
|
||||
address: address.value,
|
||||
pack_status: resourcePackStatus,
|
||||
display_status: newDisplayStatus.value,
|
||||
})
|
||||
hide()
|
||||
}
|
||||
|
||||
function show(server: ServerWorld) {
|
||||
name.value = server.name
|
||||
address.value = server.address
|
||||
resourcePack.value = server.pack_status
|
||||
index.value = server.index
|
||||
displayStatus.value = server.display_status
|
||||
hideFromHome.value = server.display_status === 'hidden'
|
||||
modal.value.show()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
|
||||
const titleMessage = defineMessage({
|
||||
id: 'instance.edit-server.title',
|
||||
defaultMessage: 'Edit server',
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<ModalWrapper ref="modal">
|
||||
<template #title>
|
||||
<span class="font-extrabold text-lg text-contrast">{{ formatMessage(titleMessage) }}</span>
|
||||
</template>
|
||||
<ServerModalBody
|
||||
v-model:name="name"
|
||||
v-model:address="address"
|
||||
v-model:resource-pack="resourcePack"
|
||||
/>
|
||||
<HideFromHomeOption v-model="hideFromHome" class="mt-3" />
|
||||
<div class="flex gap-2 mt-4">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!address" @click="saveServer">
|
||||
<SaveIcon />
|
||||
{{ formatMessage(commonMessages.saveChangesButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
@@ -0,0 +1,129 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronRightIcon, SaveIcon, XIcon, UndoIcon } from '@modrinth/assets'
|
||||
import { Avatar, ButtonStyled, commonMessages } from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import type { DisplayStatus, SingleplayerWorld } from '@/helpers/worlds.ts'
|
||||
import { set_world_display_status, rename_world, reset_world_icon } from '@/helpers/worlds.ts'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import { handleError } from '@/store/notifications'
|
||||
import HideFromHomeOption from '@/components/ui/world/modal/HideFromHomeOption.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [path: string, name: string, removeIcon: boolean, displayStatus: DisplayStatus]
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
instance: GameInstance
|
||||
}>()
|
||||
|
||||
const modal = ref()
|
||||
|
||||
const icon = ref()
|
||||
const name = ref()
|
||||
const path = ref()
|
||||
const removeIcon = ref(false)
|
||||
const displayStatus = ref<DisplayStatus>('normal')
|
||||
const hideFromHome = ref(false)
|
||||
|
||||
const newDisplayStatus = computed(() => (hideFromHome.value ? 'hidden' : 'normal'))
|
||||
|
||||
async function saveWorld() {
|
||||
await rename_world(props.instance.path, path.value, name.value).catch(handleError)
|
||||
|
||||
if (removeIcon.value) {
|
||||
await reset_world_icon(props.instance.path, path.value).catch(handleError)
|
||||
}
|
||||
if (newDisplayStatus.value !== displayStatus.value) {
|
||||
await set_world_display_status(
|
||||
props.instance.path,
|
||||
'singleplayer',
|
||||
path.value,
|
||||
newDisplayStatus.value,
|
||||
)
|
||||
}
|
||||
|
||||
emit('submit', path.value, name.value, removeIcon.value, newDisplayStatus.value)
|
||||
hide()
|
||||
}
|
||||
|
||||
function show(world: SingleplayerWorld) {
|
||||
name.value = world.name
|
||||
path.value = world.path
|
||||
icon.value = world.icon
|
||||
displayStatus.value = world.display_status
|
||||
hideFromHome.value = world.display_status === 'hidden'
|
||||
removeIcon.value = false
|
||||
modal.value.show()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'instance.edit-world.title',
|
||||
defaultMessage: 'Edit world',
|
||||
},
|
||||
name: {
|
||||
id: 'instance.edit-world.name',
|
||||
defaultMessage: 'Name',
|
||||
},
|
||||
placeholderName: {
|
||||
id: 'instance.edit-world.placeholder-name',
|
||||
defaultMessage: 'Minecraft World',
|
||||
},
|
||||
resetIcon: {
|
||||
id: 'instance.edit-world.reset-icon',
|
||||
defaultMessage: 'Reset icon',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<ModalWrapper ref="modal">
|
||||
<template #title>
|
||||
<Avatar :src="removeIcon || !icon ? undefined : icon" size="24px" />
|
||||
{{ instance.name }} <ChevronRightIcon />
|
||||
<span class="font-extrabold text-lg text-contrast">{{ formatMessage(messages.title) }}</span>
|
||||
</template>
|
||||
<div class="w-[450px]">
|
||||
<h2 class="text-lg font-extrabold text-contrast mt-0 mb-1">
|
||||
{{ formatMessage(messages.name) }}
|
||||
</h2>
|
||||
<input
|
||||
v-model="name"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.placeholderName)"
|
||||
class="w-full"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<HideFromHomeOption v-model="hideFromHome" class="mt-3" />
|
||||
</div>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="saveWorld">
|
||||
<SaveIcon />
|
||||
{{ formatMessage(commonMessages.saveChangesButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button :disabled="removeIcon || !icon" @click="removeIcon = true">
|
||||
<UndoIcon />
|
||||
{{ formatMessage(messages.resetIcon) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { defineMessage, useVIntl } from '@vintl/vintl'
|
||||
import { computed } from 'vue'
|
||||
import { Checkbox } from '@modrinth/ui'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const value = defineModel<boolean>({ required: true })
|
||||
|
||||
const labelMessage = defineMessage({
|
||||
id: 'instance.edit-world.hide-from-home',
|
||||
defaultMessage: `Hide from the Home page`,
|
||||
})
|
||||
|
||||
const label = computed(() => formatMessage(labelMessage))
|
||||
</script>
|
||||
<template>
|
||||
<Checkbox v-model="value" :label="label" />
|
||||
</template>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { TeleportDropdownMenu } from '@modrinth/ui'
|
||||
import type { ServerPackStatus } from '@/helpers/worlds.ts'
|
||||
import { type MessageDescriptor, defineMessages, useVIntl } from '@vintl/vintl'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const name = defineModel<string>('name')
|
||||
const address = defineModel<string>('address')
|
||||
const resourcePack = defineModel<ServerPackStatus>('resourcePack')
|
||||
|
||||
const resourcePackOptions: ServerPackStatus[] = ['enabled', 'prompt', 'disabled']
|
||||
|
||||
const resourcePackOptionMessages: Record<ServerPackStatus, MessageDescriptor> = defineMessages({
|
||||
enabled: {
|
||||
id: 'instance.add-server.resource-pack.enabled',
|
||||
defaultMessage: 'Enabled',
|
||||
},
|
||||
prompt: {
|
||||
id: 'instance.add-server.resource-pack.prompt',
|
||||
defaultMessage: 'Prompt',
|
||||
},
|
||||
disabled: {
|
||||
id: 'instance.add-server.resource-pack.disabled',
|
||||
defaultMessage: 'Disabled',
|
||||
},
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
name: {
|
||||
id: 'instance.server-modal.name',
|
||||
defaultMessage: 'Name',
|
||||
},
|
||||
address: {
|
||||
id: 'instance.server-modal.address',
|
||||
defaultMessage: 'Address',
|
||||
},
|
||||
resourcePack: {
|
||||
id: 'instance.server-modal.resource-pack',
|
||||
defaultMessage: 'Resource pack',
|
||||
},
|
||||
placeholderName: {
|
||||
id: 'instance.server-modal.placeholder-name',
|
||||
defaultMessage: 'Minecraft Server',
|
||||
},
|
||||
})
|
||||
|
||||
defineExpose({ resourcePackOptions })
|
||||
</script>
|
||||
<template>
|
||||
<div class="w-[450px]">
|
||||
<h2 class="text-lg font-extrabold text-contrast mt-0 mb-1">
|
||||
{{ formatMessage(messages.name) }}
|
||||
</h2>
|
||||
<input
|
||||
v-model="name"
|
||||
type="text"
|
||||
:placeholder="formatMessage(messages.placeholderName)"
|
||||
class="w-full"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<h2 class="text-lg font-extrabold text-contrast mt-3 mb-1">
|
||||
{{ formatMessage(messages.address) }}
|
||||
</h2>
|
||||
<input
|
||||
v-model="address"
|
||||
type="text"
|
||||
placeholder="example.modrinth.gg"
|
||||
class="w-full"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<h2 class="text-lg font-extrabold text-contrast mt-3 mb-1">
|
||||
{{ formatMessage(messages.resourcePack) }}
|
||||
</h2>
|
||||
<div>
|
||||
<TeleportDropdownMenu
|
||||
v-model="resourcePack"
|
||||
:options="resourcePackOptions"
|
||||
name="Server resource pack"
|
||||
:display-name="
|
||||
(option: ServerPackStatus) => formatMessage(resourcePackOptionMessages[option])
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,8 +1,9 @@
|
||||
import { posthog } from 'posthog-js'
|
||||
|
||||
export const initAnalytics = () => {
|
||||
posthog.init('phc_hm2ihMpTAoE86xIm7XzsCB8RPiTRKivViK5biiHedm', {
|
||||
posthog.init('phc_9Iqi6lFs9sr5BSqh9RRNRSJ0mATS9PSgirDiX3iOYJ', {
|
||||
persistence: 'localStorage',
|
||||
api_host: 'https://posthog.modrinth.com',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ export async function process_listener(callback) {
|
||||
ProfilePayload {
|
||||
uuid: unique identification of the process in the state (currently identified by path, but that will change)
|
||||
name: name of the profile
|
||||
profile_path: relative path to profile (used for path identification)
|
||||
profile_path: relative path toprofile_listener profile (used for path identification)
|
||||
path: path to profile (used for opening the profile in the OS file explorer)
|
||||
event: event type ("Created", "Added", "Edited", "Removed")
|
||||
}
|
||||
|
||||
@@ -7,7 +7,13 @@ import { invoke } from '@tauri-apps/api/core'
|
||||
import { create } from './profile'
|
||||
|
||||
// Installs pack from a version ID
|
||||
export async function create_profile_and_install(projectId, versionId, packTitle, iconUrl) {
|
||||
export async function create_profile_and_install(
|
||||
projectId,
|
||||
versionId,
|
||||
packTitle,
|
||||
iconUrl,
|
||||
createInstanceCallback = () => {},
|
||||
) {
|
||||
const location = {
|
||||
type: 'fromVersionId',
|
||||
project_id: projectId,
|
||||
@@ -24,6 +30,7 @@ export async function create_profile_and_install(projectId, versionId, packTitle
|
||||
null,
|
||||
true,
|
||||
)
|
||||
createInstanceCallback(profile)
|
||||
|
||||
return await invoke('plugin:pack|pack_install', { location, profile })
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
/**
|
||||
* All theseus API calls return serialized values (both return values and errors);
|
||||
* So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
|
||||
* and deserialized into a usable JS object.
|
||||
*/
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
// Settings object
|
||||
/*
|
||||
|
||||
Settings {
|
||||
"memory": MemorySettings,
|
||||
"game_resolution": [int int],
|
||||
"custom_java_args": [String ...],
|
||||
"custom_env_args" : [(string, string) ... ]>,
|
||||
"java_globals": Hash of (string, Path),
|
||||
"default_user": Uuid string (can be null),
|
||||
"hooks": Hooks,
|
||||
"max_concurrent_downloads": uint,
|
||||
"version": u32,
|
||||
"collapsed_navigation": bool,
|
||||
}
|
||||
|
||||
Memorysettings {
|
||||
"min": u32, can be null,
|
||||
"max": u32,
|
||||
}
|
||||
|
||||
*/
|
||||
|
||||
// Get full settings object
|
||||
export async function get() {
|
||||
return await invoke('plugin:settings|settings_get')
|
||||
}
|
||||
|
||||
// Set full settings object
|
||||
export async function set(settings) {
|
||||
return await invoke('plugin:settings|settings_set', { settings })
|
||||
}
|
||||
|
||||
export async function cancel_directory_change() {
|
||||
return await invoke('plugin:settings|cancel_directory_change')
|
||||
}
|
||||
78
apps/app-frontend/src/helpers/settings.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* All theseus API calls return serialized values (both return values and errors);
|
||||
* So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
|
||||
* and deserialized into a usable JS object.
|
||||
*/
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import type { ColorTheme, FeatureFlag } from '@/store/theme.ts'
|
||||
import type { Hooks, MemorySettings, WindowSize } from '@/helpers/types'
|
||||
|
||||
// Settings object
|
||||
/*
|
||||
|
||||
Settings {
|
||||
"memory": MemorySettings,
|
||||
"game_resolution": [int int],
|
||||
"custom_java_args": [String ...],
|
||||
"custom_env_args" : [(string, string) ... ]>,
|
||||
"java_globals": Hash of (string, Path),
|
||||
"default_user": Uuid string (can be null),
|
||||
"hooks": Hooks,
|
||||
"max_concurrent_downloads": uint,
|
||||
"version": u32,
|
||||
"collapsed_navigation": bool,
|
||||
}
|
||||
|
||||
Memorysettings {
|
||||
"min": u32, can be null,
|
||||
"max": u32,
|
||||
}
|
||||
|
||||
*/
|
||||
|
||||
export type AppSettings = {
|
||||
max_concurrent_downloads: number
|
||||
max_concurrent_writes: number
|
||||
|
||||
theme: ColorTheme
|
||||
default_page: 'home' | 'library'
|
||||
collapsed_navigation: boolean
|
||||
advanced_rendering: boolean
|
||||
native_decorations: boolean
|
||||
toggle_sidebar: boolean
|
||||
|
||||
telemetry: boolean
|
||||
discord_rpc: boolean
|
||||
personalized_ads: boolean
|
||||
|
||||
onboarded: boolean
|
||||
|
||||
extra_launch_args: string[]
|
||||
custom_env_vars: [string, string][]
|
||||
memory: MemorySettings
|
||||
force_fullscreen: boolean
|
||||
game_resolution: WindowSize
|
||||
hide_on_process_start: boolean
|
||||
hooks: Hooks
|
||||
|
||||
custom_dir?: string | null
|
||||
prev_custom_dir?: string | null
|
||||
migrated: boolean
|
||||
|
||||
developer_mode: boolean
|
||||
feature_flags: Record<FeatureFlag, boolean>
|
||||
}
|
||||
|
||||
// Get full settings object
|
||||
export async function get() {
|
||||
return (await invoke('plugin:settings|settings_get')) as AppSettings
|
||||
}
|
||||
|
||||
// Set full settings object
|
||||
export async function set(settings: AppSettings) {
|
||||
return await invoke('plugin:settings|settings_set', { settings })
|
||||
}
|
||||
|
||||
export async function cancel_directory_change(): Promise<void> {
|
||||
return await invoke('plugin:settings|cancel_directory_change')
|
||||
}
|
||||
27
apps/app-frontend/src/helpers/types.d.ts
vendored
@@ -48,6 +48,32 @@ type LinkedData = {
|
||||
|
||||
type InstanceLoader = 'vanilla' | 'forge' | 'fabric' | 'quilt' | 'neoforge'
|
||||
|
||||
type ContentFile = {
|
||||
hash: string
|
||||
file_name: string
|
||||
size: number
|
||||
metadata?: FileMetadata
|
||||
update_version_id?: string
|
||||
project_type: ContentFileProjectType
|
||||
}
|
||||
|
||||
type FileMetadata = {
|
||||
project_id: string
|
||||
version_id: string
|
||||
}
|
||||
|
||||
type ContentFileProjectType = 'mod' | 'datapack' | 'resourcepack' | 'shaderpack'
|
||||
|
||||
type CacheBehaviour =
|
||||
// Serve expired data. If fetch fails / launcher is offline, errors are ignored
|
||||
| 'stale_while_revalidate_skip_offline'
|
||||
// Serve expired data, revalidate in background
|
||||
| 'stale_while_revalidate'
|
||||
// Must revalidate if data is expired
|
||||
| 'must_revalidate'
|
||||
// Ignore cache- always fetch updated data from origin
|
||||
| 'bypass'
|
||||
|
||||
type MemorySettings = {
|
||||
maximum: number
|
||||
}
|
||||
@@ -88,6 +114,7 @@ type AppSettings = {
|
||||
collapsed_navigation: boolean
|
||||
advanced_rendering: boolean
|
||||
native_decorations: boolean
|
||||
worlds_in_home: boolean
|
||||
|
||||
telemetry: boolean
|
||||
discord_rpc: boolean
|
||||
|
||||
@@ -11,10 +11,8 @@ export const latestBetaCommitLink = ref('')
|
||||
export const launcherUrl = 'https://www.astralium.su/get/ar'
|
||||
|
||||
const os = ref('')
|
||||
const releaseLink = `https://api.github.com/repos/DIDIRUS4/AstralRinth/releases/latest`
|
||||
const branchesLink = `https://api.github.com/repos/DIDIRUS4/AstralRinth/branches`
|
||||
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 betaBranch = `beta` // Github repository beta branch
|
||||
const osNames = ['macos', 'windows', 'linux']
|
||||
const macExtension = `.dmg` // MacOS file type for download
|
||||
const windowsExtension = `.msi` // Windows file type for download
|
||||
@@ -28,45 +26,6 @@ const blacklistedBuilds = [
|
||||
`dirty_nightly`,
|
||||
] // This is blacklisted builds for download. For example, file.startsWith('dev') is not allowed.
|
||||
|
||||
/**
|
||||
* Asynchronously fetches branches and their latest commit information from the specified URLs.
|
||||
*
|
||||
* @return {Promise<void>} This function does not return anything directly but updates the latestBetaCommitTruncatedSha and latestBetaCommitLink values.
|
||||
*/
|
||||
export async function getBranches() {
|
||||
fetch(branchesLink)
|
||||
.then(async (response) => {
|
||||
if (response.ok) {
|
||||
response.json().then((data) => {
|
||||
const branches = data.map((branch) => branch)
|
||||
branches.forEach((branch) => {
|
||||
fetch(branch.commit.url).then(async (data) => {
|
||||
if (data.ok) {
|
||||
data.json().then((data) => {
|
||||
const truncatedSha = data.sha.slice(0, 7)
|
||||
const commitLink = data.html_url
|
||||
if (branch.name.toLowerCase() == betaBranch) {
|
||||
latestBetaCommitTruncatedSha.value = truncatedSha
|
||||
latestBetaCommitLink.value = commitLink
|
||||
}
|
||||
})
|
||||
} else {
|
||||
throw new Error(data.status)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
} else {
|
||||
throw new Error(response.status)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
latestBetaCommitTruncatedSha.value = error.message
|
||||
latestBetaCommitLink.value = undefined
|
||||
console.error(failedFetch[1], error)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronous function to get remote data and handle updates and downloads.
|
||||
*
|
||||
|
||||
@@ -42,6 +42,13 @@ export async function restartApp() {
|
||||
return await invoke('restart_app')
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated This method is no longer needed, and just returns its parameter
|
||||
*/
|
||||
export function sanitizePotentialFileUrl(url) {
|
||||
return url
|
||||
}
|
||||
|
||||
export const releaseColor = (releaseType) => {
|
||||
switch (releaseType) {
|
||||
case 'release':
|
||||
@@ -54,3 +61,7 @@ export const releaseColor = (releaseType) => {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export async function copyToClipboard(text) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
}
|
||||
|
||||
327
apps/app-frontend/src/helpers/worlds.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { get_full_path } from '@/helpers/profile'
|
||||
import { openPath } from '@/helpers/utils'
|
||||
import { autoToHTML } from '@geometrically/minecraft-motd-parser'
|
||||
import dayjs from 'dayjs'
|
||||
import type { GameVersion } from '@modrinth/ui'
|
||||
|
||||
type BaseWorld = {
|
||||
name: string
|
||||
last_played?: string
|
||||
icon?: string
|
||||
display_status: DisplayStatus
|
||||
type: WorldType
|
||||
}
|
||||
|
||||
export type WorldType = 'singleplayer' | 'server'
|
||||
export type DisplayStatus = 'normal' | 'hidden' | 'favorite'
|
||||
|
||||
export type SingleplayerWorld = BaseWorld & {
|
||||
type: 'singleplayer'
|
||||
path: string
|
||||
game_mode: SingleplayerGameMode
|
||||
hardcore: boolean
|
||||
locked: boolean
|
||||
}
|
||||
|
||||
export type ServerWorld = BaseWorld & {
|
||||
type: 'server'
|
||||
index: number
|
||||
address: string
|
||||
pack_status: ServerPackStatus
|
||||
}
|
||||
|
||||
export type World = SingleplayerWorld | ServerWorld
|
||||
|
||||
export type WorldWithProfile = {
|
||||
profile: string
|
||||
} & World
|
||||
|
||||
export type SingleplayerGameMode = 'survival' | 'creative' | 'adventure' | 'spectator'
|
||||
export type ServerPackStatus = 'enabled' | 'disabled' | 'prompt'
|
||||
|
||||
export type ServerStatus = {
|
||||
// https://minecraft.wiki/w/Text_component_format
|
||||
description?: string | Chat
|
||||
players?: {
|
||||
max: number
|
||||
online: number
|
||||
sample: { name: string; id: string }[]
|
||||
}
|
||||
version?: {
|
||||
name: string
|
||||
protocol: number
|
||||
}
|
||||
favicon?: string
|
||||
enforces_secure_chat: boolean
|
||||
ping?: number
|
||||
}
|
||||
|
||||
export interface Chat {
|
||||
text: string
|
||||
bold: boolean
|
||||
italic: boolean
|
||||
underlined: boolean
|
||||
strikethrough: boolean
|
||||
obfuscated: boolean
|
||||
color?: string
|
||||
extra: Chat[]
|
||||
}
|
||||
|
||||
export type ServerData = {
|
||||
refreshing: boolean
|
||||
status?: ServerStatus
|
||||
rawMotd?: string | Chat
|
||||
renderedMotd?: string
|
||||
}
|
||||
|
||||
export async function get_recent_worlds(
|
||||
limit: number,
|
||||
displayStatuses?: DisplayStatus[],
|
||||
): Promise<WorldWithProfile[]> {
|
||||
return await invoke('plugin:worlds|get_recent_worlds', { limit, displayStatuses })
|
||||
}
|
||||
|
||||
export async function get_profile_worlds(path: string): Promise<World[]> {
|
||||
return await invoke('plugin:worlds|get_profile_worlds', { path })
|
||||
}
|
||||
|
||||
export async function get_singleplayer_world(
|
||||
instance: string,
|
||||
world: string,
|
||||
): Promise<SingleplayerWorld> {
|
||||
return await invoke('plugin:worlds|get_singleplayer_world', { instance, world })
|
||||
}
|
||||
|
||||
export async function set_world_display_status(
|
||||
instance: string,
|
||||
worldType: WorldType,
|
||||
worldId: string,
|
||||
displayStatus: DisplayStatus,
|
||||
): Promise<void> {
|
||||
return await invoke('plugin:worlds|set_world_display_status', {
|
||||
instance,
|
||||
worldType,
|
||||
worldId,
|
||||
displayStatus,
|
||||
})
|
||||
}
|
||||
|
||||
export async function rename_world(
|
||||
instance: string,
|
||||
world: string,
|
||||
newName: string,
|
||||
): Promise<void> {
|
||||
return await invoke('plugin:worlds|rename_world', { instance, world, newName })
|
||||
}
|
||||
|
||||
export async function reset_world_icon(instance: string, world: string): Promise<void> {
|
||||
return await invoke('plugin:worlds|reset_world_icon', { instance, world })
|
||||
}
|
||||
|
||||
export async function backup_world(instance: string, world: string): Promise<number> {
|
||||
return await invoke('plugin:worlds|backup_world', { instance, world })
|
||||
}
|
||||
|
||||
export async function delete_world(instance: string, world: string): Promise<void> {
|
||||
return await invoke('plugin:worlds|delete_world', { instance, world })
|
||||
}
|
||||
|
||||
export async function add_server_to_profile(
|
||||
path: string,
|
||||
name: string,
|
||||
address: string,
|
||||
packStatus: ServerPackStatus,
|
||||
): Promise<number> {
|
||||
return await invoke('plugin:worlds|add_server_to_profile', { path, name, address, packStatus })
|
||||
}
|
||||
|
||||
export async function edit_server_in_profile(
|
||||
path: string,
|
||||
index: number,
|
||||
name: string,
|
||||
address: string,
|
||||
packStatus: ServerPackStatus,
|
||||
): Promise<void> {
|
||||
return await invoke('plugin:worlds|edit_server_in_profile', {
|
||||
path,
|
||||
index,
|
||||
name,
|
||||
address,
|
||||
packStatus,
|
||||
})
|
||||
}
|
||||
|
||||
export async function remove_server_from_profile(path: string, index: number): Promise<void> {
|
||||
return await invoke('plugin:worlds|remove_server_from_profile', { path, index })
|
||||
}
|
||||
|
||||
export async function get_profile_protocol_version(path: string): Promise<number | null> {
|
||||
return await invoke('plugin:worlds|get_profile_protocol_version', { path })
|
||||
}
|
||||
|
||||
export async function get_server_status(
|
||||
address: string,
|
||||
protocolVersion: number | null = null,
|
||||
): Promise<ServerStatus> {
|
||||
return await invoke('plugin:worlds|get_server_status', { address, protocolVersion })
|
||||
}
|
||||
|
||||
export async function start_join_singleplayer_world(path: string, world: string): Promise<unknown> {
|
||||
return await invoke('plugin:worlds|start_join_singleplayer_world', { path, world })
|
||||
}
|
||||
|
||||
export async function start_join_server(path: string, address: string): Promise<unknown> {
|
||||
return await invoke('plugin:worlds|start_join_server', { path, address })
|
||||
}
|
||||
|
||||
export async function showWorldInFolder(instancePath: string, worldPath: string) {
|
||||
const fullPath = await get_full_path(instancePath)
|
||||
return await openPath(fullPath + '/saves/' + worldPath)
|
||||
}
|
||||
|
||||
export function getWorldIdentifier(world: World) {
|
||||
return world.type === 'singleplayer' ? world.path : world.address
|
||||
}
|
||||
|
||||
export function sortWorlds(worlds: World[]) {
|
||||
worlds.sort((a, b) => {
|
||||
if (!a.last_played) {
|
||||
return 1
|
||||
}
|
||||
if (!b.last_played) {
|
||||
return -1
|
||||
}
|
||||
return dayjs(b.last_played).diff(dayjs(a.last_played))
|
||||
})
|
||||
}
|
||||
|
||||
export function isSingleplayerWorld(world: World): world is SingleplayerWorld {
|
||||
return world.type === 'singleplayer'
|
||||
}
|
||||
|
||||
export function isServerWorld(world: World): world is ServerWorld {
|
||||
return world.type === 'server'
|
||||
}
|
||||
|
||||
export async function refreshServerData(
|
||||
serverData: ServerData,
|
||||
protocolVersion: number | null,
|
||||
address: string,
|
||||
): Promise<void> {
|
||||
serverData.refreshing = true
|
||||
await get_server_status(address, protocolVersion)
|
||||
.then((status) => {
|
||||
serverData.status = status
|
||||
if (status.description) {
|
||||
serverData.rawMotd = status.description
|
||||
serverData.renderedMotd = autoToHTML(status.description)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`Refreshing addr: ${address}`, err)
|
||||
})
|
||||
.finally(() => {
|
||||
serverData.refreshing = false
|
||||
})
|
||||
}
|
||||
|
||||
export async function refreshServers(
|
||||
worlds: World[],
|
||||
serverData: Record<string, ServerData>,
|
||||
protocolVersion: number | null,
|
||||
) {
|
||||
const servers = worlds.filter(isServerWorld)
|
||||
servers.forEach((server) => {
|
||||
if (!serverData[server.address]) {
|
||||
serverData[server.address] = {
|
||||
refreshing: true,
|
||||
}
|
||||
} else {
|
||||
serverData[server.address].refreshing = true
|
||||
}
|
||||
})
|
||||
|
||||
// noinspection ES6MissingAwait - handled with .then by refreshServerData already
|
||||
Promise.all(
|
||||
Object.keys(serverData).map((address) =>
|
||||
refreshServerData(serverData[address], protocolVersion, address),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export async function refreshWorld(worlds: World[], instancePath: string, worldPath: string) {
|
||||
const index = worlds.findIndex((w) => w.type === 'singleplayer' && w.path === worldPath)
|
||||
const newWorld = await get_singleplayer_world(instancePath, worldPath)
|
||||
if (index !== -1) {
|
||||
worlds[index] = newWorld
|
||||
} else {
|
||||
console.info(`Adding new world at path: ${worldPath}.`)
|
||||
worlds.push(newWorld)
|
||||
}
|
||||
sortWorlds(worlds)
|
||||
}
|
||||
|
||||
export async function handleDefaultProfileUpdateEvent(
|
||||
worlds: World[],
|
||||
instancePath: string,
|
||||
e: ProfileEvent,
|
||||
) {
|
||||
if (e.event === 'world_updated') {
|
||||
await refreshWorld(worlds, instancePath, e.world)
|
||||
}
|
||||
|
||||
if (e.event === 'server_joined') {
|
||||
const world = worlds.find(
|
||||
(w) =>
|
||||
w.type === 'server' &&
|
||||
(w.address === `${e.host}:${e.port}` || (e.port == 25565 && w.address == e.host)),
|
||||
)
|
||||
if (world) {
|
||||
world.last_played = e.timestamp
|
||||
sortWorlds(worlds)
|
||||
} else {
|
||||
console.error(`Could not find world for server join event: ${e.host}:${e.port}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshWorlds(instancePath: string): Promise<World[]> {
|
||||
const worlds = await get_profile_worlds(instancePath).catch((err) => {
|
||||
console.error(`Error refreshing worlds for instance: ${instancePath}`, err)
|
||||
})
|
||||
if (worlds) {
|
||||
sortWorlds(worlds)
|
||||
}
|
||||
|
||||
return worlds ?? []
|
||||
}
|
||||
|
||||
const FIRST_QUICK_PLAY_VERSION = '23w14a'
|
||||
|
||||
export function hasQuickPlaySupport(gameVersions: GameVersion[], currentVersion: string) {
|
||||
if (!gameVersions.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
const versionIndex = gameVersions.findIndex((v) => v.version === currentVersion)
|
||||
const targetIndex = gameVersions.findIndex((v) => v.version === FIRST_QUICK_PLAY_VERSION)
|
||||
|
||||
return versionIndex !== -1 && targetIndex !== -1 && versionIndex <= targetIndex
|
||||
}
|
||||
|
||||
export type ProfileEvent = { profile_path_id: string } & (
|
||||
| {
|
||||
event: 'servers_updated'
|
||||
}
|
||||
| {
|
||||
event: 'world_updated'
|
||||
world: string
|
||||
}
|
||||
| {
|
||||
event: 'server_joined'
|
||||
host: string
|
||||
port: number
|
||||
timestamp: string
|
||||
}
|
||||
)
|
||||
@@ -20,12 +20,60 @@
|
||||
"app.settings.tabs.resource-management": {
|
||||
"message": "Resource management"
|
||||
},
|
||||
"instance.add-server.add-and-play": {
|
||||
"message": "Add and play"
|
||||
},
|
||||
"instance.add-server.add-server": {
|
||||
"message": "Add server"
|
||||
},
|
||||
"instance.add-server.resource-pack.disabled": {
|
||||
"message": "Disabled"
|
||||
},
|
||||
"instance.add-server.resource-pack.enabled": {
|
||||
"message": "Enabled"
|
||||
},
|
||||
"instance.add-server.resource-pack.prompt": {
|
||||
"message": "Prompt"
|
||||
},
|
||||
"instance.add-server.title": {
|
||||
"message": "Add a server"
|
||||
},
|
||||
"instance.edit-server.title": {
|
||||
"message": "Edit server"
|
||||
},
|
||||
"instance.edit-world.hide-from-home": {
|
||||
"message": "Hide from the Home page"
|
||||
},
|
||||
"instance.edit-world.name": {
|
||||
"message": "Name"
|
||||
},
|
||||
"instance.edit-world.placeholder-name": {
|
||||
"message": "Minecraft World"
|
||||
},
|
||||
"instance.edit-world.reset-icon": {
|
||||
"message": "Reset icon"
|
||||
},
|
||||
"instance.edit-world.title": {
|
||||
"message": "Edit world"
|
||||
},
|
||||
"instance.filter.disabled": {
|
||||
"message": "Disabled projects"
|
||||
},
|
||||
"instance.filter.updates-available": {
|
||||
"message": "Updates available"
|
||||
},
|
||||
"instance.server-modal.address": {
|
||||
"message": "Address"
|
||||
},
|
||||
"instance.server-modal.name": {
|
||||
"message": "Name"
|
||||
},
|
||||
"instance.server-modal.placeholder-name": {
|
||||
"message": "Minecraft Server"
|
||||
},
|
||||
"instance.server-modal.resource-pack": {
|
||||
"message": "Resource pack"
|
||||
},
|
||||
"instance.settings.tabs.general": {
|
||||
"message": "General"
|
||||
},
|
||||
@@ -308,6 +356,48 @@
|
||||
"instance.settings.title": {
|
||||
"message": "Settings"
|
||||
},
|
||||
"instance.worlds.a_minecraft_server": {
|
||||
"message": "A Minecraft Server"
|
||||
},
|
||||
"instance.worlds.cant_connect": {
|
||||
"message": "Can't connect to server"
|
||||
},
|
||||
"instance.worlds.copy_address": {
|
||||
"message": "Copy address"
|
||||
},
|
||||
"instance.worlds.dont_show_on_home": {
|
||||
"message": "Don't show on Home"
|
||||
},
|
||||
"instance.worlds.filter.available": {
|
||||
"message": "Available"
|
||||
},
|
||||
"instance.worlds.game_already_open": {
|
||||
"message": "Instance is already open"
|
||||
},
|
||||
"instance.worlds.hardcore": {
|
||||
"message": "Hardcore mode"
|
||||
},
|
||||
"instance.worlds.no_quick_play": {
|
||||
"message": "You can only jump straight into worlds on Minecraft 1.20+"
|
||||
},
|
||||
"instance.worlds.play_anyway": {
|
||||
"message": "Play anyway"
|
||||
},
|
||||
"instance.worlds.play_instance": {
|
||||
"message": "Play instance"
|
||||
},
|
||||
"instance.worlds.type.server": {
|
||||
"message": "Server"
|
||||
},
|
||||
"instance.worlds.type.singleplayer": {
|
||||
"message": "Singleplayer"
|
||||
},
|
||||
"instance.worlds.view_instance": {
|
||||
"message": "View instance"
|
||||
},
|
||||
"instance.worlds.world_in_use": {
|
||||
"message": "World is in use"
|
||||
},
|
||||
"search.filter.locked.instance": {
|
||||
"message": "Provided by the instance"
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ import FloatingVue from 'floating-vue'
|
||||
import 'floating-vue/dist/style.css'
|
||||
import { createPlugin } from '@vintl/vintl/plugin'
|
||||
import * as Sentry from '@sentry/vue'
|
||||
import { VueScanPlugin } from '@taijased/vue-render-tracker'
|
||||
|
||||
const VIntlPlugin = createPlugin({
|
||||
controllerOpts: {
|
||||
@@ -24,6 +25,13 @@ const VIntlPlugin = createPlugin({
|
||||
injectInto: [],
|
||||
})
|
||||
|
||||
const vueScan = new VueScanPlugin({
|
||||
enabled: false, // Enable or disable the tracker
|
||||
showOverlay: true, // Show overlay to visualize renders
|
||||
log: false, // Log render events to the console
|
||||
playSound: false, // Play sound on each render
|
||||
})
|
||||
|
||||
const pinia = createPinia()
|
||||
|
||||
let app = createApp(App)
|
||||
@@ -35,6 +43,7 @@ Sentry.init({
|
||||
tracesSampleRate: 0.1,
|
||||
})
|
||||
|
||||
app.use(vueScan)
|
||||
app.use(router)
|
||||
app.use(pinia)
|
||||
app.use(FloatingVue, {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { ref, onUnmounted, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import RowDisplay from '@/components/RowDisplay.vue'
|
||||
@@ -8,19 +8,32 @@ import { useBreadcrumbs } from '@/store/breadcrumbs'
|
||||
import { handleError } from '@/store/notifications.js'
|
||||
import dayjs from 'dayjs'
|
||||
import { get_search_results } from '@/helpers/cache.js'
|
||||
|
||||
const featuredModpacks = ref({})
|
||||
const featuredMods = ref({})
|
||||
const filter = ref('')
|
||||
import type { SearchResult } from '@modrinth/utils'
|
||||
import RecentWorldsList from '@/components/ui/world/RecentWorldsList.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const breadcrumbs = useBreadcrumbs()
|
||||
|
||||
breadcrumbs.setRootContext({ name: 'Home', link: route.path })
|
||||
|
||||
const recentInstances = ref([])
|
||||
const instances = ref<GameInstance[]>([])
|
||||
|
||||
const offline = ref(!navigator.onLine)
|
||||
const featuredModpacks = ref<SearchResult[]>([])
|
||||
const featuredMods = ref<SearchResult[]>([])
|
||||
const installedModpacksFilter = ref('')
|
||||
|
||||
const recentInstances = computed(() =>
|
||||
instances.value
|
||||
.filter((x) => x.last_played)
|
||||
.slice()
|
||||
.sort((a, b) => dayjs(b.last_played).diff(dayjs(a.last_played))),
|
||||
)
|
||||
|
||||
const hasFeaturedProjects = computed(
|
||||
() => (featuredModpacks.value?.length ?? 0) + (featuredMods.value?.length ?? 0) > 0,
|
||||
)
|
||||
|
||||
const offline = ref<boolean>(!navigator.onLine)
|
||||
window.addEventListener('offline', () => {
|
||||
offline.value = true
|
||||
})
|
||||
@@ -28,34 +41,21 @@ window.addEventListener('online', () => {
|
||||
offline.value = false
|
||||
})
|
||||
|
||||
const getInstances = async () => {
|
||||
const profiles = await list().catch(handleError)
|
||||
|
||||
recentInstances.value = profiles
|
||||
.filter((x) => x.last_played)
|
||||
.sort((a, b) => {
|
||||
const dateA = dayjs(a.last_played)
|
||||
const dateB = dayjs(b.last_played)
|
||||
|
||||
if (dateA.isSame(dateB)) {
|
||||
return a.name.localeCompare(b.name)
|
||||
}
|
||||
|
||||
return dateB - dateA
|
||||
})
|
||||
async function fetchInstances() {
|
||||
instances.value = await list().catch(handleError)
|
||||
|
||||
const filters = []
|
||||
for (const instance of profiles) {
|
||||
for (const instance of instances.value) {
|
||||
if (instance.linked_data && instance.linked_data.project_id) {
|
||||
filters.push(`NOT"project_id"="${instance.linked_data.project_id}"`)
|
||||
}
|
||||
}
|
||||
filter.value = filters.join(' AND ')
|
||||
installedModpacksFilter.value = filters.join(' AND ')
|
||||
}
|
||||
|
||||
const getFeaturedModpacks = async () => {
|
||||
async function fetchFeaturedModpacks() {
|
||||
const response = await get_search_results(
|
||||
`?facets=[["project_type:modpack"]]&limit=10&index=follows&filters=${filter.value}`,
|
||||
`?facets=[["project_type:modpack"]]&limit=10&index=follows&filters=${installedModpacksFilter.value}`,
|
||||
)
|
||||
|
||||
if (response) {
|
||||
@@ -64,7 +64,8 @@ const getFeaturedModpacks = async () => {
|
||||
featuredModpacks.value = []
|
||||
}
|
||||
}
|
||||
const getFeaturedMods = async () => {
|
||||
|
||||
async function fetchFeaturedMods() {
|
||||
const response = await get_search_results('?facets=[["project_type:mod"]]&limit=10&index=follows')
|
||||
|
||||
if (response) {
|
||||
@@ -74,27 +75,21 @@ const getFeaturedMods = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
await getInstances()
|
||||
async function refreshFeaturedProjects() {
|
||||
await Promise.all([fetchFeaturedModpacks(), fetchFeaturedMods()])
|
||||
}
|
||||
|
||||
await Promise.all([getFeaturedModpacks(), getFeaturedMods()])
|
||||
await fetchInstances()
|
||||
await refreshFeaturedProjects()
|
||||
|
||||
const unlistenProfile = await profile_listener(async (e) => {
|
||||
await getInstances()
|
||||
await fetchInstances()
|
||||
|
||||
if (e.event === 'added' || e.event === 'created' || e.event === 'removed') {
|
||||
await Promise.all([getFeaturedModpacks(), getFeaturedMods()])
|
||||
await refreshFeaturedProjects()
|
||||
}
|
||||
})
|
||||
|
||||
// computed sums of recentInstances, featuredModpacks, featuredMods, treating them as arrays if they are not
|
||||
const total = computed(() => {
|
||||
return (
|
||||
(recentInstances.value?.length ?? 0) +
|
||||
(featuredModpacks.value?.length ?? 0) +
|
||||
(featuredMods.value?.length ?? 0)
|
||||
)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
unlistenProfile()
|
||||
})
|
||||
@@ -104,17 +99,10 @@ onUnmounted(() => {
|
||||
<div class="p-6 flex flex-col gap-2">
|
||||
<h1 v-if="recentInstances" class="m-0 text-2xl">Welcome back!</h1>
|
||||
<h1 v-else class="m-0 text-2xl">Welcome to AstralRinth App!</h1>
|
||||
<RecentWorldsList :recent-instances="recentInstances" />
|
||||
<RowDisplay
|
||||
v-if="total > 0"
|
||||
v-if="hasFeaturedProjects"
|
||||
:instances="[
|
||||
{
|
||||
label: 'Recently played',
|
||||
route: '/library',
|
||||
instances: recentInstances,
|
||||
instance: true,
|
||||
downloaded: true,
|
||||
compact: true,
|
||||
},
|
||||
{
|
||||
label: 'Discover a modpack',
|
||||
route: '/browse/modpack',
|
||||
|
||||
4
apps/app-frontend/src/pages/Worlds.vue
Normal file
@@ -0,0 +1,4 @@
|
||||
<script setup lang="ts"></script>
|
||||
<template>
|
||||
<div class="p-6 flex flex-col gap-2">Worlds</div>
|
||||
</template>
|
||||
@@ -1,4 +1,5 @@
|
||||
import Index from './Index.vue'
|
||||
import Browse from './Browse.vue'
|
||||
import Worlds from './Worlds.vue'
|
||||
|
||||
export { Index, Browse }
|
||||
export { Index, Browse, Worlds }
|
||||
|
||||
@@ -1,152 +1,160 @@
|
||||
<template>
|
||||
<div
|
||||
class="p-6 pr-2 pb-4"
|
||||
@contextmenu.prevent.stop="(event) => handleRightClick(event, instance.path)"
|
||||
>
|
||||
<ExportModal ref="exportModal" :instance="instance" />
|
||||
<InstanceSettingsModal ref="settingsModal" :instance="instance" :offline="offline" />
|
||||
<ContentPageHeader>
|
||||
<template #icon>
|
||||
<Avatar :src="icon" :alt="instance.name" size="96px" :tint-by="instance.path" />
|
||||
</template>
|
||||
<template #title>
|
||||
{{ instance.name }}
|
||||
</template>
|
||||
<template #summary> </template>
|
||||
<template #stats>
|
||||
<div
|
||||
class="flex items-center gap-2 font-semibold transform capitalize border-0 border-solid border-divider pr-4 md:border-r"
|
||||
>
|
||||
<GameIcon class="h-6 w-6 text-secondary" />
|
||||
{{ instance.loader }} {{ instance.game_version }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 font-semibold">
|
||||
<TimerIcon class="h-6 w-6 text-secondary" />
|
||||
<template v-if="timePlayed > 0">
|
||||
{{ timePlayedHumanized }}
|
||||
</template>
|
||||
<template v-else> Never played </template>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled
|
||||
v-if="instance.install_stage.includes('installing')"
|
||||
color="brand"
|
||||
size="large"
|
||||
>
|
||||
<button disabled>Installing...</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-else-if="instance.install_stage !== 'installed'"
|
||||
color="brand"
|
||||
size="large"
|
||||
>
|
||||
<button @click="repairInstance()">
|
||||
<DownloadIcon />
|
||||
Repair
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="playing === true" color="red" size="large">
|
||||
<button @click="stopInstance('InstancePage')">
|
||||
<StopCircleIcon />
|
||||
Stop
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-else-if="playing === false && loading === false"
|
||||
color="brand"
|
||||
size="large"
|
||||
>
|
||||
<button @click="startInstance('InstancePage')">
|
||||
<PlayIcon />
|
||||
Play
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-else-if="loading === true && playing === false"
|
||||
color="brand"
|
||||
size="large"
|
||||
>
|
||||
<button disabled>Loading...</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled size="large" circular>
|
||||
<button v-tooltip="'Instance settings'" @click="settingsModal.show()">
|
||||
<SettingsIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled size="large" type="transparent" circular>
|
||||
<OverflowMenu
|
||||
:options="[
|
||||
{
|
||||
id: 'open-folder',
|
||||
action: () => showProfileInFolder(instance.path),
|
||||
},
|
||||
{
|
||||
id: 'export-mrpack',
|
||||
action: () => $refs.exportModal.show(),
|
||||
},
|
||||
]"
|
||||
>
|
||||
<MoreVerticalIcon />
|
||||
<template #share-instance> <UserPlusIcon /> Share instance </template>
|
||||
<template #host-a-server> <ServerIcon /> Create a server </template>
|
||||
<template #open-folder> <FolderOpenIcon /> Open folder </template>
|
||||
<template #export-mrpack> <PackageIcon /> Export modpack </template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</ContentPageHeader>
|
||||
</div>
|
||||
<div class="px-6">
|
||||
<NavTabs :links="tabs" />
|
||||
</div>
|
||||
<div class="p-6 pt-4">
|
||||
<RouterView v-slot="{ Component }" :key="instance.path">
|
||||
<template v-if="Component">
|
||||
<Suspense
|
||||
:key="instance.path"
|
||||
@pending="loadingBar.startLoading()"
|
||||
@resolve="loadingBar.stopLoading()"
|
||||
>
|
||||
<component
|
||||
:is="Component"
|
||||
:instance="instance"
|
||||
:options="options"
|
||||
:offline="offline"
|
||||
:playing="playing"
|
||||
:versions="modrinthVersions"
|
||||
:installed="instance.install_stage !== 'installed'"
|
||||
></component>
|
||||
<template #fallback>
|
||||
<LoadingIndicator />
|
||||
</template>
|
||||
</Suspense>
|
||||
</template>
|
||||
</RouterView>
|
||||
</div>
|
||||
<ContextMenu ref="options" @option-clicked="handleOptionsClick">
|
||||
<template #play> <PlayIcon /> Play </template>
|
||||
<template #stop> <StopCircleIcon /> Stop </template>
|
||||
<template #add_content> <PlusIcon /> Add content </template>
|
||||
<template #edit> <EditIcon /> Edit </template>
|
||||
<template #copy_path> <ClipboardCopyIcon /> Copy path </template>
|
||||
<template #open_folder> <ClipboardCopyIcon /> Open folder </template>
|
||||
<template #copy_link> <ClipboardCopyIcon /> Copy link </template>
|
||||
<template #open_link> <ClipboardCopyIcon /> Open in Modrinth <ExternalIcon /> </template>
|
||||
<template #copy_names><EditIcon />Copy names</template>
|
||||
<template #copy_slugs><HashIcon />Copy slugs</template>
|
||||
<template #copy_links><GlobeIcon />Copy links</template>
|
||||
<template #toggle><EditIcon />Toggle selected</template>
|
||||
<template #disable><XIcon />Disable selected</template>
|
||||
<template #enable><CheckCircleIcon />Enable selected</template>
|
||||
<template #hide_show><EyeIcon />Show/Hide unselected</template>
|
||||
<template #update_all
|
||||
><UpdatedIcon />Update {{ selected.length > 0 ? 'selected' : 'all' }}</template
|
||||
<div>
|
||||
<div
|
||||
class="p-6 pr-2 pb-4"
|
||||
@contextmenu.prevent.stop="(event) => handleRightClick(event, instance.path)"
|
||||
>
|
||||
<template #filter_update><UpdatedIcon />Select Updatable</template>
|
||||
</ContextMenu>
|
||||
<ExportModal ref="exportModal" :instance="instance" />
|
||||
<InstanceSettingsModal ref="settingsModal" :instance="instance" :offline="offline" />
|
||||
<ContentPageHeader>
|
||||
<template #icon>
|
||||
<Avatar :src="icon" :alt="instance.name" size="96px" :tint-by="instance.path" />
|
||||
</template>
|
||||
<template #title>
|
||||
{{ instance.name }}
|
||||
</template>
|
||||
<template #summary> </template>
|
||||
<template #stats>
|
||||
<div
|
||||
class="flex items-center gap-2 font-semibold transform capitalize border-0 border-solid border-divider pr-4 md:border-r"
|
||||
>
|
||||
<GameIcon class="h-6 w-6 text-secondary" />
|
||||
{{ instance.loader }} {{ instance.game_version }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 font-semibold">
|
||||
<TimerIcon class="h-6 w-6 text-secondary" />
|
||||
<template v-if="timePlayed > 0">
|
||||
{{ timePlayedHumanized }}
|
||||
</template>
|
||||
<template v-else> Never played </template>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled
|
||||
v-if="
|
||||
['installing', 'pack_installing', 'minecraft_installing'].includes(
|
||||
instance.install_stage,
|
||||
)
|
||||
"
|
||||
color="brand"
|
||||
size="large"
|
||||
>
|
||||
<button disabled>Installing...</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-else-if="instance.install_stage !== 'installed'"
|
||||
color="brand"
|
||||
size="large"
|
||||
>
|
||||
<button @click="repairInstance()">
|
||||
<DownloadIcon />
|
||||
Repair
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="playing === true" color="red" size="large">
|
||||
<button @click="stopInstance('InstancePage')">
|
||||
<StopCircleIcon />
|
||||
Stop
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-else-if="playing === false && loading === false"
|
||||
color="brand"
|
||||
size="large"
|
||||
>
|
||||
<button @click="startInstance('InstancePage')">
|
||||
<PlayIcon />
|
||||
Play
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-else-if="loading === true && playing === false"
|
||||
color="brand"
|
||||
size="large"
|
||||
>
|
||||
<button disabled>Loading...</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled size="large" circular>
|
||||
<button v-tooltip="'Instance settings'" @click="settingsModal.show()">
|
||||
<SettingsIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled size="large" type="transparent" circular>
|
||||
<OverflowMenu
|
||||
:options="[
|
||||
{
|
||||
id: 'open-folder',
|
||||
action: () => showProfileInFolder(instance.path),
|
||||
},
|
||||
{
|
||||
id: 'export-mrpack',
|
||||
action: () => $refs.exportModal.show(),
|
||||
},
|
||||
]"
|
||||
>
|
||||
<MoreVerticalIcon />
|
||||
<template #share-instance> <UserPlusIcon /> Share instance </template>
|
||||
<template #host-a-server> <ServerIcon /> Create a server </template>
|
||||
<template #open-folder> <FolderOpenIcon /> Open folder </template>
|
||||
<template #export-mrpack> <PackageIcon /> Export modpack </template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</ContentPageHeader>
|
||||
</div>
|
||||
<div class="px-6">
|
||||
<NavTabs :links="tabs" />
|
||||
</div>
|
||||
<div v-if="!!instance" class="p-6 pt-4">
|
||||
<RouterView v-slot="{ Component }" :key="instance.path">
|
||||
<template v-if="Component">
|
||||
<Suspense
|
||||
:key="instance.path"
|
||||
@pending="loadingBar.startLoading()"
|
||||
@resolve="loadingBar.stopLoading()"
|
||||
>
|
||||
<component
|
||||
:is="Component"
|
||||
:instance="instance"
|
||||
:options="options"
|
||||
:offline="offline"
|
||||
:playing="playing"
|
||||
:versions="modrinthVersions"
|
||||
:installed="instance.install_stage !== 'installed'"
|
||||
@play="updatePlayState"
|
||||
@stop="() => stopInstance('InstanceSubpage')"
|
||||
></component>
|
||||
<template #fallback>
|
||||
<LoadingIndicator />
|
||||
</template>
|
||||
</Suspense>
|
||||
</template>
|
||||
</RouterView>
|
||||
</div>
|
||||
<ContextMenu ref="options" @option-clicked="handleOptionsClick">
|
||||
<template #play> <PlayIcon /> Play </template>
|
||||
<template #stop> <StopCircleIcon /> Stop </template>
|
||||
<template #add_content> <PlusIcon /> Add content </template>
|
||||
<template #edit> <EditIcon /> Edit </template>
|
||||
<template #copy_path> <ClipboardCopyIcon /> Copy path </template>
|
||||
<template #open_folder> <ClipboardCopyIcon /> Open folder </template>
|
||||
<template #copy_link> <ClipboardCopyIcon /> Copy link </template>
|
||||
<template #open_link> <ClipboardCopyIcon /> Open in Modrinth <ExternalIcon /> </template>
|
||||
<template #copy_names><EditIcon />Copy names</template>
|
||||
<template #copy_slugs><HashIcon />Copy slugs</template>
|
||||
<template #copy_links><GlobeIcon />Copy links</template>
|
||||
<template #toggle><EditIcon />Toggle selected</template>
|
||||
<template #disable><XIcon />Disable selected</template>
|
||||
<template #enable><CheckCircleIcon />Enable selected</template>
|
||||
<template #hide_show><EyeIcon />Show/Hide unselected</template>
|
||||
<template #update_all
|
||||
><UpdatedIcon />Update {{ selected.length > 0 ? 'selected' : 'all' }}</template
|
||||
>
|
||||
<template #filter_update><UpdatedIcon />Select Updatable</template>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
@@ -238,6 +246,10 @@ async function fetchInstance() {
|
||||
})
|
||||
}
|
||||
|
||||
await updatePlayState()
|
||||
}
|
||||
|
||||
async function updatePlayState() {
|
||||
const runningProcesses = await get_by_profile_path(route.params.id).catch(handleError)
|
||||
|
||||
playing.value = runningProcesses.length > 0
|
||||
@@ -253,14 +265,20 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
const basePath = computed(() => `/instance/${encodeURIComponent(route.params.id)}`)
|
||||
|
||||
const tabs = computed(() => [
|
||||
{
|
||||
label: 'Content',
|
||||
href: `/instance/${encodeURIComponent(route.params.id)}`,
|
||||
href: `${basePath.value}`,
|
||||
},
|
||||
{
|
||||
label: 'Worlds',
|
||||
href: `${basePath.value}/worlds`,
|
||||
},
|
||||
{
|
||||
label: 'Logs',
|
||||
href: `/instance/${encodeURIComponent(route.params.id)}/logs`,
|
||||
href: `${basePath.value}/logs`,
|
||||
},
|
||||
])
|
||||
|
||||
|
||||
@@ -117,15 +117,37 @@ const route = useRoute()
|
||||
const props = defineProps({
|
||||
instance: {
|
||||
type: Object,
|
||||
required: true,
|
||||
default() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
offline: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
default() {
|
||||
return false
|
||||
},
|
||||
},
|
||||
playing: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
default() {
|
||||
return false
|
||||
},
|
||||
},
|
||||
versions: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
installed: {
|
||||
type: Boolean,
|
||||
default() {
|
||||
return false
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,251 +1,252 @@
|
||||
<template>
|
||||
<template v-if="projects?.length > 0">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<div class="iconified-input flex-grow">
|
||||
<SearchIcon />
|
||||
<input
|
||||
v-model="searchFilter"
|
||||
type="text"
|
||||
:placeholder="`Search ${filteredProjects.length} project${filteredProjects.length === 1 ? '' : 's'}...`"
|
||||
class="text-input search-input"
|
||||
autocomplete="off"
|
||||
<div>
|
||||
<template v-if="projects?.length > 0">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<div class="iconified-input flex-grow">
|
||||
<SearchIcon />
|
||||
<input
|
||||
v-model="searchFilter"
|
||||
type="text"
|
||||
:placeholder="`Search ${filteredProjects.length} project${filteredProjects.length === 1 ? '' : 's'}...`"
|
||||
class="text-input search-input"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<Button class="r-btn" @click="() => (searchFilter = '')">
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<AddContentButton :instance="instance" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div v-if="filterOptions.length > 1" class="flex flex-wrap gap-1 items-center pb-4">
|
||||
<FilterIcon class="text-secondary h-5 w-5 mr-1" />
|
||||
<button
|
||||
v-for="filter in filterOptions"
|
||||
:key="`content-filter-${filter.id}`"
|
||||
:class="`px-2 py-1 rounded-full font-semibold leading-none border-none cursor-pointer active:scale-[0.97] duration-100 transition-all ${selectedFilters.includes(filter.id) ? 'bg-brand-highlight text-brand' : 'bg-bg-raised text-secondary'}`"
|
||||
@click="toggleArray(selectedFilters, filter.id)"
|
||||
>
|
||||
{{ filter.formattedName }}
|
||||
</button>
|
||||
</div>
|
||||
<Pagination
|
||||
v-if="search.length > 0"
|
||||
:page="currentPage"
|
||||
:count="Math.ceil(search.length / 20)"
|
||||
:link-function="(page) => `?page=${page}`"
|
||||
@switch-page="(page) => (currentPage = page)"
|
||||
/>
|
||||
<Button class="r-btn" @click="() => (searchFilter = '')">
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<AddContentButton :instance="instance" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div v-if="filterOptions.length > 1" class="flex flex-wrap gap-1 items-center pb-4">
|
||||
<FilterIcon class="text-secondary h-5 w-5 mr-1" />
|
||||
<button
|
||||
v-for="filter in filterOptions"
|
||||
:key="filter"
|
||||
:class="`px-2 py-1 rounded-full font-semibold leading-none border-none cursor-pointer active:scale-[0.97] duration-100 transition-all ${selectedFilters.includes(filter.id) ? 'bg-brand-highlight text-brand' : 'bg-bg-raised text-secondary'}`"
|
||||
@click="toggleArray(selectedFilters, filter.id)"
|
||||
>
|
||||
{{ filter.formattedName }}
|
||||
</button>
|
||||
</div>
|
||||
<Pagination
|
||||
v-if="search.length > 0"
|
||||
:page="currentPage"
|
||||
:count="Math.ceil(search.length / 20)"
|
||||
:link-function="(page) => `?page=${page}`"
|
||||
@switch-page="(page) => (currentPage = page)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ContentListPanel
|
||||
v-model="selectedFiles"
|
||||
:locked="isPackLocked"
|
||||
:items="
|
||||
search.map((x) => {
|
||||
const item: ContentItem<any> = {
|
||||
path: x.path,
|
||||
disabled: x.disabled,
|
||||
filename: x.file_name,
|
||||
icon: x.icon,
|
||||
title: x.name,
|
||||
data: x,
|
||||
}
|
||||
|
||||
if (x.version) {
|
||||
item.version = x.version
|
||||
item.versionId = x.version
|
||||
}
|
||||
|
||||
if (x.id) {
|
||||
item.project = {
|
||||
id: x.id,
|
||||
link: { path: `/project/${x.id}`, query: { i: props.instance.path } },
|
||||
linkProps: {},
|
||||
<ContentListPanel
|
||||
v-model="selectedFiles"
|
||||
:locked="isPackLocked"
|
||||
:items="
|
||||
search.map((x) => {
|
||||
const item: ContentItem<any> = {
|
||||
path: x.path,
|
||||
disabled: x.disabled,
|
||||
filename: x.file_name,
|
||||
icon: x.icon ?? undefined,
|
||||
title: x.name,
|
||||
data: x,
|
||||
}
|
||||
}
|
||||
|
||||
if (x.author) {
|
||||
item.creator = {
|
||||
name: x.author,
|
||||
type: 'user',
|
||||
id: x.author,
|
||||
link: 'https://modrinth.com/user/' + x.author,
|
||||
linkProps: { target: '_blank' },
|
||||
if (x.version) {
|
||||
item.version = x.version
|
||||
item.versionId = x.version
|
||||
}
|
||||
}
|
||||
|
||||
return item
|
||||
})
|
||||
"
|
||||
:sort-column="sortColumn"
|
||||
:sort-ascending="ascending"
|
||||
:update-sort="sortProjects"
|
||||
:current-page="currentPage"
|
||||
>
|
||||
<template v-if="selectedProjects.length > 0" #headers>
|
||||
<div class="flex gap-2">
|
||||
if (x.id) {
|
||||
item.project = {
|
||||
id: x.id,
|
||||
link: { path: `/project/${x.id}`, query: { i: props.instance.path } },
|
||||
linkProps: {},
|
||||
}
|
||||
}
|
||||
|
||||
if (x.author) {
|
||||
item.creator = {
|
||||
name: x.author.name,
|
||||
type: x.author.type,
|
||||
id: x.author.slug,
|
||||
link: `https://modrinth.com/${x.author.type}/${x.author.slug}`,
|
||||
linkProps: { target: '_blank' },
|
||||
}
|
||||
}
|
||||
|
||||
return item
|
||||
})
|
||||
"
|
||||
:sort-column="sortColumn"
|
||||
:sort-ascending="ascending"
|
||||
:update-sort="sortProjects"
|
||||
:current-page="currentPage"
|
||||
>
|
||||
<template v-if="selectedProjects.length > 0" #headers>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled
|
||||
v-if="!isPackLocked && selectedProjects.some((m) => m.outdated)"
|
||||
color="brand"
|
||||
color-fill="text"
|
||||
hover-color-fill="text"
|
||||
>
|
||||
<button @click="updateSelected()"><DownloadIcon /> Update</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<OverflowMenu
|
||||
:options="[
|
||||
{
|
||||
id: 'share-names',
|
||||
action: () => shareNames(),
|
||||
},
|
||||
{
|
||||
id: 'share-file-names',
|
||||
action: () => shareFileNames(),
|
||||
},
|
||||
{
|
||||
id: 'share-urls',
|
||||
action: () => shareUrls(),
|
||||
},
|
||||
{
|
||||
id: 'share-markdown',
|
||||
action: () => shareMarkdown(),
|
||||
},
|
||||
]"
|
||||
>
|
||||
<ShareIcon /> Share <DropdownIcon />
|
||||
<template #share-names> <TextInputIcon /> Project names </template>
|
||||
<template #share-file-names> <FileIcon /> File names </template>
|
||||
<template #share-urls> <LinkIcon /> Project links </template>
|
||||
<template #share-markdown> <CodeIcon /> Markdown links </template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="selectedProjects.some((m) => m.disabled)">
|
||||
<button @click="enableAll()"><CheckCircleIcon /> Enable</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="selectedProjects.some((m) => !m.disabled)">
|
||||
<button @click="disableAll()"><SlashIcon /> Disable</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button @click="deleteSelected()"><TrashIcon /> Remove</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
<template #header-actions>
|
||||
<ButtonStyled type="transparent" color-fill="text" hover-color-fill="text">
|
||||
<button :disabled="refreshingProjects" class="w-max" @click="refreshProjects">
|
||||
<UpdatedIcon />
|
||||
Refresh
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-if="!isPackLocked && selectedProjects.some((m) => m.outdated)"
|
||||
v-if="!isPackLocked && projects.some((m) => (m as any).outdated)"
|
||||
type="transparent"
|
||||
color="brand"
|
||||
color-fill="text"
|
||||
hover-color-fill="text"
|
||||
@click="updateAll"
|
||||
>
|
||||
<button class="w-max"><DownloadIcon /> Update all</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-if="canUpdatePack"
|
||||
type="transparent"
|
||||
color="brand"
|
||||
color-fill="text"
|
||||
hover-color-fill="text"
|
||||
>
|
||||
<button @click="updateSelected()"><DownloadIcon /> Update</button>
|
||||
<button class="w-max" :disabled="installing" @click="modpackVersionModal?.show()">
|
||||
<DownloadIcon /> Update pack
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
</template>
|
||||
<template #actions="{ item }">
|
||||
<ButtonStyled
|
||||
v-if="!isPackLocked && (item.data as any).outdated"
|
||||
type="transparent"
|
||||
color="brand"
|
||||
circular
|
||||
>
|
||||
<button
|
||||
v-tooltip="`Update`"
|
||||
:disabled="(item.data as ProjectListEntry).updating"
|
||||
@click="updateProject(item.data)"
|
||||
>
|
||||
<DownloadIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div v-else class="w-[36px]"></div>
|
||||
<Toggle
|
||||
class="!mx-2"
|
||||
:model-value="!item.data.disabled"
|
||||
@update:model-value="toggleDisableMod(item.data)"
|
||||
/>
|
||||
<ButtonStyled type="transparent" circular>
|
||||
<button v-tooltip="'Remove'" @click="removeMod(item)">
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<ButtonStyled type="transparent" circular>
|
||||
<OverflowMenu
|
||||
:options="[
|
||||
{
|
||||
id: 'share-names',
|
||||
action: () => shareNames(),
|
||||
id: 'show-file',
|
||||
action: () => highlightModInProfile(instance.path, item.path),
|
||||
},
|
||||
{
|
||||
id: 'share-file-names',
|
||||
action: () => shareFileNames(),
|
||||
},
|
||||
{
|
||||
id: 'share-urls',
|
||||
action: () => shareUrls(),
|
||||
},
|
||||
{
|
||||
id: 'share-markdown',
|
||||
action: () => shareMarkdown(),
|
||||
id: 'copy-link',
|
||||
shown: item.data !== undefined && item.data.slug !== undefined,
|
||||
action: () => copyModLink(item),
|
||||
},
|
||||
]"
|
||||
direction="left"
|
||||
>
|
||||
<ShareIcon /> Share <DropdownIcon />
|
||||
<template #share-names> <TextInputIcon /> Project names </template>
|
||||
<template #share-file-names> <FileIcon /> File names </template>
|
||||
<template #share-urls> <LinkIcon /> Project links </template>
|
||||
<template #share-markdown> <CodeIcon /> Markdown links </template>
|
||||
<MoreVerticalIcon />
|
||||
<template #show-file> <ExternalIcon /> Show file </template>
|
||||
<template #copy-link> <ClipboardCopyIcon /> Copy link </template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="selectedProjects.some((m) => m.disabled)">
|
||||
<button @click="enableAll()"><CheckCircleIcon /> Enable</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="selectedProjects.some((m) => !m.disabled)">
|
||||
<button @click="disableAll()"><SlashIcon /> Disable</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button @click="deleteSelected()"><TrashIcon /> Remove</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
<template #header-actions>
|
||||
<ButtonStyled type="transparent" color-fill="text" hover-color-fill="text">
|
||||
<button :disabled="refreshingProjects" class="w-max" @click="refreshProjects">
|
||||
<UpdatedIcon />
|
||||
Refresh
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-if="!isPackLocked && projects.some((m) => (m as any).outdated)"
|
||||
type="transparent"
|
||||
color="brand"
|
||||
color-fill="text"
|
||||
hover-color-fill="text"
|
||||
@click="updateAll"
|
||||
>
|
||||
<button class="w-max"><DownloadIcon /> Update all</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-if="canUpdatePack"
|
||||
type="transparent"
|
||||
color="brand"
|
||||
color-fill="text"
|
||||
hover-color-fill="text"
|
||||
>
|
||||
<button class="w-max" :disabled="installing" @click="modpackVersionModal.show()">
|
||||
<DownloadIcon /> Update pack
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template #actions="{ item }">
|
||||
<ButtonStyled
|
||||
v-if="!isPackLocked && (item.data as any).outdated"
|
||||
type="transparent"
|
||||
color="brand"
|
||||
circular
|
||||
>
|
||||
<button
|
||||
v-tooltip="`Update`"
|
||||
:disabled="(item.data as any).updating"
|
||||
@click="updateProject(item.data)"
|
||||
>
|
||||
<DownloadIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div v-else class="w-[36px]"></div>
|
||||
<Toggle
|
||||
class="!mx-2"
|
||||
:model-value="!item.data.disabled"
|
||||
@update:model-value="toggleDisableMod(item.data)"
|
||||
</template>
|
||||
</ContentListPanel>
|
||||
<div class="flex justify-end mt-4">
|
||||
<Pagination
|
||||
v-if="search.length > 0"
|
||||
:page="currentPage"
|
||||
:count="Math.ceil(search.length / 20)"
|
||||
:link-function="(page) => `?page=${page}`"
|
||||
@switch-page="(page) => (currentPage = page)"
|
||||
/>
|
||||
<ButtonStyled type="transparent" circular>
|
||||
<button v-tooltip="'Remove'" @click="removeMod(item)">
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<ButtonStyled type="transparent" circular>
|
||||
<OverflowMenu
|
||||
:options="[
|
||||
{
|
||||
id: 'show-file',
|
||||
action: () => highlightModInProfile(instance.path, item.path),
|
||||
},
|
||||
{
|
||||
id: 'copy-link',
|
||||
shown: item.data !== undefined && item.data.slug !== undefined,
|
||||
action: () => copyModLink(item),
|
||||
},
|
||||
]"
|
||||
direction="left"
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="w-full max-w-[48rem] mx-auto flex flex-col mt-6">
|
||||
<RadialHeader class="">
|
||||
<div class="flex items-center gap-6 w-[32rem] mx-auto">
|
||||
<img src="@/assets/sad-modrinth-bot.webp" class="h-24" />
|
||||
<span class="text-contrast font-bold text-xl"
|
||||
>You haven't added any content to this instance yet.</span
|
||||
>
|
||||
<MoreVerticalIcon />
|
||||
<template #show-file> <ExternalIcon /> Show file </template>
|
||||
<template #copy-link> <ClipboardCopyIcon /> Copy link </template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</ContentListPanel>
|
||||
<div class="flex justify-end mt-4">
|
||||
<Pagination
|
||||
v-if="search.length > 0"
|
||||
:page="currentPage"
|
||||
:count="Math.ceil(search.length / 20)"
|
||||
:link-function="(page) => `?page=${page}`"
|
||||
@switch-page="(page) => (currentPage = page)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="w-full flex flex-col items-center justify-center mt-6 max-w-[48rem] mx-auto">
|
||||
<div class="top-box w-full">
|
||||
<div class="flex items-center gap-6 w-[32rem] mx-auto">
|
||||
<img src="@/assets/sad-modrinth-bot.webp" class="h-24" />
|
||||
<span class="text-contrast font-bold text-xl"
|
||||
>You haven't added any content to this instance yet.</span
|
||||
>
|
||||
</div>
|
||||
</RadialHeader>
|
||||
<div class="flex mt-4 mx-auto">
|
||||
<AddContentButton :instance="instance" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="top-box-divider"></div>
|
||||
<div class="flex items-center gap-6 py-4">
|
||||
<AddContentButton :instance="instance" />
|
||||
</div>
|
||||
<ShareModalWrapper
|
||||
ref="shareModal"
|
||||
share-title="Sharing modpack content"
|
||||
share-text="Check out the projects I'm using in my modpack!"
|
||||
:open-in-new-tab="false"
|
||||
/>
|
||||
<ExportModal v-if="projects.length > 0" ref="exportModal" :instance="instance" />
|
||||
<ModpackVersionModal
|
||||
v-if="instance.linked_data"
|
||||
ref="modpackVersionModal"
|
||||
:instance="instance"
|
||||
:versions="props.versions"
|
||||
/>
|
||||
</div>
|
||||
<ShareModalWrapper
|
||||
ref="shareModal"
|
||||
share-title="Sharing modpack content"
|
||||
share-text="Check out the projects I'm using in my modpack!"
|
||||
:open-in-new-tab="false"
|
||||
/>
|
||||
<ExportModal v-if="projects.length > 0" ref="exportModal" :instance="instance" />
|
||||
<ModpackVersionModal
|
||||
v-if="instance.linked_data"
|
||||
ref="modpackVersionModal"
|
||||
:instance="instance"
|
||||
:versions="props.versions"
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
@@ -272,8 +273,10 @@ import {
|
||||
ContentListPanel,
|
||||
OverflowMenu,
|
||||
Pagination,
|
||||
RadialHeader,
|
||||
Toggle,
|
||||
} from '@modrinth/ui'
|
||||
import type { Organization, Project, TeamMember, Version } from '@modrinth/utils'
|
||||
import { formatProjectType } from '@modrinth/utils'
|
||||
import type { ComputedRef } from 'vue'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
@@ -303,31 +306,42 @@ import { profile_listener } from '@/helpers/events.js'
|
||||
import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue'
|
||||
import { getCurrentWebview } from '@tauri-apps/api/webview'
|
||||
import dayjs from 'dayjs'
|
||||
import type { CacheBehaviour, ContentFile, GameInstance } from '@/helpers/types'
|
||||
import type ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import type { ContentItem } from '@modrinth/ui/src/components/content/ContentListItem.vue'
|
||||
|
||||
const props = defineProps({
|
||||
instance: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
offline: {
|
||||
type: Boolean,
|
||||
default() {
|
||||
return false
|
||||
},
|
||||
},
|
||||
versions: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
const props = defineProps<{
|
||||
instance: GameInstance
|
||||
options: InstanceType<typeof ContextMenu>
|
||||
offline: boolean
|
||||
playing: boolean
|
||||
versions: Version[]
|
||||
installed: boolean
|
||||
}>()
|
||||
|
||||
type ProjectListEntryAuthor = {
|
||||
name: string
|
||||
slug: string
|
||||
type: 'user' | 'organization'
|
||||
}
|
||||
|
||||
type ProjectListEntry = {
|
||||
path: string
|
||||
name: string
|
||||
slug?: string
|
||||
author: ProjectListEntryAuthor | null
|
||||
version: string | null
|
||||
file_name: string
|
||||
icon: string | undefined
|
||||
disabled: boolean
|
||||
updateVersion?: string
|
||||
outdated: boolean
|
||||
updated: dayjs.Dayjs
|
||||
project_type: string
|
||||
id?: string
|
||||
updating?: boolean
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
const isPackLocked = computed(() => {
|
||||
return props.instance.linked_data && props.instance.linked_data.locked
|
||||
@@ -338,18 +352,21 @@ const canUpdatePack = computed(() => {
|
||||
})
|
||||
const exportModal = ref(null)
|
||||
|
||||
const projects = ref([])
|
||||
const selectedFiles = ref([])
|
||||
const projects = ref<ProjectListEntry[]>([])
|
||||
const selectedFiles = ref<string[]>([])
|
||||
const selectedProjects = computed(() =>
|
||||
projects.value.filter((x) => selectedFiles.value.includes(x.file_name)),
|
||||
)
|
||||
|
||||
const selectionMap = ref(new Map())
|
||||
|
||||
const initProjects = async (cacheBehaviour?) => {
|
||||
const newProjects = []
|
||||
const initProjects = async (cacheBehaviour?: CacheBehaviour) => {
|
||||
const newProjects: ProjectListEntry[] = []
|
||||
|
||||
const profileProjects = await get_projects(props.instance.path, cacheBehaviour)
|
||||
const profileProjects = (await get_projects(props.instance.path, cacheBehaviour)) as Record<
|
||||
string,
|
||||
ContentFile
|
||||
>
|
||||
const fetchProjects = []
|
||||
const fetchVersions = []
|
||||
|
||||
@@ -361,21 +378,21 @@ const initProjects = async (cacheBehaviour?) => {
|
||||
}
|
||||
|
||||
const [modrinthProjects, modrinthVersions] = await Promise.all([
|
||||
await get_project_many(fetchProjects).catch(handleError),
|
||||
await get_version_many(fetchVersions).catch(handleError),
|
||||
(await get_project_many(fetchProjects).catch(handleError)) as Project[],
|
||||
(await get_version_many(fetchVersions).catch(handleError)) as Version[],
|
||||
])
|
||||
|
||||
const [modrinthTeams, modrinthOrganizations] = await Promise.all([
|
||||
await get_team_many(modrinthProjects.map((x) => x.team)).catch(handleError),
|
||||
await get_organization_many(
|
||||
(await get_team_many(modrinthProjects.map((x) => x.team)).catch(handleError)) as TeamMember[][],
|
||||
(await get_organization_many(
|
||||
modrinthProjects.map((x) => x.organization).filter((x) => !!x),
|
||||
).catch(handleError),
|
||||
).catch(handleError)) as Organization[],
|
||||
])
|
||||
|
||||
for (const [path, file] of Object.entries(profileProjects)) {
|
||||
if (file.metadata) {
|
||||
const project = modrinthProjects.find((x) => file.metadata.project_id === x.id)
|
||||
const version = modrinthVersions.find((x) => file.metadata.version_id === x.id)
|
||||
const project = modrinthProjects.find((x) => file.metadata?.project_id === x.id)
|
||||
const version = modrinthVersions.find((x) => file.metadata?.version_id === x.id)
|
||||
|
||||
if (project && version) {
|
||||
const org = project.organization
|
||||
@@ -384,21 +401,29 @@ const initProjects = async (cacheBehaviour?) => {
|
||||
|
||||
const team = modrinthTeams.find((x) => x[0].team_id === project.team)
|
||||
|
||||
let owner
|
||||
|
||||
let author: ProjectListEntryAuthor | null = null
|
||||
if (org) {
|
||||
owner = org.name
|
||||
author = {
|
||||
name: org.name,
|
||||
slug: org.slug,
|
||||
type: 'organization',
|
||||
}
|
||||
} else if (team) {
|
||||
owner = team.find((x) => x.is_owner).user.username
|
||||
} else {
|
||||
owner = null
|
||||
const teamMember = team.find((x) => x.is_owner)
|
||||
if (teamMember) {
|
||||
author = {
|
||||
name: teamMember.user.username,
|
||||
slug: teamMember.user.username,
|
||||
type: 'user',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
newProjects.push({
|
||||
path,
|
||||
name: project.title,
|
||||
slug: project.slug,
|
||||
author: owner,
|
||||
author,
|
||||
version: version.version_number,
|
||||
file_name: file.file_name,
|
||||
icon: project.icon_url,
|
||||
@@ -417,10 +442,10 @@ const initProjects = async (cacheBehaviour?) => {
|
||||
newProjects.push({
|
||||
path,
|
||||
name: file.file_name.replace('.disabled', ''),
|
||||
author: '',
|
||||
author: null,
|
||||
version: null,
|
||||
file_name: file.file_name,
|
||||
icon: null,
|
||||
icon: undefined,
|
||||
disabled: file.file_name.endsWith('.disabled'),
|
||||
outdated: false,
|
||||
updated: dayjs(0),
|
||||
@@ -428,7 +453,7 @@ const initProjects = async (cacheBehaviour?) => {
|
||||
})
|
||||
}
|
||||
|
||||
projects.value = newProjects
|
||||
projects.value = newProjects ?? []
|
||||
|
||||
const newSelectionMap = new Map()
|
||||
for (const project of projects.value) {
|
||||
@@ -444,7 +469,7 @@ const initProjects = async (cacheBehaviour?) => {
|
||||
}
|
||||
await initProjects()
|
||||
|
||||
const modpackVersionModal = ref(null)
|
||||
const modpackVersionModal = ref<InstanceType<typeof ModpackVersionModal> | null>()
|
||||
const installing = computed(() => props.instance.install_stage !== 'installed')
|
||||
|
||||
const vintl = useVIntl()
|
||||
@@ -469,7 +494,7 @@ const messages = defineMessages({
|
||||
const filterOptions: ComputedRef<FilterOption[]> = computed(() => {
|
||||
const options: FilterOption[] = []
|
||||
|
||||
const frequency = projects.value.reduce((map, item) => {
|
||||
const frequency = projects.value.reduce((map: Record<string, number>, item) => {
|
||||
map[item.project_type] = (map[item.project_type] || 0) + 1
|
||||
return map
|
||||
}, {})
|
||||
@@ -500,7 +525,7 @@ const filterOptions: ComputedRef<FilterOption[]> = computed(() => {
|
||||
return options
|
||||
})
|
||||
|
||||
const selectedFilters = ref([])
|
||||
const selectedFilters = ref<string[]>([])
|
||||
const filteredProjects = computed(() => {
|
||||
const updatesFilter = selectedFilters.value.includes('updates')
|
||||
const disabledFilter = selectedFilters.value.includes('disabled')
|
||||
@@ -527,7 +552,7 @@ watch(filterOptions, () => {
|
||||
}
|
||||
})
|
||||
|
||||
function toggleArray(array, value) {
|
||||
function toggleArray<T>(array: T[], value: T) {
|
||||
if (array.includes(value)) {
|
||||
array.splice(array.indexOf(value), 1)
|
||||
} else {
|
||||
@@ -537,7 +562,7 @@ function toggleArray(array, value) {
|
||||
|
||||
const searchFilter = ref('')
|
||||
const selectAll = ref(false)
|
||||
const shareModal = ref(null)
|
||||
const shareModal = ref<InstanceType<typeof ShareModalWrapper> | null>()
|
||||
const ascending = ref(true)
|
||||
const sortColumn = ref('Name')
|
||||
const currentPage = ref(1)
|
||||
@@ -578,7 +603,7 @@ const search = computed(() => {
|
||||
|
||||
watch([sortColumn, ascending, selectedFilters.value, searchFilter], () => (currentPage.value = 1))
|
||||
|
||||
const sortProjects = (filter) => {
|
||||
const sortProjects = (filter: string) => {
|
||||
if (sortColumn.value === filter) {
|
||||
ascending.value = !ascending.value
|
||||
} else {
|
||||
@@ -596,7 +621,7 @@ const updateAll = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const paths = await update_all(props.instance.path).catch(handleError)
|
||||
const paths = (await update_all(props.instance.path).catch(handleError)) as Record<string, string>
|
||||
|
||||
for (const [oldVal, newVal] of Object.entries(paths)) {
|
||||
const index = projects.value.findIndex((x) => x.path === oldVal)
|
||||
@@ -605,7 +630,7 @@ const updateAll = async () => {
|
||||
|
||||
if (projects.value[index].updateVersion) {
|
||||
projects.value[index].version = projects.value[index].updateVersion.version_number
|
||||
projects.value[index].updateVersion = null
|
||||
projects.value[index].updateVersion = undefined
|
||||
}
|
||||
}
|
||||
for (const project of setProjects) {
|
||||
@@ -620,15 +645,15 @@ const updateAll = async () => {
|
||||
})
|
||||
}
|
||||
|
||||
const updateProject = async (mod) => {
|
||||
const updateProject = async (mod: ProjectListEntry) => {
|
||||
mod.updating = true
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
mod.path = await update_project(props.instance.path, mod.path).catch(handleError)
|
||||
mod.updating = false
|
||||
|
||||
mod.outdated = false
|
||||
mod.version = mod.updateVersion.version_number
|
||||
mod.updateVersion = null
|
||||
mod.version = mod.updateVersion?.version_number
|
||||
mod.updateVersion = undefined
|
||||
|
||||
trackEvent('InstanceProjectUpdate', {
|
||||
loader: props.instance.loader,
|
||||
@@ -639,15 +664,15 @@ const updateProject = async (mod) => {
|
||||
})
|
||||
}
|
||||
|
||||
const locks = {}
|
||||
const locks: Record<string, string | null> = {}
|
||||
|
||||
const toggleDisableMod = async (mod) => {
|
||||
const toggleDisableMod = async (mod: ProjectListEntry) => {
|
||||
// Use mod's id as the key for the lock. If mod doesn't have a unique id, replace `mod.id` with some unique property.
|
||||
const lock = locks[mod.file_name]
|
||||
|
||||
while (lock) {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout((_) => resolve(), 100)
|
||||
setTimeout((value: unknown) => resolve(value), 100)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -672,20 +697,20 @@ const toggleDisableMod = async (mod) => {
|
||||
locks[mod.file_name] = null
|
||||
}
|
||||
|
||||
const removeMod = async (mod) => {
|
||||
const removeMod = async (mod: ContentItem<ProjectListEntry>) => {
|
||||
await remove_project(props.instance.path, mod.path).catch(handleError)
|
||||
projects.value = projects.value.filter((x) => mod.path !== x.path)
|
||||
|
||||
trackEvent('InstanceProjectRemove', {
|
||||
loader: props.instance.loader,
|
||||
game_version: props.instance.game_version,
|
||||
id: mod.id,
|
||||
name: mod.name,
|
||||
project_type: mod.project_type,
|
||||
id: mod.data.id,
|
||||
name: mod.data.name,
|
||||
project_type: mod.data.project_type,
|
||||
})
|
||||
}
|
||||
|
||||
const copyModLink = async (mod) => {
|
||||
const copyModLink = async (mod: ContentItem<ProjectListEntry>) => {
|
||||
await navigator.clipboard.writeText(
|
||||
`https://modrinth.com/${mod.data.project_type}/${mod.data.slug}`,
|
||||
)
|
||||
@@ -700,15 +725,15 @@ const deleteSelected = async () => {
|
||||
}
|
||||
|
||||
const shareNames = async () => {
|
||||
await shareModal.value.show(functionValues.value.map((x) => x.name).join('\n'))
|
||||
await shareModal.value?.show(functionValues.value.map((x) => x.name).join('\n'))
|
||||
}
|
||||
|
||||
const shareFileNames = async () => {
|
||||
await shareModal.value.show(functionValues.value.map((x) => x.file_name).join('\n'))
|
||||
await shareModal.value?.show(functionValues.value.map((x) => x.file_name).join('\n'))
|
||||
}
|
||||
|
||||
const shareUrls = async () => {
|
||||
await shareModal.value.show(
|
||||
await shareModal.value?.show(
|
||||
functionValues.value
|
||||
.filter((x) => x.slug)
|
||||
.map((x) => `https://modrinth.com/${x.project_type}/${x.slug}`)
|
||||
@@ -717,7 +742,7 @@ const shareUrls = async () => {
|
||||
}
|
||||
|
||||
const shareMarkdown = async () => {
|
||||
await shareModal.value.show(
|
||||
await shareModal.value?.show(
|
||||
functionValues.value
|
||||
.map((x) => {
|
||||
if (x.slug) {
|
||||
@@ -782,15 +807,17 @@ const unlisten = await getCurrentWebview().onDragDropEvent(async (event) => {
|
||||
await initProjects()
|
||||
})
|
||||
|
||||
const unlistenProfiles = await profile_listener(async (event) => {
|
||||
if (
|
||||
event.profile_path_id === props.instance.path &&
|
||||
event.event === 'synced' &&
|
||||
props.instance.install_stage !== 'pack_installing'
|
||||
) {
|
||||
await initProjects()
|
||||
}
|
||||
})
|
||||
const unlistenProfiles = await profile_listener(
|
||||
async (event: { event: string; profile_path_id: string }) => {
|
||||
if (
|
||||
event.profile_path_id === props.instance.path &&
|
||||
event.event === 'synced' &&
|
||||
props.instance.install_stage !== 'pack_installing'
|
||||
) {
|
||||
await initProjects()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
unlisten()
|
||||
|
||||
15
apps/app-frontend/src/pages/instance/Overview.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>{{ instance.name }} overview</template>
|
||||
<script setup lang="ts">
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import type ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import type { Version } from '@modrinth/utils'
|
||||
|
||||
defineProps<{
|
||||
instance: GameInstance
|
||||
options: InstanceType<typeof ContextMenu>
|
||||
offline: boolean
|
||||
playing: boolean
|
||||
versions: Version[]
|
||||
installed: boolean
|
||||
}>()
|
||||
</script>
|
||||
465
apps/app-frontend/src/pages/instance/Worlds.vue
Normal file
@@ -0,0 +1,465 @@
|
||||
<template>
|
||||
<AddServerModal
|
||||
ref="addServerModal"
|
||||
:instance="instance"
|
||||
@submit="
|
||||
(server, start) => {
|
||||
addServer(server)
|
||||
if (start) {
|
||||
joinWorld(server)
|
||||
}
|
||||
}
|
||||
"
|
||||
/>
|
||||
<EditServerModal ref="editServerModal" :instance="instance" @submit="editServer" />
|
||||
<EditWorldModal ref="editWorldModal" :instance="instance" @submit="editWorld" />
|
||||
<ConfirmModalWrapper
|
||||
ref="removeServerModal"
|
||||
:title="`Are you sure you want to remove ${serverToRemove?.name ?? 'this server'}?`"
|
||||
:description="`'${serverToRemove?.name}'${serverToRemove?.address === serverToRemove?.name ? ' ' : ` (${serverToRemove?.address})`} will be removed from your list, including in-game, and there will be no way to recover it.`"
|
||||
:markdown="false"
|
||||
@proceed="proceedRemoveServer"
|
||||
/>
|
||||
<ConfirmModalWrapper
|
||||
ref="deleteWorldModal"
|
||||
:title="`Are you sure you want to permanently delete this world?`"
|
||||
:description="`'${worldToDelete?.name}' will be **permanently deleted**, and there will be no way to recover it.`"
|
||||
@proceed="proceedDeleteWorld"
|
||||
/>
|
||||
<div v-if="worlds.length > 0" class="flex flex-col gap-4">
|
||||
<div class="flex flex-wrap gap-2 items-center">
|
||||
<div class="iconified-input flex-grow">
|
||||
<SearchIcon />
|
||||
<input
|
||||
v-model="searchFilter"
|
||||
type="text"
|
||||
:placeholder="`Search worlds...`"
|
||||
class="text-input search-input"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<Button v-if="searchFilter" class="r-btn" @click="() => (searchFilter = '')">
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<ButtonStyled>
|
||||
<button :disabled="refreshingAll" @click="refreshAllWorlds">
|
||||
<template v-if="refreshingAll">
|
||||
<SpinnerIcon class="animate-spin" />
|
||||
Refreshing...
|
||||
</template>
|
||||
<template v-else>
|
||||
<UpdatedIcon />
|
||||
Refresh
|
||||
</template>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="addServerModal?.show()">
|
||||
<PlusIcon />
|
||||
Add a server
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<FilterBar v-model="filters" :options="filterOptions" show-all-options />
|
||||
<div class="flex flex-col w-full gap-2">
|
||||
<WorldItem
|
||||
v-for="world in filteredWorlds"
|
||||
:key="`world-${world.type}-${world.type == 'singleplayer' ? world.path : `${world.address}-${world.index}`}`"
|
||||
:world="world"
|
||||
:highlighted="highlightedWorld === getWorldIdentifier(world)"
|
||||
:supports-quick-play="supportsQuickPlay"
|
||||
:current-protocol="protocolVersion"
|
||||
:playing-instance="playing"
|
||||
:playing-world="worldsMatch(world, worldPlaying)"
|
||||
:starting-instance="startingInstance"
|
||||
:refreshing="world.type === 'server' ? serverData[world.address]?.refreshing : undefined"
|
||||
:server-status="world.type === 'server' ? serverData[world.address]?.status : undefined"
|
||||
:rendered-motd="
|
||||
world.type === 'server' ? serverData[world.address]?.renderedMotd : undefined
|
||||
"
|
||||
:game-mode="world.type === 'singleplayer' ? GAME_MODES[world.game_mode] : undefined"
|
||||
@play="() => joinWorld(world)"
|
||||
@stop="() => emit('stop')"
|
||||
@refresh="() => refreshServer((world as ServerWorld).address)"
|
||||
@edit="
|
||||
() =>
|
||||
world.type === 'server' ? editServerModal?.show(world) : editWorldModal?.show(world)
|
||||
"
|
||||
@delete="() => promptToRemoveWorld(world)"
|
||||
@open-folder="(world: SingleplayerWorld) => showWorldInFolder(instance.path, world.path)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="w-full max-w-[48rem] mx-auto flex flex-col mt-6">
|
||||
<RadialHeader class="">
|
||||
<div class="flex items-center gap-6 w-[32rem] mx-auto">
|
||||
<img src="@/assets/sad-modrinth-bot.webp" alt="" aria-hidden="true" class="h-24" />
|
||||
<span class="text-contrast font-bold text-xl"> You don't have any worlds yet. </span>
|
||||
</div>
|
||||
</RadialHeader>
|
||||
<div class="flex gap-2 mt-4 mx-auto">
|
||||
<ButtonStyled>
|
||||
<button @click="addServerModal?.show()">
|
||||
<PlusIcon aria-hidden="true" />
|
||||
Add a server
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button :disabled="refreshingAll" @click="refreshAllWorlds">
|
||||
<template v-if="refreshingAll">
|
||||
<SpinnerIcon aria-hidden="true" class="animate-spin" />
|
||||
Refreshing...
|
||||
</template>
|
||||
<template v-else>
|
||||
<UpdatedIcon aria-hidden="true" />
|
||||
Refresh
|
||||
</template>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onUnmounted, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import {
|
||||
Button,
|
||||
ButtonStyled,
|
||||
RadialHeader,
|
||||
FilterBar,
|
||||
type FilterBarOption,
|
||||
type GameVersion,
|
||||
GAME_MODES,
|
||||
} from '@modrinth/ui'
|
||||
import { PlusIcon, SpinnerIcon, UpdatedIcon, SearchIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
type SingleplayerWorld,
|
||||
type World,
|
||||
type ServerWorld,
|
||||
type ServerData,
|
||||
type ProfileEvent,
|
||||
get_profile_protocol_version,
|
||||
remove_server_from_profile,
|
||||
delete_world,
|
||||
start_join_server,
|
||||
start_join_singleplayer_world,
|
||||
getWorldIdentifier,
|
||||
refreshServerData,
|
||||
refreshWorld,
|
||||
sortWorlds,
|
||||
refreshServers,
|
||||
hasQuickPlaySupport,
|
||||
refreshWorlds,
|
||||
handleDefaultProfileUpdateEvent,
|
||||
showWorldInFolder,
|
||||
} from '@/helpers/worlds.ts'
|
||||
import AddServerModal from '@/components/ui/world/modal/AddServerModal.vue'
|
||||
import EditServerModal from '@/components/ui/world/modal/EditServerModal.vue'
|
||||
import EditWorldModal from '@/components/ui/world/modal/EditSingleplayerWorldModal.vue'
|
||||
import WorldItem from '@/components/ui/world/WorldItem.vue'
|
||||
|
||||
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
|
||||
import { handleError } from '@/store/notifications'
|
||||
import type ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import type { Version } from '@modrinth/utils'
|
||||
import { profile_listener } from '@/helpers/events'
|
||||
import { get_game_versions } from '@/helpers/tags'
|
||||
import { defineMessages } from '@vintl/vintl'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const addServerModal = ref<InstanceType<typeof AddServerModal>>()
|
||||
const editServerModal = ref<InstanceType<typeof EditServerModal>>()
|
||||
const editWorldModal = ref<InstanceType<typeof EditWorldModal>>()
|
||||
const removeServerModal = ref<InstanceType<typeof ConfirmModalWrapper>>()
|
||||
const deleteWorldModal = ref<InstanceType<typeof ConfirmModalWrapper>>()
|
||||
|
||||
const serverToRemove = ref<ServerWorld>()
|
||||
const worldToDelete = ref<SingleplayerWorld>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'play', world: World): void
|
||||
(event: 'stop'): void
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
instance: GameInstance
|
||||
options: InstanceType<typeof ContextMenu> | null
|
||||
offline: boolean
|
||||
playing: boolean
|
||||
versions: Version[]
|
||||
installed: boolean
|
||||
}>()
|
||||
|
||||
const instance = computed(() => props.instance)
|
||||
const playing = computed(() => props.playing)
|
||||
|
||||
function play(world: World) {
|
||||
emit('play', world)
|
||||
}
|
||||
|
||||
const filters = ref<string[]>([])
|
||||
const searchFilter = ref('')
|
||||
|
||||
const refreshingAll = ref(false)
|
||||
const hadNoWorlds = ref(true)
|
||||
const startingInstance = ref(false)
|
||||
const worldPlaying = ref<World>()
|
||||
|
||||
const worlds = ref<World[]>([])
|
||||
const serverData = ref<Record<string, ServerData>>({})
|
||||
|
||||
const protocolVersion = ref<number | null>(await get_profile_protocol_version(instance.value.path))
|
||||
|
||||
const unlistenProfile = await profile_listener(async (e: ProfileEvent) => {
|
||||
if (e.profile_path_id !== instance.value.path) return
|
||||
|
||||
console.info(`Handling profile event '${e.event}' for profile: ${e.profile_path_id}`)
|
||||
|
||||
if (e.event === 'servers_updated') {
|
||||
await refreshAllWorlds()
|
||||
}
|
||||
|
||||
await handleDefaultProfileUpdateEvent(worlds.value, instance.value.path, e)
|
||||
})
|
||||
|
||||
await refreshAllWorlds()
|
||||
|
||||
async function refreshServer(address: string) {
|
||||
if (!serverData.value[address]) {
|
||||
serverData.value[address] = {
|
||||
refreshing: true,
|
||||
}
|
||||
}
|
||||
await refreshServerData(serverData.value[address], protocolVersion.value, address)
|
||||
}
|
||||
|
||||
async function refreshAllWorlds() {
|
||||
if (refreshingAll.value) {
|
||||
console.log(`Already refreshing, cancelling refresh.`)
|
||||
return
|
||||
}
|
||||
|
||||
refreshingAll.value = true
|
||||
|
||||
worlds.value = await refreshWorlds(instance.value.path).finally(
|
||||
() => (refreshingAll.value = false),
|
||||
)
|
||||
await refreshServers(worlds.value, serverData.value, protocolVersion.value)
|
||||
|
||||
const hasNoWorlds = worlds.value.length === 0
|
||||
|
||||
if (hadNoWorlds.value && hasNoWorlds) {
|
||||
setTimeout(() => {
|
||||
refreshingAll.value = false
|
||||
}, 1000)
|
||||
} else {
|
||||
refreshingAll.value = false
|
||||
}
|
||||
|
||||
hadNoWorlds.value = hasNoWorlds
|
||||
}
|
||||
|
||||
async function addServer(server: ServerWorld) {
|
||||
worlds.value.push(server)
|
||||
sortWorlds(worlds.value)
|
||||
await refreshServer(server.address)
|
||||
}
|
||||
|
||||
async function editServer(server: ServerWorld) {
|
||||
const index = worlds.value.findIndex((w) => w.type === 'server' && w.index === server.index)
|
||||
if (index !== -1) {
|
||||
const oldServer = worlds.value[index] as ServerWorld
|
||||
worlds.value[index] = server
|
||||
sortWorlds(worlds.value)
|
||||
if (oldServer.address !== server.address) {
|
||||
await refreshServer(server.address)
|
||||
}
|
||||
} else {
|
||||
handleError(`Error refreshing server, refreshing all worlds`)
|
||||
await refreshAllWorlds()
|
||||
}
|
||||
}
|
||||
|
||||
async function removeServer(server: ServerWorld) {
|
||||
await remove_server_from_profile(instance.value.path, server.index).catch(handleError)
|
||||
worlds.value = worlds.value.filter((w) => w.type !== 'server' || w.index !== server.index)
|
||||
}
|
||||
|
||||
async function editWorld(path: string, name: string, removeIcon: boolean) {
|
||||
const world = worlds.value.find((world) => world.type === 'singleplayer' && world.path === path)
|
||||
if (world) {
|
||||
world.name = name
|
||||
if (removeIcon) {
|
||||
world.icon = undefined
|
||||
}
|
||||
sortWorlds(worlds.value)
|
||||
} else {
|
||||
handleError(`Error finding world in list, refreshing all worlds`)
|
||||
await refreshAllWorlds()
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteWorld(world: SingleplayerWorld) {
|
||||
await delete_world(instance.value.path, world.path).catch(handleError)
|
||||
worlds.value = worlds.value.filter((w) => w.type !== 'singleplayer' || w.path !== world.path)
|
||||
}
|
||||
|
||||
function handleJoinError(err: unknown) {
|
||||
handleError(err)
|
||||
startingInstance.value = false
|
||||
worldPlaying.value = undefined
|
||||
}
|
||||
|
||||
async function joinWorld(world: World) {
|
||||
console.log(`Joining world ${getWorldIdentifier(world)}`)
|
||||
startingInstance.value = true
|
||||
worldPlaying.value = world
|
||||
if (world.type === 'server') {
|
||||
await start_join_server(instance.value.path, world.address).catch(handleJoinError)
|
||||
} else if (world.type === 'singleplayer') {
|
||||
await start_join_singleplayer_world(instance.value.path, world.path).catch(handleJoinError)
|
||||
}
|
||||
play(world)
|
||||
startingInstance.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => playing.value,
|
||||
(playing) => {
|
||||
if (!playing) {
|
||||
worldPlaying.value = undefined
|
||||
|
||||
setTimeout(async () => {
|
||||
for (const world of worlds.value) {
|
||||
if (world.type === 'singleplayer' && world.locked) {
|
||||
await refreshWorld(worlds.value, instance.value.path, world.path)
|
||||
}
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function worldsMatch(world: World, other: World | undefined) {
|
||||
if (world.type === 'server' && other?.type === 'server') {
|
||||
return world.address === other.address
|
||||
} else if (world.type === 'singleplayer' && other?.type === 'singleplayer') {
|
||||
return world.path === other.path
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const gameVersions = ref<GameVersion[]>(await get_game_versions().catch(() => []))
|
||||
const supportsQuickPlay = computed(() =>
|
||||
hasQuickPlaySupport(gameVersions.value, instance.value.game_version),
|
||||
)
|
||||
|
||||
const filterOptions = computed(() => {
|
||||
const options: FilterBarOption[] = []
|
||||
|
||||
const hasServer = worlds.value.some((x) => x.type === 'server')
|
||||
|
||||
if (worlds.value.some((x) => x.type === 'singleplayer') && hasServer) {
|
||||
options.push({
|
||||
id: 'singleplayer',
|
||||
message: messages.singleplayer,
|
||||
})
|
||||
options.push({
|
||||
id: 'server',
|
||||
message: messages.server,
|
||||
})
|
||||
}
|
||||
|
||||
if (hasServer) {
|
||||
// add available filter if there's any offline ("unavailable") servers AND there's any singleplayer worlds or available servers
|
||||
if (
|
||||
worlds.value.some(
|
||||
(x) =>
|
||||
x.type === 'server' &&
|
||||
!serverData.value[x.address]?.status &&
|
||||
!serverData.value[x.address]?.refreshing,
|
||||
) &&
|
||||
worlds.value.some(
|
||||
(x) =>
|
||||
x.type === 'singleplayer' ||
|
||||
(x.type === 'server' &&
|
||||
serverData.value[x.address]?.status &&
|
||||
!serverData.value[x.address]?.refreshing),
|
||||
)
|
||||
) {
|
||||
options.push({
|
||||
id: 'available',
|
||||
message: messages.available,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return options
|
||||
})
|
||||
|
||||
const filteredWorlds = computed(() =>
|
||||
worlds.value.filter((x) => {
|
||||
const availableFilter = filters.value.includes('available')
|
||||
const typeFilter = filters.value.includes('server') || filters.value.includes('singleplayer')
|
||||
|
||||
return (
|
||||
(!typeFilter || filters.value.includes(x.type)) &&
|
||||
(!availableFilter || x.type !== 'server' || serverData.value[x.address]?.status) &&
|
||||
(!searchFilter.value || x.name.toLowerCase().includes(searchFilter.value.toLowerCase()))
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const highlightedWorld = ref(route.query.highlight)
|
||||
|
||||
function promptToRemoveWorld(world: World): boolean {
|
||||
if (world.type === 'server') {
|
||||
serverToRemove.value = world
|
||||
removeServerModal.value?.show()
|
||||
return !!removeServerModal.value
|
||||
} else {
|
||||
worldToDelete.value = world
|
||||
deleteWorldModal.value?.show()
|
||||
return !!deleteWorldModal.value
|
||||
}
|
||||
}
|
||||
|
||||
async function proceedRemoveServer() {
|
||||
if (!serverToRemove.value) {
|
||||
handleError(`Error removing server, no server marked for removal.`)
|
||||
return
|
||||
}
|
||||
await removeServer(serverToRemove.value)
|
||||
serverToRemove.value = undefined
|
||||
}
|
||||
|
||||
async function proceedDeleteWorld() {
|
||||
if (!worldToDelete.value) {
|
||||
handleError(`Error deleting world, no world marked for removal.`)
|
||||
return
|
||||
}
|
||||
await deleteWorld(worldToDelete.value)
|
||||
worldToDelete.value = undefined
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
unlistenProfile()
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
singleplayer: {
|
||||
id: 'instance.worlds.type.singleplayer',
|
||||
defaultMessage: 'Singleplayer',
|
||||
},
|
||||
server: {
|
||||
id: 'instance.worlds.type.server',
|
||||
defaultMessage: 'Server',
|
||||
},
|
||||
available: {
|
||||
id: 'instance.worlds.filter.available',
|
||||
defaultMessage: 'Available',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -1,5 +1,7 @@
|
||||
import Index from './Index.vue'
|
||||
import Overview from './Overview.vue'
|
||||
import Worlds from './Worlds.vue'
|
||||
import Mods from './Mods.vue'
|
||||
import Logs from './Logs.vue'
|
||||
|
||||
export { Index, Mods, Logs }
|
||||
export { Index, Overview, Worlds, Mods, Logs }
|
||||
|
||||
@@ -155,7 +155,7 @@ import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
|
||||
import { get as getInstance, get_projects as getInstanceProjects } from '@/helpers/profile'
|
||||
import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ref, shallowRef, watch } from 'vue'
|
||||
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
||||
import { handleError } from '@/store/notifications.js'
|
||||
@@ -170,6 +170,7 @@ import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const breadcrumbs = useBreadcrumbs()
|
||||
const themeStore = useTheming()
|
||||
|
||||
@@ -192,6 +193,11 @@ const [allLoaders, allGameVersions] = await Promise.all([
|
||||
async function fetchProjectData() {
|
||||
const project = await get_project(route.params.id, 'must_revalidate').catch(handleError)
|
||||
|
||||
if (!project) {
|
||||
handleError('Error loading project')
|
||||
return
|
||||
}
|
||||
|
||||
data.value = project
|
||||
;[versions.value, members.value, categories.value, instance.value, instanceProjects.value] =
|
||||
await Promise.all([
|
||||
@@ -242,6 +248,9 @@ async function install(version) {
|
||||
installedVersion.value = version
|
||||
}
|
||||
},
|
||||
(profile) => {
|
||||
router.push(`/instance/${profile}`)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,14 @@ export default new createRouter({
|
||||
breadcrumb: [{ name: 'Home' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/worlds',
|
||||
name: 'Worlds',
|
||||
component: Pages.Worlds,
|
||||
meta: {
|
||||
breadcrumb: [{ name: 'Worlds' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/browse/:projectType',
|
||||
name: 'Discover content',
|
||||
@@ -106,13 +114,31 @@ export default new createRouter({
|
||||
component: Instance.Index,
|
||||
props: true,
|
||||
children: [
|
||||
// {
|
||||
// path: '',
|
||||
// name: 'Overview',
|
||||
// component: Instance.Overview,
|
||||
// meta: {
|
||||
// useRootContext: true,
|
||||
// breadcrumb: [{ name: '?Instance' }],
|
||||
// },
|
||||
// },
|
||||
{
|
||||
path: 'worlds',
|
||||
name: 'InstanceWorlds',
|
||||
component: Instance.Worlds,
|
||||
meta: {
|
||||
useRootContext: true,
|
||||
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Worlds' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
name: 'Mods',
|
||||
component: Instance.Mods,
|
||||
meta: {
|
||||
useRootContext: true,
|
||||
breadcrumb: [{ name: '?Instance' }],
|
||||
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Content' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -121,7 +147,7 @@ export default new createRouter({
|
||||
component: Instance.Mods,
|
||||
meta: {
|
||||
useRootContext: true,
|
||||
breadcrumb: [{ name: '?Instance' }],
|
||||
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Content' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -23,8 +23,8 @@ export const useInstall = defineStore('installStore', {
|
||||
setInstallConfirmModal(ref) {
|
||||
this.installConfirmModal = ref
|
||||
},
|
||||
showInstallConfirmModal(project, version_id, onInstall) {
|
||||
this.installConfirmModal.show(project, version_id, onInstall)
|
||||
showInstallConfirmModal(project, version_id, onInstall, createInstanceCallback) {
|
||||
this.installConfirmModal.show(project, version_id, onInstall, createInstanceCallback)
|
||||
},
|
||||
setIncompatibilityWarningModal(ref) {
|
||||
this.incompatibilityWarningModal = ref
|
||||
@@ -41,7 +41,14 @@ export const useInstall = defineStore('installStore', {
|
||||
},
|
||||
})
|
||||
|
||||
export const install = async (projectId, versionId, instancePath, source, callback = () => {}) => {
|
||||
export const install = async (
|
||||
projectId,
|
||||
versionId,
|
||||
instancePath,
|
||||
source,
|
||||
callback = () => {},
|
||||
createInstanceCallback = () => {},
|
||||
) => {
|
||||
const project = await get_project(projectId, 'must_revalidate').catch(handleError)
|
||||
|
||||
if (project.project_type === 'modpack') {
|
||||
@@ -49,7 +56,13 @@ export const install = async (projectId, versionId, instancePath, source, callba
|
||||
const packs = await list().catch(handleError)
|
||||
|
||||
if (packs.length === 0 || !packs.find((pack) => pack.linked_data?.project_id === project.id)) {
|
||||
await packInstall(project.id, version, project.title, project.icon_url).catch(handleError)
|
||||
await packInstall(
|
||||
project.id,
|
||||
version,
|
||||
project.title,
|
||||
project.icon_url,
|
||||
createInstanceCallback,
|
||||
).catch(handleError)
|
||||
|
||||
trackEvent('PackInstall', {
|
||||
id: project.id,
|
||||
@@ -61,7 +74,7 @@ export const install = async (projectId, versionId, instancePath, source, callba
|
||||
callback(version)
|
||||
} else {
|
||||
const install = useInstall()
|
||||
install.showInstallConfirmModal(project, version, callback)
|
||||
install.showInstallConfirmModal(project, version, callback, createInstanceCallback)
|
||||
}
|
||||
} else {
|
||||
if (instancePath) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useTheming } from './theme'
|
||||
import { useTheming } from './theme.ts'
|
||||
import { useBreadcrumbs } from './breadcrumbs'
|
||||
import { useLoading } from './loading'
|
||||
import { useNotifications, handleError } from './notifications'
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useTheming = defineStore('themeStore', {
|
||||
state: () => ({
|
||||
themeOptions: ['dark', 'light', 'oled', 'system'],
|
||||
advancedRendering: true,
|
||||
selectedTheme: 'dark',
|
||||
toggleSidebar: false,
|
||||
|
||||
devMode: false,
|
||||
featureFlags: {},
|
||||
}),
|
||||
actions: {
|
||||
setThemeState(newTheme) {
|
||||
if (this.themeOptions.includes(newTheme)) this.selectedTheme = newTheme
|
||||
else console.warn('Selected theme is not present. Check themeOptions.')
|
||||
|
||||
this.setThemeClass()
|
||||
},
|
||||
setThemeClass() {
|
||||
for (const theme of this.themeOptions) {
|
||||
document.getElementsByTagName('html')[0].classList.remove(`${theme}-mode`)
|
||||
}
|
||||
|
||||
let theme = this.selectedTheme
|
||||
if (this.selectedTheme === 'system') {
|
||||
const darkThemeMq = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
if (darkThemeMq.matches) {
|
||||
theme = 'dark'
|
||||
} else {
|
||||
theme = 'light'
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementsByTagName('html')[0].classList.add(`${theme}-mode`)
|
||||
},
|
||||
},
|
||||
})
|
||||
70
apps/app-frontend/src/store/theme.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const DEFAULT_FEATURE_FLAGS = {
|
||||
project_background: false,
|
||||
page_path: false,
|
||||
worlds_tab: false,
|
||||
worlds_in_home: true,
|
||||
}
|
||||
|
||||
export const THEME_OPTIONS = ['dark', 'light', 'oled', 'system'] as const
|
||||
|
||||
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
|
||||
export type FeatureFlags = Record<FeatureFlag, boolean>
|
||||
export type ColorTheme = (typeof THEME_OPTIONS)[number]
|
||||
|
||||
export type ThemeStore = {
|
||||
selectedTheme: ColorTheme
|
||||
advancedRendering: boolean
|
||||
toggleSidebar: boolean
|
||||
|
||||
devMode: boolean
|
||||
featureFlags: FeatureFlags
|
||||
}
|
||||
|
||||
export const DEFAULT_THEME_STORE: ThemeStore = {
|
||||
selectedTheme: 'dark',
|
||||
advancedRendering: true,
|
||||
toggleSidebar: false,
|
||||
|
||||
devMode: false,
|
||||
featureFlags: DEFAULT_FEATURE_FLAGS,
|
||||
}
|
||||
|
||||
export const useTheming = defineStore('themeStore', {
|
||||
state: () => DEFAULT_THEME_STORE,
|
||||
actions: {
|
||||
setThemeState(newTheme: ColorTheme) {
|
||||
if (THEME_OPTIONS.includes(newTheme)) {
|
||||
this.selectedTheme = newTheme
|
||||
} else {
|
||||
console.warn('Selected theme is not present. Check themeOptions.')
|
||||
}
|
||||
|
||||
this.setThemeClass()
|
||||
},
|
||||
setThemeClass() {
|
||||
for (const theme of THEME_OPTIONS) {
|
||||
document.getElementsByTagName('html')[0].classList.remove(`${theme}-mode`)
|
||||
}
|
||||
|
||||
let theme = this.selectedTheme
|
||||
if (this.selectedTheme === 'system') {
|
||||
const darkThemeMq = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
if (darkThemeMq.matches) {
|
||||
theme = 'dark'
|
||||
} else {
|
||||
theme = 'light'
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementsByTagName('html')[0].classList.add(`${theme}-mode`)
|
||||
},
|
||||
getFeatureFlag(key: FeatureFlag) {
|
||||
return this.featureFlags[key] ?? DEFAULT_FEATURE_FLAGS[key]
|
||||
},
|
||||
getThemeOptions() {
|
||||
return THEME_OPTIONS
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,24 +1,14 @@
|
||||
[package]
|
||||
name = "theseus_playground"
|
||||
version = "0.0.0"
|
||||
edition = "2021"
|
||||
edition.workspace = true
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
theseus = { path = "../../packages/app-lib", features = ["cli"] }
|
||||
theseus = { workspace = true, features = ["cli"] }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
enumset.workspace = true
|
||||
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
thiserror = "1.0"
|
||||
url = "2.2"
|
||||
webbrowser = "0.8.13"
|
||||
dunce = "1.0.3"
|
||||
|
||||
futures = "0.3"
|
||||
uuid = { version = "1.1", features = ["serde", "v4"] }
|
||||
|
||||
tracing = "0.1.37"
|
||||
tracing-subscriber = "0.3.18"
|
||||
tracing-error = "0.2.0"
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
"name": "@modrinth/app-playground",
|
||||
"scripts": {
|
||||
"build": "cargo build --release",
|
||||
"lint": "cargo fmt --check && cargo clippy --all-targets --all-features -- -D warnings",
|
||||
"fix": "cargo fmt && cargo clippy --fix",
|
||||
"lint": "cargo fmt --check && cargo clippy --all-targets",
|
||||
"fix": "cargo clippy --all-targets --fix --allow-dirty && cargo fmt",
|
||||
"dev": "cargo run",
|
||||
"test": "cargo test"
|
||||
"test": "cargo nextest run --all-targets --no-fail-fast"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
windows_subsystem = "windows"
|
||||
)]
|
||||
|
||||
use std::time::Duration;
|
||||
use enumset::EnumSet;
|
||||
use theseus::prelude::*;
|
||||
use tokio::signal::ctrl_c;
|
||||
use theseus::worlds::get_recent_worlds;
|
||||
|
||||
// A simple Rust implementation of the authentication run
|
||||
// 1) call the authenticate_begin_flow() function to get the URL to open (like you would in the frontend)
|
||||
@@ -15,8 +15,7 @@ pub async fn authenticate_run() -> theseus::Result<Credentials> {
|
||||
println!("A browser window will now open, follow the login flow there.");
|
||||
let login = minecraft_auth::begin_login().await?;
|
||||
|
||||
println!("URL {}", login.redirect_uri.as_str());
|
||||
webbrowser::open(login.redirect_uri.as_str())?;
|
||||
println!("Open URL {} in a browser", login.redirect_uri.as_str());
|
||||
|
||||
println!("Please enter URL code: ");
|
||||
let mut input = String::new();
|
||||
@@ -41,21 +40,16 @@ async fn main() -> theseus::Result<()> {
|
||||
// Initialize state
|
||||
State::init().await?;
|
||||
|
||||
loop {
|
||||
if State::get().await?.friends_socket.is_connected().await {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
let worlds = get_recent_worlds(4, EnumSet::all()).await?;
|
||||
for world in worlds {
|
||||
println!(
|
||||
"World: {:?}/{:?} played at {:?}: {:#?}",
|
||||
world.profile,
|
||||
world.world.name,
|
||||
world.world.last_played,
|
||||
world.world.details
|
||||
);
|
||||
}
|
||||
|
||||
tracing::info!("Starting host");
|
||||
|
||||
let socket = State::get().await?.friends_socket.open_port(25565).await?;
|
||||
tracing::info!("Running host on socket {}", socket.socket_id());
|
||||
|
||||
ctrl_c().await?;
|
||||
tracing::info!("Stopping host");
|
||||
socket.shutdown().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,66 +1,51 @@
|
||||
[package]
|
||||
name = "theseus_gui"
|
||||
version = "0.9.3"
|
||||
version = "0.9.5"
|
||||
description = "The Modrinth App is a desktop application for managing your Minecraft mods"
|
||||
license = "GPL-3.0-only"
|
||||
repository = "https://github.com/modrinth/code/apps/app/"
|
||||
edition = "2021"
|
||||
build = "build.rs"
|
||||
edition.workspace = true
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.0.3", features = ["codegen"] }
|
||||
tauri-build = { workspace = true, features = ["codegen"] }
|
||||
|
||||
[dependencies]
|
||||
theseus = { path = "../../packages/app-lib", features = ["tauri"] }
|
||||
theseus = { workspace = true, features = ["tauri"] }
|
||||
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_with = "3.0.0"
|
||||
serde_json.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_with.workspace = true
|
||||
|
||||
tauri = { version = "2.1.1", features = ["devtools", "macos-private-api", "protocol-asset", "unstable"] }
|
||||
tauri-plugin-window-state = "2.2.0"
|
||||
tauri-plugin-deep-link = "2.2.0"
|
||||
tauri-plugin-os = "2.2.0"
|
||||
tauri-plugin-opener = "2.2.1"
|
||||
tauri-plugin-dialog = "2.2.0"
|
||||
tauri-plugin-updater = { version = "2.3.0" }
|
||||
tauri-plugin-single-instance = { version = "2.2.0" }
|
||||
tauri = { workspace = true, features = ["devtools", "macos-private-api", "protocol-asset"] }
|
||||
tauri-plugin-window-state.workspace = true
|
||||
tauri-plugin-deep-link.workspace = true
|
||||
tauri-plugin-os.workspace = true
|
||||
tauri-plugin-opener.workspace = true
|
||||
tauri-plugin-dialog.workspace = true
|
||||
tauri-plugin-updater.workspace = true
|
||||
tauri-plugin-single-instance.workspace = true
|
||||
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
thiserror = "1.0"
|
||||
futures = "0.3"
|
||||
daedalus = { path = "../../packages/daedalus" }
|
||||
chrono = "0.4.26"
|
||||
tokio = { workspace = true, features = ["time"] }
|
||||
thiserror.workspace = true
|
||||
daedalus.workspace = true
|
||||
chrono.workspace = true
|
||||
either.workspace = true
|
||||
|
||||
dirs = "5.0.1"
|
||||
url.workspace = true
|
||||
urlencoding.workspace = true
|
||||
uuid = { workspace = true, features = ["serde", "v4"] }
|
||||
|
||||
url = "2.2"
|
||||
uuid = { version = "1.1", features = ["serde", "v4"] }
|
||||
os_info = "3.7.0"
|
||||
tracing.workspace = true
|
||||
tracing-error.workspace = true
|
||||
|
||||
tracing = "0.1.37"
|
||||
tracing-error = "0.2.0"
|
||||
dashmap.workspace = true
|
||||
paste.workspace = true
|
||||
enumset = { workspace = true, features = ["serde"] }
|
||||
|
||||
lazy_static = "1"
|
||||
once_cell = "1"
|
||||
|
||||
dashmap = "6.0.1"
|
||||
paste = "1.0.15"
|
||||
|
||||
opener = { version = "0.7.2", features = ["reveal", "dbus-vendored"] }
|
||||
|
||||
native-dialog = "0.7.0"
|
||||
|
||||
[target.'cfg(not(target_os = "linux"))'.dependencies]
|
||||
window-shadows = "0.2.1"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
cocoa = "0.25.0"
|
||||
objc = "0.2.7"
|
||||
rand = "0.8.5"
|
||||
native-dialog.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
tauri-plugin-updater = { version = "2.3.0", optional = true, features = ["native-tls-vendored", "zip"], default-features = false }
|
||||
tauri-plugin-updater = { workspace = true, optional = true }
|
||||
|
||||
[features]
|
||||
# by default Tauri runs in production mode
|
||||
@@ -70,3 +55,6 @@ default = ["custom-protocol"]
|
||||
# DO NOT remove this
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
updater = []
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -226,6 +226,30 @@ fn main() {
|
||||
.default_permission(
|
||||
DefaultPermissionRule::AllowAllCommands,
|
||||
),
|
||||
)
|
||||
.plugin(
|
||||
"worlds",
|
||||
InlinedPlugin::new()
|
||||
.commands(&[
|
||||
"get_recent_worlds",
|
||||
"get_profile_worlds",
|
||||
"get_singleplayer_world",
|
||||
"set_world_display_status",
|
||||
"rename_world",
|
||||
"reset_world_icon",
|
||||
"backup_world",
|
||||
"delete_world",
|
||||
"add_server_to_profile",
|
||||
"edit_server_in_profile",
|
||||
"remove_server_from_profile",
|
||||
"get_profile_protocol_version",
|
||||
"get_server_status",
|
||||
"start_join_singleplayer_world",
|
||||
"start_join_server",
|
||||
])
|
||||
.default_permission(
|
||||
DefaultPermissionRule::AllowAllCommands,
|
||||
),
|
||||
),
|
||||
)
|
||||
.expect("Failed to run tauri-build");
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"settings:default",
|
||||
"tags:default",
|
||||
"utils:default",
|
||||
"friends:default"
|
||||
"friends:default",
|
||||
"worlds:default"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"name": "@modrinth/app",
|
||||
"scripts": {
|
||||
"build": "tauri build",
|
||||
"tauri": "tauri",
|
||||
"build": "tauri build",
|
||||
"dev": "tauri dev",
|
||||
"test": "cargo test",
|
||||
"lint": "cargo fmt --check && cargo clippy --all-targets -- -D warnings",
|
||||
"fix": "cargo fmt && cargo clippy --fix"
|
||||
"test": "cargo nextest run --all-targets --no-fail-fast",
|
||||
"lint": "cargo fmt --check && cargo clippy --all-targets",
|
||||
"fix": "cargo clippy --all-targets --fix --allow-dirty && cargo fmt"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "2.1.0"
|
||||
"@tauri-apps/cli": "2.5.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modrinth/app-frontend": "workspace:*",
|
||||
|
||||
@@ -18,6 +18,7 @@ pub mod utils;
|
||||
|
||||
pub mod cache;
|
||||
pub mod friends;
|
||||
pub mod worlds;
|
||||
|
||||
pub type Result<T> = std::result::Result<T, TheseusSerializableError>;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use theseus::prelude::*;
|
||||
use theseus::profile::QuickPlayType;
|
||||
|
||||
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
tauri::plugin::Builder::new("profile")
|
||||
@@ -250,7 +251,7 @@ pub async fn profile_get_pack_export_candidates(
|
||||
// invoke('plugin:profile|profile_run', path)
|
||||
#[tauri::command]
|
||||
pub async fn profile_run(path: &str) -> Result<ProcessMetadata> {
|
||||
let process = profile::run(path).await?;
|
||||
let process = profile::run(path, &QuickPlayType::None).await?;
|
||||
|
||||
Ok(process)
|
||||
}
|
||||
@@ -264,7 +265,9 @@ pub async fn profile_run_credentials(
|
||||
path: &str,
|
||||
credentials: Credentials,
|
||||
) -> Result<ProcessMetadata> {
|
||||
let process = profile::run_credentials(path, &credentials).await?;
|
||||
let process =
|
||||
profile::run_credentials(path, &credentials, &QuickPlayType::None)
|
||||
.await?;
|
||||
|
||||
Ok(process)
|
||||
}
|
||||
@@ -347,6 +350,9 @@ pub async fn profile_edit(path: &str, edit_profile: EditProfile) -> Result<()> {
|
||||
prof.name = name;
|
||||
}
|
||||
if let Some(game_version) = edit_profile.game_version.clone() {
|
||||
if game_version != prof.game_version {
|
||||
prof.protocol_version = None;
|
||||
}
|
||||
prof.game_version = game_version;
|
||||
}
|
||||
if let Some(loader) = edit_profile.loader {
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::Runtime;
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
use theseus::{
|
||||
handler,
|
||||
prelude::{CommandPayload, DirectoryInfo},
|
||||
};
|
||||
|
||||
use crate::api::Result;
|
||||
use crate::api::{Result, TheseusSerializableError};
|
||||
use dashmap::DashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use theseus::prelude::canonicalize;
|
||||
use url::Url;
|
||||
|
||||
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
tauri::plugin::Builder::new("utils")
|
||||
@@ -40,6 +44,7 @@ pub fn get_os() -> OS {
|
||||
let os = OS::MacOS;
|
||||
os
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
pub enum OS {
|
||||
@@ -52,57 +57,56 @@ pub enum OS {
|
||||
// Create a new HashMap with the same keys
|
||||
// Values provided should not be used directly, as they are not guaranteed to be up-to-date
|
||||
#[tauri::command]
|
||||
pub async fn progress_bars_list(
|
||||
) -> Result<DashMap<uuid::Uuid, theseus::LoadingBar>> {
|
||||
pub async fn progress_bars_list()
|
||||
-> Result<DashMap<uuid::Uuid, theseus::LoadingBar>> {
|
||||
let res = theseus::EventState::list_progress_bars().await?;
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
// cfg only on mac os
|
||||
// disables mouseover and fixes a random crash error only fixed by recent versions of macos
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tauri::command]
|
||||
pub async fn should_disable_mouseover() -> bool {
|
||||
// We try to match version to 12.2 or higher. If unrecognizable to pattern or lower, we default to the css with disabled mouseover for safety
|
||||
let os = os_info::get();
|
||||
if let os_info::Version::Semantic(major, minor, _) = os.version() {
|
||||
if *major >= 12 && *minor >= 3 {
|
||||
// Mac os version is 12.3 or higher, we allow mouseover
|
||||
return false;
|
||||
if cfg!(target_os = "macos") {
|
||||
// We try to match version to 12.2 or higher. If unrecognizable to pattern or lower, we default to the css with disabled mouseover for safety
|
||||
if let tauri_plugin_os::Version::Semantic(major, minor, _) =
|
||||
tauri_plugin_os::version()
|
||||
{
|
||||
if major >= 12 && minor >= 3 {
|
||||
// Mac os version is 12.3 or higher, we allow mouseover
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
} else {
|
||||
// Not macos, we allow mouseover
|
||||
false
|
||||
}
|
||||
true
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[tauri::command]
|
||||
pub async fn should_disable_mouseover() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn highlight_in_folder(path: PathBuf) {
|
||||
let res = opener::reveal(path);
|
||||
|
||||
if let Err(e) = res {
|
||||
pub fn highlight_in_folder<R: Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
path: PathBuf,
|
||||
) {
|
||||
if let Err(e) = app.opener().reveal_item_in_dir(path) {
|
||||
tracing::error!("Failed to highlight file in folder: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_path(path: PathBuf) {
|
||||
let res = opener::open(path);
|
||||
|
||||
if let Err(e) = res {
|
||||
pub fn open_path<R: Runtime>(app: tauri::AppHandle<R>, path: PathBuf) {
|
||||
if let Err(e) = app.opener().open_path(path.to_string_lossy(), None::<&str>)
|
||||
{
|
||||
tracing::error!("Failed to open path: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn show_launcher_logs_folder() {
|
||||
pub fn show_launcher_logs_folder<R: Runtime>(app: tauri::AppHandle<R>) {
|
||||
let path = DirectoryInfo::launcher_logs_dir().unwrap_or_default();
|
||||
// failure to get folder just opens filesystem
|
||||
// (ie: if in debug mode only and launcher_logs never created)
|
||||
open_path(path);
|
||||
open_path(app, path);
|
||||
}
|
||||
|
||||
// Get opening command
|
||||
@@ -147,3 +151,28 @@ pub async fn handle_command(command: String) -> Result<()> {
|
||||
tracing::info!("handle command: {command}");
|
||||
Ok(theseus::handler::parse_and_emit_command(&command).await?)
|
||||
}
|
||||
|
||||
// Remove when (and if) https://github.com/tauri-apps/tauri/issues/12022 is implemented
|
||||
pub(crate) fn tauri_convert_file_src(path: &Path) -> Result<Url> {
|
||||
#[cfg(any(windows, target_os = "android"))]
|
||||
const BASE: &str = "http://asset.localhost/";
|
||||
#[cfg(not(any(windows, target_os = "android")))]
|
||||
const BASE: &str = "asset://localhost/";
|
||||
|
||||
macro_rules! theseus_try {
|
||||
($test:expr) => {
|
||||
match $test {
|
||||
Ok(val) => val,
|
||||
Err(e) => {
|
||||
return Err(TheseusSerializableError::Theseus(e.into()))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let path = theseus_try!(canonicalize(path));
|
||||
let path = path.to_string_lossy();
|
||||
let encoded = urlencoding::encode(&path);
|
||||
|
||||
Ok(theseus_try!(Url::parse(&format!("{BASE}{encoded}"))))
|
||||
}
|
||||
|
||||
218
apps/app/src/api/worlds.rs
Normal file
@@ -0,0 +1,218 @@
|
||||
use crate::api::Result;
|
||||
use either::Either;
|
||||
use enumset::EnumSet;
|
||||
use tauri::{AppHandle, Manager, Runtime};
|
||||
use theseus::prelude::ProcessMetadata;
|
||||
use theseus::profile::{QuickPlayType, get_full_path};
|
||||
use theseus::worlds::{
|
||||
DisplayStatus, ServerPackStatus, ServerStatus, World, WorldType,
|
||||
WorldWithProfile,
|
||||
};
|
||||
use theseus::{profile, worlds};
|
||||
|
||||
pub fn init<R: Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
tauri::plugin::Builder::new("worlds")
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_recent_worlds,
|
||||
get_profile_worlds,
|
||||
get_singleplayer_world,
|
||||
set_world_display_status,
|
||||
rename_world,
|
||||
reset_world_icon,
|
||||
backup_world,
|
||||
delete_world,
|
||||
add_server_to_profile,
|
||||
edit_server_in_profile,
|
||||
remove_server_from_profile,
|
||||
get_profile_protocol_version,
|
||||
get_server_status,
|
||||
start_join_singleplayer_world,
|
||||
start_join_server,
|
||||
])
|
||||
.build()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_recent_worlds<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
limit: usize,
|
||||
display_statuses: Option<EnumSet<DisplayStatus>>,
|
||||
) -> Result<Vec<WorldWithProfile>> {
|
||||
let mut result = worlds::get_recent_worlds(
|
||||
limit,
|
||||
display_statuses.unwrap_or(EnumSet::all()),
|
||||
)
|
||||
.await?;
|
||||
for world in &mut result {
|
||||
adapt_world_icon(&app_handle, &mut world.world);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_profile_worlds<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
path: &str,
|
||||
) -> Result<Vec<World>> {
|
||||
let mut result = worlds::get_profile_worlds(path).await?;
|
||||
for world in &mut result {
|
||||
adapt_world_icon(&app_handle, world);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_singleplayer_world<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
instance: &str,
|
||||
world: &str,
|
||||
) -> Result<World> {
|
||||
let mut world = worlds::get_singleplayer_world(instance, world).await?;
|
||||
adapt_world_icon(&app_handle, &mut world);
|
||||
Ok(world)
|
||||
}
|
||||
|
||||
fn adapt_world_icon<R: Runtime>(app_handle: &AppHandle<R>, world: &mut World) {
|
||||
if let Some(Either::Left(icon_path)) = &world.icon {
|
||||
let icon_path = icon_path.clone();
|
||||
if let Ok(new_url) = super::utils::tauri_convert_file_src(&icon_path) {
|
||||
world.icon = Some(Either::Right(new_url));
|
||||
if let Err(e) =
|
||||
app_handle.asset_protocol_scope().allow_file(&icon_path)
|
||||
{
|
||||
tracing::warn!(
|
||||
"Failed to allow file access for icon {}: {}",
|
||||
icon_path.display(),
|
||||
e
|
||||
);
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"Encountered invalid icon path for world {}: {}",
|
||||
world.name,
|
||||
icon_path.display()
|
||||
);
|
||||
world.icon = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_world_display_status(
|
||||
instance: &str,
|
||||
world_type: WorldType,
|
||||
world_id: &str,
|
||||
display_status: DisplayStatus,
|
||||
) -> Result<()> {
|
||||
Ok(worlds::set_world_display_status(
|
||||
instance,
|
||||
world_type,
|
||||
world_id,
|
||||
display_status,
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn rename_world(
|
||||
instance: &str,
|
||||
world: &str,
|
||||
new_name: &str,
|
||||
) -> Result<()> {
|
||||
let instance = get_full_path(instance).await?;
|
||||
worlds::rename_world(&instance, world, new_name).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn reset_world_icon(instance: &str, world: &str) -> Result<()> {
|
||||
let instance = get_full_path(instance).await?;
|
||||
worlds::reset_world_icon(&instance, world).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn backup_world(instance: &str, world: &str) -> Result<u64> {
|
||||
let instance = get_full_path(instance).await?;
|
||||
Ok(worlds::backup_world(&instance, world).await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_world(instance: &str, world: &str) -> Result<()> {
|
||||
let instance = get_full_path(instance).await?;
|
||||
worlds::delete_world(&instance, world).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn add_server_to_profile(
|
||||
path: &str,
|
||||
name: String,
|
||||
address: String,
|
||||
pack_status: ServerPackStatus,
|
||||
) -> Result<usize> {
|
||||
let path = get_full_path(path).await?;
|
||||
Ok(
|
||||
worlds::add_server_to_profile(&path, name, address, pack_status)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn edit_server_in_profile(
|
||||
path: &str,
|
||||
index: usize,
|
||||
name: String,
|
||||
address: String,
|
||||
pack_status: ServerPackStatus,
|
||||
) -> Result<()> {
|
||||
let path = get_full_path(path).await?;
|
||||
worlds::edit_server_in_profile(&path, index, name, address, pack_status)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remove_server_from_profile(
|
||||
path: &str,
|
||||
index: usize,
|
||||
) -> Result<()> {
|
||||
let path = get_full_path(path).await?;
|
||||
worlds::remove_server_from_profile(&path, index).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_profile_protocol_version(path: &str) -> Result<Option<i32>> {
|
||||
Ok(worlds::get_profile_protocol_version(path).await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_server_status(
|
||||
address: &str,
|
||||
protocol_version: Option<i32>,
|
||||
) -> Result<ServerStatus> {
|
||||
Ok(worlds::get_server_status(address, protocol_version).await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_join_singleplayer_world(
|
||||
path: &str,
|
||||
world: String,
|
||||
) -> Result<ProcessMetadata> {
|
||||
let process =
|
||||
profile::run(path, &QuickPlayType::Singleplayer(world)).await?;
|
||||
|
||||
Ok(process)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_join_server(
|
||||
path: &str,
|
||||
address: &str,
|
||||
) -> Result<ProcessMetadata> {
|
||||
let process =
|
||||
profile::run(path, &QuickPlayType::Server(address.to_owned())).await?;
|
||||
|
||||
Ok(process)
|
||||
}
|
||||
@@ -11,7 +11,8 @@ pub fn get_or_init_payload<R: Runtime, M: Manager<R>>(
|
||||
manager: &M,
|
||||
) -> InitialPayload {
|
||||
let initial_payload = manager.try_state::<InitialPayload>();
|
||||
let mtx = if let Some(initial_payload) = initial_payload {
|
||||
|
||||
if let Some(initial_payload) = initial_payload {
|
||||
initial_payload.inner().clone()
|
||||
} else {
|
||||
tracing::info!("No initial payload found, creating new");
|
||||
@@ -22,7 +23,5 @@ pub fn get_or_init_payload<R: Runtime, M: Manager<R>>(
|
||||
manager.manage(payload.clone());
|
||||
|
||||
payload
|
||||
};
|
||||
|
||||
mtx
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
pub mod deep_link;
|
||||
pub mod window_ext;
|
||||
|
||||
@@ -1,412 +0,0 @@
|
||||
// Stolen from https://gist.github.com/charrondev/43150e940bd2771b1ea88256d491c7a9
|
||||
use objc::{msg_send, sel, sel_impl};
|
||||
use rand::{distributions::Alphanumeric, Rng};
|
||||
use tauri::{
|
||||
plugin::{Builder, TauriPlugin},
|
||||
Emitter, Runtime, Window,
|
||||
}; // 0.8
|
||||
|
||||
const WINDOW_CONTROL_PAD_X: f64 = 9.0;
|
||||
const WINDOW_CONTROL_PAD_Y: f64 = 10.0;
|
||||
|
||||
struct UnsafeWindowHandle(*mut std::ffi::c_void);
|
||||
unsafe impl Send for UnsafeWindowHandle {}
|
||||
unsafe impl Sync for UnsafeWindowHandle {}
|
||||
|
||||
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
Builder::new("traffic_light_positioner")
|
||||
.on_window_ready(|window| {
|
||||
#[cfg(target_os = "macos")]
|
||||
setup_traffic_light_positioner(window);
|
||||
})
|
||||
.build()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn position_traffic_lights(
|
||||
ns_window_handle: UnsafeWindowHandle,
|
||||
x: f64,
|
||||
y: f64,
|
||||
) {
|
||||
use cocoa::appkit::{NSView, NSWindow, NSWindowButton};
|
||||
use cocoa::foundation::NSRect;
|
||||
let ns_window = ns_window_handle.0 as cocoa::base::id;
|
||||
unsafe {
|
||||
let close = ns_window
|
||||
.standardWindowButton_(NSWindowButton::NSWindowCloseButton);
|
||||
let miniaturize = ns_window
|
||||
.standardWindowButton_(NSWindowButton::NSWindowMiniaturizeButton);
|
||||
let zoom =
|
||||
ns_window.standardWindowButton_(NSWindowButton::NSWindowZoomButton);
|
||||
|
||||
let title_bar_container_view = close.superview().superview();
|
||||
|
||||
let close_rect: NSRect = msg_send![close, frame];
|
||||
let button_height = close_rect.size.height + 12.0;
|
||||
|
||||
let title_bar_frame_height = button_height + y;
|
||||
let mut title_bar_rect = NSView::frame(title_bar_container_view);
|
||||
title_bar_rect.size.height = title_bar_frame_height;
|
||||
title_bar_rect.origin.y =
|
||||
NSView::frame(ns_window).size.height - title_bar_frame_height;
|
||||
let _: () =
|
||||
msg_send![title_bar_container_view, setFrame: title_bar_rect];
|
||||
|
||||
let window_buttons = vec![close, miniaturize, zoom];
|
||||
let space_between =
|
||||
NSView::frame(miniaturize).origin.x - NSView::frame(close).origin.x;
|
||||
|
||||
for (i, button) in window_buttons.into_iter().enumerate() {
|
||||
let mut rect: NSRect = NSView::frame(button);
|
||||
rect.origin.x = x + (i as f64 * space_between) + 6.0;
|
||||
button.setFrameOrigin(rect.origin);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[derive(Debug)]
|
||||
struct WindowState<R: Runtime> {
|
||||
window: Window<R>,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn setup_traffic_light_positioner<R: Runtime>(window: Window<R>) {
|
||||
use cocoa::appkit::NSWindow;
|
||||
use cocoa::base::{id, BOOL};
|
||||
use cocoa::foundation::NSUInteger;
|
||||
use objc::runtime::{Object, Sel};
|
||||
use std::ffi::c_void;
|
||||
|
||||
// Do the initial positioning
|
||||
position_traffic_lights(
|
||||
UnsafeWindowHandle(
|
||||
window.ns_window().expect("Failed to create window handle"),
|
||||
),
|
||||
WINDOW_CONTROL_PAD_X,
|
||||
WINDOW_CONTROL_PAD_Y,
|
||||
);
|
||||
|
||||
// Ensure they stay in place while resizing the window.
|
||||
fn with_window_state<R: Runtime, F: FnOnce(&mut WindowState<R>) -> T, T>(
|
||||
this: &Object,
|
||||
func: F,
|
||||
) {
|
||||
let ptr = unsafe {
|
||||
let x: *mut c_void = *this.get_ivar("app_box");
|
||||
&mut *(x as *mut WindowState<R>)
|
||||
};
|
||||
func(ptr);
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let ns_win = window
|
||||
.ns_window()
|
||||
.expect("NS Window should exist to mount traffic light delegate.")
|
||||
as id;
|
||||
|
||||
let current_delegate: id = ns_win.delegate();
|
||||
|
||||
extern "C" fn on_window_should_close(
|
||||
this: &Object,
|
||||
_cmd: Sel,
|
||||
sender: id,
|
||||
) -> BOOL {
|
||||
unsafe {
|
||||
let super_del: id = *this.get_ivar("super_delegate");
|
||||
msg_send![super_del, windowShouldClose: sender]
|
||||
}
|
||||
}
|
||||
extern "C" fn on_window_will_close(
|
||||
this: &Object,
|
||||
_cmd: Sel,
|
||||
notification: id,
|
||||
) {
|
||||
unsafe {
|
||||
let super_del: id = *this.get_ivar("super_delegate");
|
||||
let _: () = msg_send![super_del, windowWillClose: notification];
|
||||
}
|
||||
}
|
||||
extern "C" fn on_window_did_resize<R: Runtime>(
|
||||
this: &Object,
|
||||
_cmd: Sel,
|
||||
notification: id,
|
||||
) {
|
||||
unsafe {
|
||||
with_window_state(this, |state: &mut WindowState<R>| {
|
||||
let id = state.window.ns_window().expect(
|
||||
"NS window should exist on state to handle resize",
|
||||
) as id;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
position_traffic_lights(
|
||||
UnsafeWindowHandle(id as *mut std::ffi::c_void),
|
||||
WINDOW_CONTROL_PAD_X,
|
||||
WINDOW_CONTROL_PAD_Y,
|
||||
);
|
||||
});
|
||||
|
||||
let super_del: id = *this.get_ivar("super_delegate");
|
||||
let _: () = msg_send![super_del, windowDidResize: notification];
|
||||
}
|
||||
}
|
||||
extern "C" fn on_window_did_move(
|
||||
this: &Object,
|
||||
_cmd: Sel,
|
||||
notification: id,
|
||||
) {
|
||||
unsafe {
|
||||
let super_del: id = *this.get_ivar("super_delegate");
|
||||
let _: () = msg_send![super_del, windowDidMove: notification];
|
||||
}
|
||||
}
|
||||
extern "C" fn on_window_did_change_backing_properties(
|
||||
this: &Object,
|
||||
_cmd: Sel,
|
||||
notification: id,
|
||||
) {
|
||||
unsafe {
|
||||
let super_del: id = *this.get_ivar("super_delegate");
|
||||
let _: () = msg_send![super_del, windowDidChangeBackingProperties: notification];
|
||||
}
|
||||
}
|
||||
extern "C" fn on_window_did_become_key(
|
||||
this: &Object,
|
||||
_cmd: Sel,
|
||||
notification: id,
|
||||
) {
|
||||
unsafe {
|
||||
let super_del: id = *this.get_ivar("super_delegate");
|
||||
let _: () =
|
||||
msg_send![super_del, windowDidBecomeKey: notification];
|
||||
}
|
||||
}
|
||||
extern "C" fn on_window_did_resign_key(
|
||||
this: &Object,
|
||||
_cmd: Sel,
|
||||
notification: id,
|
||||
) {
|
||||
unsafe {
|
||||
let super_del: id = *this.get_ivar("super_delegate");
|
||||
let _: () =
|
||||
msg_send![super_del, windowDidResignKey: notification];
|
||||
}
|
||||
}
|
||||
extern "C" fn on_dragging_entered(
|
||||
this: &Object,
|
||||
_cmd: Sel,
|
||||
notification: id,
|
||||
) -> BOOL {
|
||||
unsafe {
|
||||
let super_del: id = *this.get_ivar("super_delegate");
|
||||
msg_send![super_del, draggingEntered: notification]
|
||||
}
|
||||
}
|
||||
extern "C" fn on_prepare_for_drag_operation(
|
||||
this: &Object,
|
||||
_cmd: Sel,
|
||||
notification: id,
|
||||
) -> BOOL {
|
||||
unsafe {
|
||||
let super_del: id = *this.get_ivar("super_delegate");
|
||||
msg_send![super_del, prepareForDragOperation: notification]
|
||||
}
|
||||
}
|
||||
extern "C" fn on_perform_drag_operation(
|
||||
this: &Object,
|
||||
_cmd: Sel,
|
||||
sender: id,
|
||||
) -> BOOL {
|
||||
unsafe {
|
||||
let super_del: id = *this.get_ivar("super_delegate");
|
||||
msg_send![super_del, performDragOperation: sender]
|
||||
}
|
||||
}
|
||||
extern "C" fn on_conclude_drag_operation(
|
||||
this: &Object,
|
||||
_cmd: Sel,
|
||||
notification: id,
|
||||
) {
|
||||
unsafe {
|
||||
let super_del: id = *this.get_ivar("super_delegate");
|
||||
let _: () =
|
||||
msg_send![super_del, concludeDragOperation: notification];
|
||||
}
|
||||
}
|
||||
extern "C" fn on_dragging_exited(
|
||||
this: &Object,
|
||||
_cmd: Sel,
|
||||
notification: id,
|
||||
) {
|
||||
unsafe {
|
||||
let super_del: id = *this.get_ivar("super_delegate");
|
||||
let _: () = msg_send![super_del, draggingExited: notification];
|
||||
}
|
||||
}
|
||||
extern "C" fn on_window_will_use_full_screen_presentation_options(
|
||||
this: &Object,
|
||||
_cmd: Sel,
|
||||
window: id,
|
||||
proposed_options: NSUInteger,
|
||||
) -> NSUInteger {
|
||||
unsafe {
|
||||
let super_del: id = *this.get_ivar("super_delegate");
|
||||
msg_send![super_del, window: window willUseFullScreenPresentationOptions: proposed_options]
|
||||
}
|
||||
}
|
||||
extern "C" fn on_window_did_enter_full_screen<R: Runtime>(
|
||||
this: &Object,
|
||||
_cmd: Sel,
|
||||
notification: id,
|
||||
) {
|
||||
unsafe {
|
||||
with_window_state(this, |state: &mut WindowState<R>| {
|
||||
state
|
||||
.window
|
||||
.emit("did-enter-fullscreen", ())
|
||||
.expect("Failed to emit event");
|
||||
});
|
||||
|
||||
let super_del: id = *this.get_ivar("super_delegate");
|
||||
let _: () = msg_send![super_del, windowDidEnterFullScreen: notification];
|
||||
}
|
||||
}
|
||||
extern "C" fn on_window_will_enter_full_screen<R: Runtime>(
|
||||
this: &Object,
|
||||
_cmd: Sel,
|
||||
notification: id,
|
||||
) {
|
||||
unsafe {
|
||||
with_window_state(this, |state: &mut WindowState<R>| {
|
||||
state
|
||||
.window
|
||||
.emit("will-enter-fullscreen", ())
|
||||
.expect("Failed to emit event");
|
||||
});
|
||||
|
||||
let super_del: id = *this.get_ivar("super_delegate");
|
||||
let _: () = msg_send![super_del, windowWillEnterFullScreen: notification];
|
||||
}
|
||||
}
|
||||
extern "C" fn on_window_did_exit_full_screen<R: Runtime>(
|
||||
this: &Object,
|
||||
_cmd: Sel,
|
||||
notification: id,
|
||||
) {
|
||||
unsafe {
|
||||
with_window_state(this, |state: &mut WindowState<R>| {
|
||||
state
|
||||
.window
|
||||
.emit("did-exit-fullscreen", ())
|
||||
.expect("Failed to emit event");
|
||||
|
||||
let id =
|
||||
state.window.ns_window().expect("Failed to emit event")
|
||||
as id;
|
||||
position_traffic_lights(
|
||||
UnsafeWindowHandle(id as *mut std::ffi::c_void),
|
||||
WINDOW_CONTROL_PAD_X,
|
||||
WINDOW_CONTROL_PAD_Y,
|
||||
);
|
||||
});
|
||||
|
||||
let super_del: id = *this.get_ivar("super_delegate");
|
||||
let _: () =
|
||||
msg_send![super_del, windowDidExitFullScreen: notification];
|
||||
}
|
||||
}
|
||||
extern "C" fn on_window_will_exit_full_screen<R: Runtime>(
|
||||
this: &Object,
|
||||
_cmd: Sel,
|
||||
notification: id,
|
||||
) {
|
||||
unsafe {
|
||||
with_window_state(this, |state: &mut WindowState<R>| {
|
||||
state
|
||||
.window
|
||||
.emit("will-exit-fullscreen", ())
|
||||
.expect("Failed to emit event");
|
||||
});
|
||||
|
||||
let super_del: id = *this.get_ivar("super_delegate");
|
||||
let _: () = msg_send![super_del, windowWillExitFullScreen: notification];
|
||||
}
|
||||
}
|
||||
extern "C" fn on_window_did_fail_to_enter_full_screen(
|
||||
this: &Object,
|
||||
_cmd: Sel,
|
||||
window: id,
|
||||
) {
|
||||
unsafe {
|
||||
let super_del: id = *this.get_ivar("super_delegate");
|
||||
let _: () = msg_send![super_del, windowDidFailToEnterFullScreen: window];
|
||||
}
|
||||
}
|
||||
extern "C" fn on_effective_appearance_did_change(
|
||||
this: &Object,
|
||||
_cmd: Sel,
|
||||
notification: id,
|
||||
) {
|
||||
unsafe {
|
||||
let super_del: id = *this.get_ivar("super_delegate");
|
||||
let _: () = msg_send![super_del, effectiveAppearanceDidChange: notification];
|
||||
}
|
||||
}
|
||||
extern "C" fn on_effective_appearance_did_changed_on_main_thread(
|
||||
this: &Object,
|
||||
_cmd: Sel,
|
||||
notification: id,
|
||||
) {
|
||||
unsafe {
|
||||
let super_del: id = *this.get_ivar("super_delegate");
|
||||
let _: () = msg_send![
|
||||
super_del,
|
||||
effectiveAppearanceDidChangedOnMainThread: notification
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Are we deallocing this properly ? (I miss safe Rust :( )
|
||||
let window_label = window.label().to_string();
|
||||
|
||||
let app_state = WindowState { window };
|
||||
let app_box = Box::into_raw(Box::new(app_state)) as *mut c_void;
|
||||
let random_str: String = rand::thread_rng()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(20)
|
||||
.map(char::from)
|
||||
.collect();
|
||||
|
||||
// We need to ensure we have a unique delegate name, otherwise we will panic while trying to create a duplicate
|
||||
// delegate with the same name.
|
||||
let delegate_name =
|
||||
format!("windowDelegate_{}_{}", window_label, random_str);
|
||||
|
||||
ns_win.setDelegate_(delegate!(&delegate_name, {
|
||||
window: id = ns_win,
|
||||
app_box: *mut c_void = app_box,
|
||||
toolbar: id = cocoa::base::nil,
|
||||
super_delegate: id = current_delegate,
|
||||
(windowShouldClose:) => on_window_should_close as extern fn(&Object, Sel, id) -> BOOL,
|
||||
(windowWillClose:) => on_window_will_close as extern fn(&Object, Sel, id),
|
||||
(windowDidResize:) => on_window_did_resize::<R> as extern fn(&Object, Sel, id),
|
||||
(windowDidMove:) => on_window_did_move as extern fn(&Object, Sel, id),
|
||||
(windowDidChangeBackingProperties:) => on_window_did_change_backing_properties as extern fn(&Object, Sel, id),
|
||||
(windowDidBecomeKey:) => on_window_did_become_key as extern fn(&Object, Sel, id),
|
||||
(windowDidResignKey:) => on_window_did_resign_key as extern fn(&Object, Sel, id),
|
||||
(draggingEntered:) => on_dragging_entered as extern fn(&Object, Sel, id) -> BOOL,
|
||||
(prepareForDragOperation:) => on_prepare_for_drag_operation as extern fn(&Object, Sel, id) -> BOOL,
|
||||
(performDragOperation:) => on_perform_drag_operation as extern fn(&Object, Sel, id) -> BOOL,
|
||||
(concludeDragOperation:) => on_conclude_drag_operation as extern fn(&Object, Sel, id),
|
||||
(draggingExited:) => on_dragging_exited as extern fn(&Object, Sel, id),
|
||||
(window:willUseFullScreenPresentationOptions:) => on_window_will_use_full_screen_presentation_options as extern fn(&Object, Sel, id, NSUInteger) -> NSUInteger,
|
||||
(windowDidEnterFullScreen:) => on_window_did_enter_full_screen::<R> as extern fn(&Object, Sel, id),
|
||||
(windowWillEnterFullScreen:) => on_window_will_enter_full_screen::<R> as extern fn(&Object, Sel, id),
|
||||
(windowDidExitFullScreen:) => on_window_did_exit_full_screen::<R> as extern fn(&Object, Sel, id),
|
||||
(windowWillExitFullScreen:) => on_window_will_exit_full_screen::<R> as extern fn(&Object, Sel, id),
|
||||
(windowDidFailToEnterFullScreen:) => on_window_did_fail_to_enter_full_screen as extern fn(&Object, Sel, id),
|
||||
(effectiveAppearanceDidChange:) => on_effective_appearance_did_change as extern fn(&Object, Sel, id),
|
||||
(effectiveAppearanceDidChangedOnMainThread:) => on_effective_appearance_did_changed_on_main_thread as extern fn(&Object, Sel, id)
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
windows_subsystem = "windows"
|
||||
)]
|
||||
|
||||
use native_dialog::{MessageDialog, MessageType};
|
||||
use native_dialog::{DialogBuilder, MessageLevel};
|
||||
use std::env;
|
||||
use tauri::{Listener, Manager};
|
||||
use theseus::prelude::*;
|
||||
@@ -14,14 +14,6 @@ mod error;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[macro_use]
|
||||
extern crate cocoa;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[macro_use]
|
||||
extern crate objc;
|
||||
|
||||
// Should be called in launcher initialization
|
||||
#[tracing::instrument(skip_all)]
|
||||
#[tauri::command]
|
||||
@@ -113,14 +105,14 @@ async fn initialize_state(app: tauri::AppHandle) -> api::Result<()> {
|
||||
fn show_window(app: tauri::AppHandle) {
|
||||
let win = app.get_window("main").unwrap();
|
||||
if let Err(e) = win.show() {
|
||||
MessageDialog::new()
|
||||
.set_type(MessageType::Error)
|
||||
DialogBuilder::message()
|
||||
.set_level(MessageLevel::Error)
|
||||
.set_title("Initialization error")
|
||||
.set_text(&format!(
|
||||
"Cannot display application window due to an error:\n{}",
|
||||
e
|
||||
.set_text(format!(
|
||||
"Cannot display application window due to an error:\n{e}"
|
||||
))
|
||||
.show_alert()
|
||||
.alert()
|
||||
.show()
|
||||
.unwrap();
|
||||
panic!("cannot display application window")
|
||||
} else {
|
||||
@@ -138,8 +130,7 @@ fn is_dev() -> bool {
|
||||
async fn toggle_decorations(b: bool, window: tauri::Window) -> api::Result<()> {
|
||||
window.set_decorations(b).map_err(|e| {
|
||||
theseus::Error::from(theseus::ErrorKind::OtherError(format!(
|
||||
"Failed to toggle decorations: {}",
|
||||
e
|
||||
"Failed to toggle decorations: {e}"
|
||||
)))
|
||||
})?;
|
||||
Ok(())
|
||||
@@ -219,7 +210,7 @@ fn main() {
|
||||
{
|
||||
let payload = macos::deep_link::get_or_init_payload(app);
|
||||
|
||||
let mtx_copy = payload.payload.clone();
|
||||
let mtx_copy = payload.payload;
|
||||
app.listen("deep-link://new-url", move |url| {
|
||||
let mtx_copy_copy = mtx_copy.clone();
|
||||
let request = url.payload().to_owned();
|
||||
@@ -251,13 +242,12 @@ fn main() {
|
||||
tauri::async_runtime::spawn(api::utils::handle_command(
|
||||
payload,
|
||||
));
|
||||
dbg!(url);
|
||||
});
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
if let Some(window) = app.get_window("main") {
|
||||
window.set_shadow(true).unwrap();
|
||||
if let Some(window) = app.get_window("main") {
|
||||
if let Err(e) = window.set_shadow(true) {
|
||||
tracing::warn!("Failed to set window shadow: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,6 +270,7 @@ fn main() {
|
||||
.plugin(api::utils::init())
|
||||
.plugin(api::cache::init())
|
||||
.plugin(api::friends::init())
|
||||
.plugin(api::worlds::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
initialize_state,
|
||||
is_dev,
|
||||
@@ -288,32 +279,27 @@ fn main() {
|
||||
restart_app,
|
||||
]);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
builder = builder.plugin(macos::window_ext::init());
|
||||
}
|
||||
|
||||
tracing::info!("Initializing app...");
|
||||
let app = builder.build(tauri::generate_context!());
|
||||
|
||||
match app {
|
||||
Ok(app) => {
|
||||
#[allow(unused_variables)]
|
||||
app.run(|app, event| {
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
drop((app, event));
|
||||
#[cfg(target_os = "macos")]
|
||||
if let tauri::RunEvent::Opened { urls } = event {
|
||||
tracing::info!("Handling webview open {urls:?}");
|
||||
|
||||
let file = urls
|
||||
.into_iter()
|
||||
.filter_map(|url| url.to_file_path().ok())
|
||||
.next();
|
||||
.find_map(|url| url.to_file_path().ok());
|
||||
|
||||
if let Some(file) = file {
|
||||
let payload =
|
||||
macos::deep_link::get_or_init_payload(app);
|
||||
|
||||
let mtx_copy = payload.payload.clone();
|
||||
let mtx_copy = payload.payload;
|
||||
let request = file.to_string_lossy().to_string();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut payload = mtx_copy.lock().await;
|
||||
@@ -331,28 +317,29 @@ fn main() {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// tauri doesn't expose runtime errors, so matching a string representation seems like the only solution
|
||||
if format!("{:?}", e).contains(
|
||||
if format!("{e:?}").contains(
|
||||
"Runtime(CreateWebview(WebView2Error(WindowsError",
|
||||
) {
|
||||
MessageDialog::new()
|
||||
.set_type(MessageType::Error)
|
||||
DialogBuilder::message()
|
||||
.set_level(MessageLevel::Error)
|
||||
.set_title("Initialization error")
|
||||
.set_text("Your Microsoft Edge WebView2 installation is corrupt.\n\nMicrosoft Edge WebView2 is required to run Modrinth App.\n\nLearn how to repair it at https://support.modrinth.com/en/articles/8797765-corrupted-microsoft-edge-webview2-installation")
|
||||
.show_alert()
|
||||
.alert()
|
||||
.show()
|
||||
.unwrap();
|
||||
|
||||
panic!("webview2 initialization failed")
|
||||
}
|
||||
}
|
||||
|
||||
MessageDialog::new()
|
||||
.set_type(MessageType::Error)
|
||||
DialogBuilder::message()
|
||||
.set_level(MessageLevel::Error)
|
||||
.set_title("Initialization error")
|
||||
.set_text(&format!(
|
||||
"Cannot initialize application due to an error:\n{:?}",
|
||||
e
|
||||
.set_text(format!(
|
||||
"Cannot initialize application due to an error:\n{e:?}"
|
||||
))
|
||||
.show_alert()
|
||||
.alert()
|
||||
.show()
|
||||
.unwrap();
|
||||
|
||||
tracing::error!("Error while running tauri application: {:?}", e);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm turbo run dev --filter=@modrinth/app-frontend",
|
||||
"beforeBuildCommand": "pnpm turbo run build --filter=@modrinth/app-frontend",
|
||||
@@ -44,7 +45,7 @@
|
||||
]
|
||||
},
|
||||
"productName": "AstralRinth App",
|
||||
"version": "0.9.304",
|
||||
"version": "0.9.501",
|
||||
"mainBinaryName": "AstralRinth App",
|
||||
"identifier": "AstralRinthApp",
|
||||
"plugins": {
|
||||
@@ -76,18 +77,26 @@
|
||||
],
|
||||
"security": {
|
||||
"assetProtocol": {
|
||||
"scope": ["$APPDATA/caches/icons/*", "$APPCONFIG/caches/icons/*", "$CONFIG/caches/icons/*"],
|
||||
"scope": [
|
||||
"$APPDATA/caches/icons/*",
|
||||
"$APPCONFIG/caches/icons/*",
|
||||
"$CONFIG/caches/icons/*",
|
||||
"$APPDATA/profiles/*/saves/*/icon.png",
|
||||
"$APPCONFIG/profiles/*/saves/*/icon.png",
|
||||
"$CONFIG/profiles/*/saves/*/icon.png"
|
||||
],
|
||||
"enable": true
|
||||
},
|
||||
"capabilities": ["core", "plugins"],
|
||||
"csp": {
|
||||
"default-src": "'self' customprotocol: asset:",
|
||||
"connect-src": "https://api.github.com ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.posthog.com https://*.sentry.io https://api.mclo.gs",
|
||||
"font-src": ["https://cdn-raw.modrinth.com/fonts/inter/"],
|
||||
"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",
|
||||
"font-src": ["https://cdn-raw.modrinth.com/fonts/"],
|
||||
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost blob: data:",
|
||||
"style-src": "'unsafe-inline' 'self'",
|
||||
"script-src": "https://*.posthog.com 'self'",
|
||||
"frame-src": "https://www.youtube.com https://www.youtube-nocookie.com https://discord.com 'self'"
|
||||
"frame-src": "https://www.youtube.com https://www.youtube-nocookie.com https://discord.com 'self'",
|
||||
"media-src": "https://*.githubusercontent.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
14
apps/app/turbo.jsonc
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "../../node_modules/turbo/schema.json",
|
||||
"extends": ["//"],
|
||||
"tasks": {
|
||||
// Running Clippy and tests on a Tauri application requires
|
||||
// the frontend to be built at least once first
|
||||
"lint": {
|
||||
"dependsOn": ["@modrinth/app-frontend#build"]
|
||||
},
|
||||
"test": {
|
||||
"dependsOn": ["@modrinth/app-frontend#build"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,41 +2,32 @@
|
||||
name = "daedalus_client"
|
||||
version = "0.2.2"
|
||||
authors = ["Jai A <jai@modrinth.com>"]
|
||||
edition = "2021"
|
||||
edition.workspace = true
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
daedalus = { path = "../../packages/daedalus" }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
futures = "0.3.25"
|
||||
dotenvy = "0.15.6"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
serde-xml-rs = "0.6.0"
|
||||
lazy_static = "1.4.0"
|
||||
thiserror = "1.0"
|
||||
reqwest = { version = "0.12.5", default-features = false, features = [
|
||||
"stream",
|
||||
"json",
|
||||
"rustls-tls-native-roots",
|
||||
] }
|
||||
async_zip = { version = "0.0.17", features = ["full"] }
|
||||
semver = "1.0"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
bytes = "1.6.0"
|
||||
rust-s3 = { version = "0.33.0", default-features = false, features = [
|
||||
"fail-on-err",
|
||||
"tags",
|
||||
"tokio-rustls-tls",
|
||||
"reqwest",
|
||||
] }
|
||||
dashmap = "5.5.3"
|
||||
sha1_smol = { version = "1.0.0", features = ["std"] }
|
||||
indexmap = { version = "2.2.6", features = ["serde"] }
|
||||
itertools = "0.13.0"
|
||||
tracing-error = "0.2.0"
|
||||
daedalus.workspace = true
|
||||
tokio = { workspace = true, features = ["sync", "macros", "rt-multi-thread"] }
|
||||
futures.workspace = true
|
||||
dotenvy.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
serde-xml-rs.workspace = true
|
||||
thiserror.workspace = true
|
||||
reqwest = { workspace = true, features = ["stream", "json", "rustls-tls-native-roots"] }
|
||||
async_zip = { workspace = true, features = ["chrono", "tokio-fs", "deflate", "bzip2", "zstd", "deflate64"] }
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
bytes.workspace = true
|
||||
rust-s3.workspace = true
|
||||
dashmap.workspace = true
|
||||
sha1_smol.workspace = true
|
||||
indexmap = { workspace = true, features = ["serde"] }
|
||||
itertools.workspace = true
|
||||
tracing-error.workspace = true
|
||||
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tracing-futures = { version = "0.2.5", features = ["futures", "tokio"] }
|
||||
tracing.workspace = true
|
||||
tracing-subscriber = { workspace = true, features = ["env-filter"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM rust:1.82.0 as build
|
||||
FROM rust:1.87.0 AS build
|
||||
ENV PKG_CONFIG_ALLOW_CROSS=1
|
||||
|
||||
WORKDIR /usr/src/daedalus
|
||||
@@ -9,9 +9,9 @@ RUN cargo build --release --package daedalus_client
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates openssl \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
&& apt-get install -y --no-install-recommends ca-certificates openssl \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN update-ca-certificates
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
"name": "@modrinth/daedalus_client",
|
||||
"scripts": {
|
||||
"build": "cargo build --release",
|
||||
"lint": "cargo fmt --check && cargo clippy --all-targets --all-features -- -D warnings",
|
||||
"fix": "cargo fmt && cargo clippy --fix",
|
||||
"lint": "cargo fmt --check && cargo clippy --all-targets",
|
||||
"fix": "cargo clippy --all-targets --fix --allow-dirty && cargo fmt",
|
||||
"dev": "cargo run",
|
||||
"test": "cargo test"
|
||||
"test": "cargo nextest run --all-targets --no-fail-fast"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modrinth/daedalus": "workspace:*"
|
||||
|
||||
@@ -12,7 +12,9 @@ pub enum ErrorKind {
|
||||
SerdeJSON(#[from] serde_json::Error),
|
||||
#[error("Error while deserializing XML: {0}")]
|
||||
SerdeXML(#[from] serde_xml_rs::Error),
|
||||
#[error("Failed to validate file checksum at url {url} with hash {hash} after {tries} tries")]
|
||||
#[error(
|
||||
"Failed to validate file checksum at url {url} with hash {hash} after {tries} tries"
|
||||
)]
|
||||
ChecksumFailure {
|
||||
hash: String,
|
||||
url: String,
|
||||
@@ -22,7 +24,7 @@ pub enum ErrorKind {
|
||||
Fetch { inner: reqwest::Error, item: String },
|
||||
#[error("Error while uploading file to S3: {file}")]
|
||||
S3 {
|
||||
inner: s3::error::S3Error,
|
||||
inner: Box<s3::error::S3Error>,
|
||||
file: String,
|
||||
},
|
||||
#[error("Error acquiring semaphore: {0}")]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::util::{download_file, fetch_json, format_url};
|
||||
use crate::{insert_mirrored_artifact, Error, MirrorArtifact, UploadFile};
|
||||
use daedalus::modded::{Manifest, PartialVersionInfo, DUMMY_REPLACE_STRING};
|
||||
use crate::{Error, MirrorArtifact, UploadFile, insert_mirrored_artifact};
|
||||
use daedalus::modded::{DUMMY_REPLACE_STRING, Manifest, PartialVersionInfo};
|
||||
use dashmap::DashMap;
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
@@ -169,10 +169,11 @@ async fn fetch(
|
||||
insert_mirrored_artifact(
|
||||
&new_name,
|
||||
None,
|
||||
vec![lib
|
||||
.url
|
||||
.clone()
|
||||
.unwrap_or_else(|| maven_url.to_string())],
|
||||
vec![
|
||||
lib.url
|
||||
.clone()
|
||||
.unwrap_or_else(|| maven_url.to_string()),
|
||||
],
|
||||
false,
|
||||
mirror_artifacts,
|
||||
)?;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::util::{download_file, fetch_json, fetch_xml, format_url};
|
||||
use crate::{insert_mirrored_artifact, Error, MirrorArtifact, UploadFile};
|
||||
use crate::{Error, MirrorArtifact, UploadFile, insert_mirrored_artifact};
|
||||
use chrono::{DateTime, Utc};
|
||||
use daedalus::get_path_from_artifact;
|
||||
use daedalus::modded::PartialVersionInfo;
|
||||
@@ -7,8 +7,8 @@ use dashmap::DashMap;
|
||||
use futures::io::Cursor;
|
||||
use indexmap::IndexMap;
|
||||
use itertools::Itertools;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Deserialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Semaphore;
|
||||
@@ -57,7 +57,7 @@ pub async fn fetch_forge(
|
||||
|
||||
ForgeVersion {
|
||||
format_version,
|
||||
installer_url: format!("https://maven.minecraftforge.net/net/minecraftforge/forge/{0}/forge-{0}-installer.jar", loader_version),
|
||||
installer_url: format!("https://maven.minecraftforge.net/net/minecraftforge/forge/{loader_version}/forge-{loader_version}-installer.jar"),
|
||||
raw: loader_version,
|
||||
loader_version: version_split,
|
||||
game_version: game_version.clone(),
|
||||
@@ -137,7 +137,7 @@ pub async fn fetch_neo(
|
||||
|
||||
Ok(ForgeVersion {
|
||||
format_version: 2,
|
||||
installer_url: format!("https://maven.neoforged.net/net/neoforged/forge/{0}/forge-{0}-installer.jar", loader_version),
|
||||
installer_url: format!("https://maven.neoforged.net/net/neoforged/forge/{loader_version}/forge-{loader_version}-installer.jar"),
|
||||
raw: loader_version,
|
||||
loader_version: version_split,
|
||||
game_version: "1.20.1".to_string(), // All NeoForge Forge versions are for 1.20.1
|
||||
@@ -163,7 +163,7 @@ pub async fn fetch_neo(
|
||||
|
||||
Ok(ForgeVersion {
|
||||
format_version: 2,
|
||||
installer_url: format!("https://maven.neoforged.net/net/neoforged/neoforge/{0}/neoforge-{0}-installer.jar", loader_version),
|
||||
installer_url: format!("https://maven.neoforged.net/net/neoforged/neoforge/{loader_version}/neoforge-{loader_version}-installer.jar"),
|
||||
loader_version: loader_version.clone(),
|
||||
raw: loader_version,
|
||||
game_version,
|
||||
@@ -502,7 +502,7 @@ async fn fetch(
|
||||
)?;
|
||||
|
||||
artifact.url =
|
||||
format_url(&format!("maven/{}", artifact_path));
|
||||
format_url(&format!("maven/{artifact_path}"));
|
||||
|
||||
return Ok(lib);
|
||||
}
|
||||
@@ -589,16 +589,18 @@ async fn fetch(
|
||||
mod_loader: &str,
|
||||
version: &ForgeVersion,
|
||||
) -> Result<String, Error> {
|
||||
let extract_file =
|
||||
read_file(zip, &value[1..value.len()])
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InvalidInput(format!(
|
||||
let extract_file = read_file(
|
||||
zip,
|
||||
&value[1..value.len()],
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InvalidInput(format!(
|
||||
"Unable reading data key {key} at path {value}",
|
||||
))
|
||||
})?;
|
||||
})?;
|
||||
|
||||
let file_name = value.split('/').last()
|
||||
let file_name = value.split('/').next_back()
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InvalidInput(format!(
|
||||
"Unable reading filename for data key {key} at path {value}",
|
||||
@@ -622,10 +624,7 @@ async fn fetch(
|
||||
|
||||
let path = format!(
|
||||
"com.modrinth.daedalus:{}-installer-extracts:{}:{}@{}",
|
||||
mod_loader,
|
||||
version.raw,
|
||||
file_name,
|
||||
ext
|
||||
mod_loader, version.raw, file_name, ext
|
||||
);
|
||||
|
||||
upload_files.insert(
|
||||
@@ -753,7 +752,8 @@ async fn fetch(
|
||||
.rev()
|
||||
.chunk_by(|x| x.game_version.clone())
|
||||
.into_iter()
|
||||
.map(|(game_version, loaders)| daedalus::modded::Version {
|
||||
.map(|(game_version, loaders)| {
|
||||
daedalus::modded::Version {
|
||||
id: game_version,
|
||||
stable: true,
|
||||
loaders: loaders
|
||||
@@ -766,6 +766,7 @@ async fn fetch(
|
||||
stable: false,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
use crate::util::{
|
||||
format_url, upload_file_to_bucket, upload_url_to_bucket_mirrors,
|
||||
REQWEST_CLIENT,
|
||||
REQWEST_CLIENT, format_url, upload_file_to_bucket,
|
||||
upload_url_to_bucket_mirrors,
|
||||
};
|
||||
use daedalus::get_path_from_artifact;
|
||||
use dashmap::{DashMap, DashSet};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Semaphore;
|
||||
use tracing_error::ErrorLayer;
|
||||
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
|
||||
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
|
||||
|
||||
mod error;
|
||||
mod fabric;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use crate::util::fetch_json;
|
||||
use crate::{
|
||||
util::download_file, util::format_url, util::sha1_async, Error,
|
||||
MirrorArtifact, UploadFile,
|
||||
Error, MirrorArtifact, UploadFile, util::download_file, util::format_url,
|
||||
util::sha1_async,
|
||||
};
|
||||
use daedalus::minecraft::{
|
||||
merge_partial_library, Library, PartialLibrary, VersionInfo,
|
||||
VersionManifest, VERSION_MANIFEST_URL,
|
||||
Library, PartialLibrary, VERSION_MANIFEST_URL, VersionInfo,
|
||||
VersionManifest, merge_partial_library,
|
||||
};
|
||||
use dashmap::DashMap;
|
||||
use serde::Deserialize;
|
||||
@@ -52,8 +52,7 @@ pub async fn fetch(
|
||||
if modrinth_version
|
||||
.original_sha1
|
||||
.as_ref()
|
||||
.map(|x| x == &version.sha1)
|
||||
.unwrap_or(false)
|
||||
.is_some_and(|x| x == &version.sha1)
|
||||
{
|
||||
existing_versions.push(modrinth_version);
|
||||
} else {
|
||||
|
||||