34 Commits

Author SHA1 Message Date
neoapps-dev f0e0ba69dc chore: update LCELive button logo 2026-05-13 03:07:55 +03:00
neoapps-dev 9ec65fbcc8 feat(LCELive): outgoing friend requests 2026-05-13 03:00:06 +03:00
neoapps-dev 5a0b7f1a26 chore: discord link AGAIN 2026-05-13 02:45:09 +03:00
neoapps-dev 887f0d5bd0 chore: document use of refreshSession on each request() 2026-05-13 02:29:22 +03:00
neoapps-dev 3b91e96074 feat: listen on 255.255.255.255:25566 instead of servers.db 2026-05-13 02:27:24 +03:00
neoapps-dev 76bca26143 feat: migrate from unzip to bsdtar on linux 2026-05-13 01:47:20 +03:00
neoapps-dev a4ce0686ad feat: add andi's skin 2026-05-13 01:06:05 +03:00
neoapps-dev 96eec9d979 feaat(LCELiveView): fullscreen modals 2026-05-13 00:57:49 +03:00
neoapps-dev 1600b2e942 feat: LCELive notifs everywhere 2026-05-13 00:48:00 +03:00
neoapps-dev 29916d5c8a chore: prepend underscore to unused var 2026-05-13 00:28:38 +03:00
neoapps-dev 1b5acadf99 fix(LCELive): relay joining 2026-05-12 22:05:59 +03:00
neoapps-dev 8817b1e611 fix(LCELive): relay joining 2026-05-12 21:57:56 +03:00
neoapps-dev 9d078eaf2f feat(LCELive): relay support 2026-05-12 21:46:12 +03:00
neoapps-dev 4b577b5034 fix: world hosting 2026-05-12 20:59:18 +03:00
neoapps-dev 15d40c6961 fix: typescript errors 2026-05-12 20:15:57 +03:00
neoapps-dev c10e4d20ab feat: initial implementation of LCELive networking 2026-05-12 20:12:46 +03:00
neoapps-dev 026d284644 fix: capitalization issues 2026-05-12 11:18:52 +03:00
neoapps-dev 34332e99ee feat: MangoHud support 2026-05-12 09:52:27 +03:00
neoapps-dev c5ff9c6ed7 feat: button for LCELive 2026-05-12 09:34:19 +03:00
neoapps-dev eb0e90f92b feat: cherry pick edits from ui-change 2026-05-12 07:37:13 +03:00
neoapps-dev 37b3c93604 feat: cherry pick edits from ui-change 2026-05-12 07:35:53 +03:00
neoapps-dev b2b279be38 feat: introduce ScreenshotImage and get rid of screenshots:// 2026-05-12 06:56:46 +03:00
neoapps-dev 803c1b8762 feat: refactor panorama to allow custom panorama id per instance 2026-05-12 06:49:36 +03:00
neoapps-dev 912a604971 feat: LCELive token refresh on request 2026-05-10 20:30:09 +03:00
neoapps-dev a16e3acc91 feat: LCELive notifs 2026-05-10 19:16:50 +03:00
neoapps-dev 6c591729ff feat: auto update and remove nightly updates 2026-05-10 18:46:13 +03:00
neoapps-dev f4841c3be0 fix: compiler errors 2026-05-10 18:37:08 +03:00
neoapps-dev 17353f6984 ci: sign releases 2026-05-10 18:20:56 +03:00
neoapps-dev a2021529b2 feat: initial support for AUTOupdates 2026-05-10 18:17:21 +03:00
neoapps-dev 7ec96e6a14 feat(SetUidModal): fullscreen 2026-05-10 15:16:24 +03:00
neoapps-dev 6ad97219c9 feat(VersionsView): set uid 2026-05-10 15:08:05 +03:00
neoapps-dev 14e92937e8 feat: discord rpc for LCELive 2026-05-08 14:03:37 +03:00
/home/neo c9ef1db546 Merge pull request #26 from LCE-Hub/main 2026-05-08 13:28:28 +03:00
neoapps-dev 265a2b3992 feat: initial implementation of LCELive 2026-05-08 01:17:45 +03:00
52 changed files with 4589 additions and 792 deletions
+4 -1
View File
@@ -91,7 +91,10 @@ jobs:
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
uploadPlainBinary: true
tagName: v__VERSION__
releaseName: 'Emerald v__VERSION__'
releaseBody: |
@@ -142,4 +145,4 @@ jobs:
tag_name: v${{ needs.get-version.outputs.version }}
files: Emerald.Legacy.Launcher.flatpak
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+2
View File
@@ -15,6 +15,8 @@
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "~2.3.1",
"@tauri-apps/plugin-updater": "~2.10.1",
"framer-motion": "^12.36.0",
"pako": "^2.1.0",
"react": "^19.1.0",
+20
View File
@@ -14,6 +14,12 @@ importers:
'@tauri-apps/plugin-opener':
specifier: ^2
version: 2.5.3
'@tauri-apps/plugin-process':
specifier: ~2.3.1
version: 2.3.1
'@tauri-apps/plugin-updater':
specifier: ~2.10.1
version: 2.10.1
framer-motion:
specifier: ^12.36.0
version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -647,6 +653,12 @@ packages:
'@tauri-apps/plugin-opener@2.5.3':
resolution: {integrity: sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==}
'@tauri-apps/plugin-process@2.3.1':
resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==}
'@tauri-apps/plugin-updater@2.10.1':
resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==}
'@tweenjs/tween.js@23.1.3':
resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
@@ -1748,6 +1760,14 @@ snapshots:
dependencies:
'@tauri-apps/api': 2.10.1
'@tauri-apps/plugin-process@2.3.1':
dependencies:
'@tauri-apps/api': 2.10.1
'@tauri-apps/plugin-updater@2.10.1':
dependencies:
'@tauri-apps/api': 2.10.1
'@tweenjs/tween.js@23.1.3': {}
'@types/babel__core@7.20.5':
Binary file not shown.

After

Width:  |  Height:  |  Size: 893 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 273 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 715 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 744 B

+340 -1
View File
@@ -47,6 +47,15 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "ascii"
version = "1.1.0"
@@ -773,6 +782,12 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "data-encoding"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
[[package]]
name = "deranged"
version = "0.5.8"
@@ -783,6 +798,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "derive_more"
version = "0.99.20"
@@ -992,9 +1018,10 @@ version = "1.1.0"
dependencies = [
"base64 0.21.7",
"futures-util",
"http 1.4.0",
"image 0.24.9",
"libc",
"percent-encoding",
"rand 0.8.5",
"reqwest 0.11.27",
"rfd",
"serde",
@@ -1005,8 +1032,12 @@ dependencies = [
"tauri-plugin-drpc",
"tauri-plugin-gamepad",
"tauri-plugin-opener",
"tauri-plugin-process",
"tauri-plugin-updater",
"tokio",
"tokio-tungstenite",
"tokio-util",
"url",
]
[[package]]
@@ -1153,6 +1184,16 @@ dependencies = [
"rustc_version",
]
[[package]]
name = "filetime"
version = "0.2.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d5b2eef6fafbf69f877e55509ce5b11a760690ac9700a2921be067aa6afaef6"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -1899,6 +1940,21 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http 1.4.0",
"hyper 1.9.0",
"hyper-util",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]]
name = "hyper-tls"
version = "0.5.0"
@@ -2524,6 +2580,12 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2775,6 +2837,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.11.0",
"block2",
"libc",
"objc2",
"objc2-core-foundation",
]
@@ -2801,6 +2864,18 @@ dependencies = [
"objc2-core-foundation",
]
[[package]]
name = "objc2-osa-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
dependencies = [
"bitflags 2.11.0",
"objc2",
"objc2-app-kit",
"objc2-foundation",
]
[[package]]
name = "objc2-quartz-core"
version = "0.3.2"
@@ -2917,6 +2992,20 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "osakit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
dependencies = [
"objc2",
"objc2-foundation",
"objc2-osa-kit",
"serde",
"serde_json",
"thiserror 2.0.18",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -3675,15 +3764,20 @@ dependencies = [
"http-body 1.0.1",
"http-body-util",
"hyper 1.9.0",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde",
"serde_json",
"sync_wrapper 1.0.2",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
@@ -3719,6 +3813,20 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rpcdiscord"
version = "0.2.6"
@@ -3760,6 +3868,32 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]]
name = "rustls-pemfile"
version = "1.0.4"
@@ -3769,6 +3903,53 @@ dependencies = [
"base64 0.21.7",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.22"
@@ -4111,6 +4292,17 @@ dependencies = [
"stable_deref_trait",
]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sha2"
version = "0.10.9"
@@ -4309,6 +4501,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.7"
@@ -4451,6 +4649,17 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "tar"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "target-lexicon"
version = "0.12.16"
@@ -4643,6 +4852,49 @@ dependencies = [
"zbus",
]
[[package]]
name = "tauri-plugin-process"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a"
dependencies = [
"tauri",
"tauri-plugin",
]
[[package]]
name = "tauri-plugin-updater"
version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af"
dependencies = [
"base64 0.22.1",
"dirs",
"flate2",
"futures-util",
"http 1.4.0",
"infer",
"log",
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest 0.13.2",
"rustls",
"semver",
"serde",
"serde_json",
"tar",
"tauri",
"tauri-plugin",
"tempfile",
"thiserror 2.0.18",
"time",
"tokio",
"url",
"windows-sys 0.60.2",
"zip",
]
[[package]]
name = "tauri-runtime"
version = "2.10.1"
@@ -4907,6 +5159,30 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-tungstenite"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38"
dependencies = [
"futures-util",
"log",
"native-tls",
"tokio",
"tokio-native-tls",
"tungstenite",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
@@ -5130,6 +5406,26 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1"
dependencies = [
"byteorder",
"bytes",
"data-encoding",
"http 1.4.0",
"httparse",
"log",
"native-tls",
"rand 0.8.5",
"sha1",
"thiserror 1.0.69",
"url",
"utf-8",
]
[[package]]
name = "typeid"
version = "1.0.3"
@@ -5212,6 +5508,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
@@ -5610,6 +5912,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"
@@ -6409,6 +6720,16 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]]
name = "yoke"
version = "0.8.2"
@@ -6534,6 +6855,12 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]]
name = "zerotrie"
version = "0.2.4"
@@ -6567,6 +6894,18 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "zip"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"indexmap 2.13.1",
"memchr",
]
[[package]]
name = "zmij"
version = "1.0.21"
+8 -1
View File
@@ -24,8 +24,15 @@ tokio = { version = "1", features = ["full"] }
futures-util = "0.3"
tokio-util = { version = "0.7.18", features = ["rt"] }
base64 = "0.21"
tokio-tungstenite = { version = "0.21", default-features = false, features = ["connect", "native-tls"] }
url = "2"
rand = "0.8"
http = "1"
rfd = "0.15"
libc = "0.2"
image = "0.24"
percent-encoding = "2.3"
steam_shortcuts_util = "1.1.8"
tauri-plugin-process = "2"
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-updater = "2"
+13 -3
View File
@@ -18,13 +18,23 @@
"opener:default",
"gamepad:default",
"drpc:default",
"updater:default",
{
"identifier": "opener:allow-open-path",
"allow": [{ "path": "**" }]
"allow": [
{
"path": "**"
}
]
},
{
"identifier": "opener:allow-reveal-item-in-dir",
"allow": [{ "path": "**" }]
}
"allow": [
{
"path": "**"
}
]
},
"process:default"
]
}
+15
View File
@@ -0,0 +1,15 @@
{
"identifier": "desktop-capability",
"platforms": [
"macOS",
"windows",
"linux"
],
"windows": [
"main"
],
"permissions": [
"updater:default",
"process:default"
]
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"default":{"identifier":"default","description":"Default permissions","local":true,"windows":["main"],"permissions":["core:default","core:app:default","core:event:default","core:image:default","core:menu:default","core:path:default","core:resources:default","core:tray:default","core:webview:default","core:window:default","opener:default","gamepad:default","drpc:default",{"identifier":"opener:allow-open-path","allow":[{"path":"**"}]},{"identifier":"opener:allow-reveal-item-in-dir","allow":[{"path":"**"}]}]}}
{"default":{"identifier":"default","description":"Default permissions","local":true,"windows":["main"],"permissions":["core:default","core:app:default","core:event:default","core:image:default","core:menu:default","core:path:default","core:resources:default","core:tray:default","core:webview:default","core:window:default","opener:default","gamepad:default","drpc:default","updater:default",{"identifier":"opener:allow-open-path","allow":[{"path":"**"}]},{"identifier":"opener:allow-reveal-item-in-dir","allow":[{"path":"**"}]},"process:default"]},"desktop-capability":{"identifier":"desktop-capability","description":"","local":true,"windows":["main"],"permissions":["updater:default","process:default"],"platforms":["macOS","windows","linux"]}}
+84
View File
@@ -2443,6 +2443,90 @@
"type": "string",
"const": "opener:deny-reveal-item-in-dir",
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
},
{
"description": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`",
"type": "string",
"const": "process:default",
"markdownDescription": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`"
},
{
"description": "Enables the exit command without any pre-configured scope.",
"type": "string",
"const": "process:allow-exit",
"markdownDescription": "Enables the exit command without any pre-configured scope."
},
{
"description": "Enables the restart command without any pre-configured scope.",
"type": "string",
"const": "process:allow-restart",
"markdownDescription": "Enables the restart command without any pre-configured scope."
},
{
"description": "Denies the exit command without any pre-configured scope.",
"type": "string",
"const": "process:deny-exit",
"markdownDescription": "Denies the exit command without any pre-configured scope."
},
{
"description": "Denies the restart command without any pre-configured scope.",
"type": "string",
"const": "process:deny-restart",
"markdownDescription": "Denies the restart command without any pre-configured scope."
},
{
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
"type": "string",
"const": "updater:default",
"markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
},
{
"description": "Enables the check command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-check",
"markdownDescription": "Enables the check command without any pre-configured scope."
},
{
"description": "Enables the download command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-download",
"markdownDescription": "Enables the download command without any pre-configured scope."
},
{
"description": "Enables the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-download-and-install",
"markdownDescription": "Enables the download_and_install command without any pre-configured scope."
},
{
"description": "Enables the install command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-install",
"markdownDescription": "Enables the install command without any pre-configured scope."
},
{
"description": "Denies the check command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-check",
"markdownDescription": "Denies the check command without any pre-configured scope."
},
{
"description": "Denies the download command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-download",
"markdownDescription": "Denies the download command without any pre-configured scope."
},
{
"description": "Denies the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-download-and-install",
"markdownDescription": "Denies the download_and_install command without any pre-configured scope."
},
{
"description": "Denies the install command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-install",
"markdownDescription": "Denies the install command without any pre-configured scope."
}
]
},
+84
View File
@@ -2443,6 +2443,90 @@
"type": "string",
"const": "opener:deny-reveal-item-in-dir",
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
},
{
"description": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`",
"type": "string",
"const": "process:default",
"markdownDescription": "This permission set configures which\nprocess features are by default exposed.\n\n#### Granted Permissions\n\nThis enables to quit via `allow-exit` and restart via `allow-restart`\nthe application.\n\n#### This default permission set includes:\n\n- `allow-exit`\n- `allow-restart`"
},
{
"description": "Enables the exit command without any pre-configured scope.",
"type": "string",
"const": "process:allow-exit",
"markdownDescription": "Enables the exit command without any pre-configured scope."
},
{
"description": "Enables the restart command without any pre-configured scope.",
"type": "string",
"const": "process:allow-restart",
"markdownDescription": "Enables the restart command without any pre-configured scope."
},
{
"description": "Denies the exit command without any pre-configured scope.",
"type": "string",
"const": "process:deny-exit",
"markdownDescription": "Denies the exit command without any pre-configured scope."
},
{
"description": "Denies the restart command without any pre-configured scope.",
"type": "string",
"const": "process:deny-restart",
"markdownDescription": "Denies the restart command without any pre-configured scope."
},
{
"description": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`",
"type": "string",
"const": "updater:default",
"markdownDescription": "This permission set configures which kind of\nupdater functions are exposed to the frontend.\n\n#### Granted Permissions\n\nThe full workflow from checking for updates to installing them\nis enabled.\n\n\n#### This default permission set includes:\n\n- `allow-check`\n- `allow-download`\n- `allow-install`\n- `allow-download-and-install`"
},
{
"description": "Enables the check command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-check",
"markdownDescription": "Enables the check command without any pre-configured scope."
},
{
"description": "Enables the download command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-download",
"markdownDescription": "Enables the download command without any pre-configured scope."
},
{
"description": "Enables the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-download-and-install",
"markdownDescription": "Enables the download_and_install command without any pre-configured scope."
},
{
"description": "Enables the install command without any pre-configured scope.",
"type": "string",
"const": "updater:allow-install",
"markdownDescription": "Enables the install command without any pre-configured scope."
},
{
"description": "Denies the check command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-check",
"markdownDescription": "Denies the check command without any pre-configured scope."
},
{
"description": "Denies the download command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-download",
"markdownDescription": "Denies the download command without any pre-configured scope."
},
{
"description": "Denies the download_and_install command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-download-and-install",
"markdownDescription": "Denies the download_and_install command without any pre-configured scope."
},
{
"description": "Denies the install command without any pre-configured scope.",
"type": "string",
"const": "updater:deny-install",
"markdownDescription": "Denies the install command without any pre-configured scope."
}
]
},
+740 -53
View File
@@ -1,5 +1,6 @@
use std::fs;
use std::io::Write;
use std::net::UdpSocket;
use std::path::PathBuf;
use std::process::Command;
use steam_shortcuts_util::{Shortcut, parse_shortcuts, shortcuts_to_bytes};
@@ -8,9 +9,11 @@ use steam_shortcuts_util::{Shortcut, parse_shortcuts, shortcuts_to_bytes};
use std::process::Stdio;
use tauri::{AppHandle, Emitter, State, Manager};
use futures_util::StreamExt;
use futures_util::{StreamExt, SinkExt};
use std::sync::Arc;
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::Mutex;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio_util::sync::CancellationToken;
use tauri_plugin_opener::OpenerExt;
use serde::{Deserialize, Serialize};
@@ -60,6 +63,7 @@ pub struct AppConfig {
pub music_vol: Option<u32>,
pub sfx_vol: Option<u32>,
pub legacy_mode: Option<bool>,
pub mangohud_enabled: Option<bool>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
@@ -87,9 +91,20 @@ pub struct ScreenshotInfo {
pub date: u64,
}
#[derive(Serialize)]
pub struct HttpResponse {
status: u16,
body: String,
}
pub struct DownloadState { pub token: Arc<Mutex<Option<CancellationToken>>> }
pub struct GameState { pub child: Arc<Mutex<Option<tokio::process::Child>>> }
pub struct ProxyGuard {
pub cancel_token: Arc<Mutex<Option<CancellationToken>>>,
pub local_port: Arc<Mutex<Option<u16>>>,
}
#[derive(Serialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
#[cfg(target_os = "macos")]
@@ -251,6 +266,7 @@ fn load_config(app: AppHandle) -> AppConfig {
music_vol: Some(50),
sfx_vol: Some(100),
legacy_mode: Some(false),
mangohud_enabled: None,
}
}
@@ -349,6 +365,29 @@ fn read_binary_file(path: String) -> Result<Vec<u8>, String> {
fs::read(path).map_err(|e| e.to_string())
}
#[tauri::command]
fn read_screenshot_as_data_url(path: String) -> Result<String, String> {
let data = fs::read(&path).map_err(|e| e.to_string())?;
let mime = if path.to_lowercase().ends_with(".png") {
"image/png"
} else if path.to_lowercase().ends_with(".jpg") || path.to_lowercase().ends_with(".jpeg") {
"image/jpeg"
} else if path.to_lowercase().ends_with(".gif") {
"image/gif"
} else if path.to_lowercase().ends_with(".webp") {
"image/webp"
} else if data.len() > 4 && data[..4] == [0x89, 0x50, 0x4E, 0x47] {
"image/png"
} else if data.len() > 2 && data[..2] == [0xFF, 0xD8] {
"image/jpeg"
} else {
"image/png"
};
use base64::Engine;
let b64 = base64::engine::general_purpose::STANDARD.encode(&data);
Ok(format!("data:{};base64,{}", mime, b64))
}
#[tauri::command]
fn get_available_runners(app: AppHandle) -> Vec<Runner> {
let mut runners = Vec::new();
@@ -836,13 +875,8 @@ async fn download_and_install(app: AppHandle, state: State<'_, DownloadState>, u
{ *state.token.lock().await = None; }
#[cfg(target_os = "linux")]
{
let unzip_check = Command::new("unzip").arg("-v").status();
if unzip_check.is_err() || !unzip_check.unwrap().success() {
return Err("The 'unzip' command was not found. Please install it (e.g., 'sudo apt install unzip') to continue.".into());
}
let status = Command::new("unzip")
.args(["-o", "-q", zip_path.to_str().unwrap(), "-d", instance_dir.to_str().unwrap()])
let status = Command::new("bsdtar")
.args(["-xf", zip_path.to_str().unwrap(), "-C", instance_dir.to_str().unwrap()])
.status()
.map_err(|e| e.to_string())?;
@@ -900,6 +934,124 @@ async fn download_and_install(app: AppHandle, state: State<'_, DownloadState>, u
Ok("Success".into())
}
#[repr(C, packed)]
struct LanBroadcastPacket {
magic: u32,
net_version: u16,
game_port: u16,
host_name: [u16; 32],
player_count: u8,
max_players: u8,
game_host_settings: u32,
texture_pack_parent_id: u32,
sub_texture_pack_id: u8,
is_joinable: u8,
}
const LAN_FORWARD_BASE_PORT: u16 = 61000;
struct LanServicesGuard(CancellationToken);
impl Drop for LanServicesGuard {
fn drop(&mut self) {
self.0.cancel();
}
}
fn start_lan_broadcast(servers: &[(McServer, u16)], cancel: CancellationToken) {
for (server, broadcast_port) in servers {
let cancel = cancel.clone();
let name = server.name.clone();
let port = *broadcast_port;
std::thread::spawn(move || {
let socket = match UdpSocket::bind("0.0.0.0:0") {
Ok(s) => s,
Err(e) => {
eprintln!("[Emerald] LAN broadcast socket bind failed: {e}");
return;
}
};
if let Err(e) = socket.set_broadcast(true) {
eprintln!("[Emerald] LAN broadcast set_broadcast failed: {e}");
return;
}
let mut host_name = [0u16; 32];
for (i, c) in name.encode_utf16().take(31).enumerate() {
host_name[i] = c;
}
let packet = LanBroadcastPacket {
magic: 0x4D434C4E,
net_version: 170,
game_port: port,
host_name,
player_count: 0,
max_players: 8,
game_host_settings: 0,
texture_pack_parent_id: 0,
sub_texture_pack_id: 0,
is_joinable: 1,
};
let packet_bytes = unsafe {
std::slice::from_raw_parts(
&packet as *const LanBroadcastPacket as *const u8,
std::mem::size_of::<LanBroadcastPacket>(),
)
};
while !cancel.is_cancelled() {
if let Err(e) = socket.send_to(packet_bytes, "255.255.255.255:25566") {
eprintln!("[Emerald] LAN broadcast send failed: {e}");
}
std::thread::sleep(std::time::Duration::from_secs(1));
}
});
}
}
async fn run_tcp_forwarder(
listen_port: u16,
target_host: String,
target_port: u16,
cancel: CancellationToken,
) {
let addr = format!("127.0.0.1:{listen_port}");
let listener = match TcpListener::bind(&addr).await {
Ok(l) => l,
Err(e) => {
eprintln!("[Emerald] Forwarder bind failed on {addr}: {e}");
return;
}
};
loop {
tokio::select! {
_ = cancel.cancelled() => break,
result = listener.accept() => {
let (mut client, _) = match result {
Ok(c) => c,
Err(e) => {
eprintln!("[Emerald] Forwarder accept failed: {e}");
continue;
}
};
let target = format!("{target_host}:{target_port}");
tokio::spawn(async move {
let mut server = match TcpStream::connect(&target).await {
Ok(s) => s,
Err(e) => {
eprintln!("[Emerald] Forwarder connect to {target} failed: {e}");
return;
}
};
let _ = tokio::io::copy_bidirectional(&mut client, &mut server).await;
});
}
}
}
}
fn ensure_server_list(instance_dir: &PathBuf, servers: Vec<McServer>) {
let servers_db = instance_dir.join("servers.db");
let mut all_servers = Vec::new();
@@ -1127,8 +1279,8 @@ async fn workshop_install(app: AppHandle, request: WorkshopInstallRequest) -> Re
#[cfg(target_os = "linux")]
{
let status = Command::new("unzip")
.args(["-o", "-q", zip_tmp.to_str().unwrap(), "-d", dest_dir.to_str().unwrap()])
let status = Command::new("bsdtar")
.args(["-xf", zip_tmp.to_str().unwrap(), "-C", dest_dir.to_str().unwrap()])
.status()
.map_err(|e| e.to_string())?;
if !status.success() {
@@ -1308,7 +1460,29 @@ async fn launch_game(app: AppHandle, state: State<'_, GameState>, instance_id: S
perform_instance_sync(&app, &instance_id).await?;
let working_dir = get_instance_working_dir(&app, &instance_id);
let config = load_config(app.clone());
ensure_server_list(&working_dir, servers);
let _lan_services: Option<LanServicesGuard> = if !servers.is_empty() {
let cancel = CancellationToken::new();
let servers_with_ports: Vec<(McServer, u16)> = servers
.into_iter()
.enumerate()
.map(|(i, s)| (s, LAN_FORWARD_BASE_PORT + i as u16))
.collect();
for (server, fp) in &servers_with_ports {
let cancel = cancel.clone();
let host = server.ip.clone();
let port = server.port;
let fp = *fp;
tokio::spawn(async move {
run_tcp_forwarder(fp, host, port, cancel).await;
});
}
start_lan_broadcast(&servers_with_ports, cancel.clone());
Some(LanServicesGuard(cancel))
} else {
None
};
let game_exe = working_dir.join("Minecraft.Client.exe");
if !game_exe.exists() {
return Err("Game executable not found in instance folder.".into());
@@ -1319,23 +1493,49 @@ async fn launch_game(app: AppHandle, state: State<'_, GameState>, instance_id: S
if let Some(runner_id) = config.linux_runner {
let runners = get_available_runners(app.clone());
if let Some(runner) = runners.into_iter().find(|r| r.id == runner_id) {
let mut cmd = if runner.r#type == "proton" {
let proton_exe = PathBuf::from(&runner.path).join("proton");
let mut c = tokio::process::Command::new(proton_exe);
let compat_data = working_dir.join("proton_prefix");
fs::create_dir_all(&compat_data).map_err(|e| e.to_string())?;
if std::env::var("STEAM_COMPAT_CLIENT_INSTALL_PATH").is_err() {
c.env("STEAM_COMPAT_CLIENT_INSTALL_PATH", "");
}
c.env("STEAM_COMPAT_DATA_PATH", compat_data.to_str().unwrap());
if std::env::var("SteamAppId").is_err() {
c.env("SteamAppId", "480");
}
c.arg("run");
c
let is_proton = runner.r#type == "proton";
let program = if is_proton {
PathBuf::from(&runner.path).join("proton").to_string_lossy().to_string()
} else {
tokio::process::Command::new(runner.path)
runner.path.clone()
};
let mut args: Vec<String> = Vec::new();
if is_proton {
args.push("run".to_string());
}
let compat_data = if is_proton {
let cd = working_dir.join("proton_prefix");
fs::create_dir_all(&cd).map_err(|e| e.to_string())?;
Some(cd)
} else {
None
};
let mangohud = config.mangohud_enabled.unwrap_or(false);
let (prog, extra_args): (&str, &[&str]) = if mangohud {
("mangohud", &[&program])
} else {
(&program, &[])
};
let mut cmd = tokio::process::Command::new(prog);
for a in extra_args {
cmd.arg(a);
}
for a in &args {
cmd.arg(a);
}
if is_proton {
let cd = compat_data.as_ref().unwrap();
if std::env::var("STEAM_COMPAT_CLIENT_INSTALL_PATH").is_err() {
cmd.env("STEAM_COMPAT_CLIENT_INSTALL_PATH", "");
}
cmd.env("STEAM_COMPAT_DATA_PATH", cd.to_str().unwrap());
if std::env::var("SteamAppId").is_err() {
cmd.env("SteamAppId", "480");
}
}
#[cfg(unix)]
{
@@ -1809,41 +2009,528 @@ async fn save_global_skin_pck(app: AppHandle, pck_data: Vec<u8>) -> Result<(), S
Ok(())
}
#[tauri::command]
async fn http_proxy_request(method: String, url: String, body: Option<String>, headers: std::collections::HashMap<String, String>) -> Result<HttpResponse, String> {
let client = reqwest::Client::new();
let mut req = match method.to_uppercase().as_str() {
"GET" => client.get(&url),
"POST" => client.post(&url),
"PUT" => client.put(&url),
"DELETE" => client.delete(&url),
_ => return Err(format!("Unsupported method: {}", method)),
};
for (k, v) in headers {
req = req.header(k, v);
}
if let Some(b) = body {
req = req.body(b);
}
let res = req.send().await.map_err(|e| e.to_string())?;
let status = res.status().as_u16();
let text = res.text().await.map_err(|e| e.to_string())?;
Ok(HttpResponse {
status,
body: text,
})
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct P2pEndpoint {
pub ip: String,
pub port: u16,
}
fn ws_base_url(api_base_url: &str) -> String {
if api_base_url.starts_with("https") {
api_base_url.replace("https", "wss")
} else {
api_base_url.replace("http", "ws")
}
}
async fn stun_discover_impl() -> Result<P2pEndpoint, String> {
let socket = tokio::net::UdpSocket::bind("0.0.0.0:0").await.map_err(|e| e.to_string())?;
let stun_addr = tokio::net::lookup_host("stun.l.google.com:19302").await
.map_err(|e| format!("STUN DNS lookup failed: {}", e))?
.next()
.ok_or_else(|| "STUN DNS returned no addresses".to_string())?;
let magic_cookie: u32 = 0x2112A442;
let mut trans_id = [0u8; 12];
rand::Rng::fill(&mut rand::thread_rng(), &mut trans_id);
let mut req = Vec::with_capacity(20);
req.extend_from_slice(&0x0001u16.to_be_bytes());
req.extend_from_slice(&0x0000u16.to_be_bytes());
req.extend_from_slice(&magic_cookie.to_be_bytes());
req.extend_from_slice(&trans_id);
socket.send_to(&req, stun_addr).await.map_err(|e| format!("STUN send: {}", e))?;
let mut buf = [0u8; 512];
tokio::time::timeout(
std::time::Duration::from_secs(5),
socket.recv_from(&mut buf)
).await.map_err(|_| "STUN request timed out (5s)".to_string())?
.map_err(|e| format!("STUN recv: {}", e))?;
let msg_type = u16::from_be_bytes([buf[0], buf[1]]);
let rcvd_cookie = u32::from_be_bytes([buf[4], buf[5], buf[6], buf[7]]);
if msg_type != 0x0101 || rcvd_cookie != magic_cookie {
return Err(format!("Invalid STUN response (type=0x{:04X}, cookie=0x{:08X})", msg_type, rcvd_cookie));
}
let mut pos = 20;
while pos + 4 <= buf.len() {
let attr_type = u16::from_be_bytes([buf[pos], buf[pos+1]]);
let attr_len = u16::from_be_bytes([buf[pos+2], buf[pos+3]]) as usize;
pos += 4;
if attr_type == 0x0020 && attr_len >= 8 && pos + 8 <= buf.len() {
let _family = buf[pos+1];
let xport = u16::from_be_bytes([buf[pos+2], buf[pos+3]]);
let port = xport ^ (magic_cookie >> 16) as u16;
let ip_bytes = [
buf[pos+4] ^ (magic_cookie >> 24) as u8,
buf[pos+5] ^ (magic_cookie >> 16) as u8,
buf[pos+6] ^ (magic_cookie >> 8) as u8,
buf[pos+7] ^ magic_cookie as u8,
];
return Ok(P2pEndpoint {
ip: format!("{}.{}.{}.{}", ip_bytes[0], ip_bytes[1], ip_bytes[2], ip_bytes[3]),
port,
});
}
pos += attr_len;
}
Err("No XOR-MAPPED-ADDRESS in STUN response".into())
}
#[tauri::command]
async fn stun_discover() -> Result<P2pEndpoint, String> {
stun_discover_impl().await
}
async fn run_relay_proxy(
proxy_state: &ProxyGuard,
ws_url: &str,
auth_token: &str,
cancel: CancellationToken,
) -> Result<u16, String> {
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
let mut request = ws_url
.into_client_request()
.map_err(|e| format!("Failed to build WS request: {}", e))?;
request.headers_mut().insert(
http::header::AUTHORIZATION,
format!("Bearer {}", auth_token)
.parse()
.map_err(|_| "Invalid auth header value".to_string())?,
);
request.headers_mut().insert(
http::header::USER_AGENT,
"MCLCE-LceLive/1.0"
.parse()
.map_err(|_| "Invalid UA header value".to_string())?,
);
let (ws_stream, _) = tokio_tungstenite::connect_async(request)
.await
.map_err(|e| format!("Relay WS connect failed: {}", e))?;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.map_err(|e| format!("Bind failed: {}", e))?;
let local_port = listener.local_addr().map_err(|e| e.to_string())?.port();
{
let mut port = proxy_state.local_port.lock().await;
*port = Some(local_port);
}
tokio::spawn(async move {
let (tcp_stream, _) = tokio::select! {
result = listener.accept() => result.map_err(|e| format!("Accept failed: {}", e)).unwrap(),
_ = cancel.cancelled() => return,
};
let (tcp_read, tcp_write) = tcp_stream.into_split();
let (ws_write, ws_read) = ws_stream.split();
let cancel_ws = cancel.clone();
let forward_tcp = tokio::spawn(async move {
let mut ws_write = ws_write;
let mut tcp_read = tcp_read;
let mut buf = [0u8; 65536];
loop {
tokio::select! {
result = tcp_read.read(&mut buf) => {
match result {
Ok(0) | Err(_) => break,
Ok(n) => {
if ws_write.send(tokio_tungstenite::tungstenite::Message::Binary(buf[..n].to_vec())).await.is_err() {
break;
}
}
}
}
_ = cancel_ws.cancelled() => break,
}
}
});
let cancel_tcp = cancel.clone();
let forward_ws = tokio::spawn(async move {
let ws_read = ws_read;
let mut tcp_write = tcp_write;
tokio::pin!(ws_read);
loop {
tokio::select! {
result = ws_read.next() => {
match result {
Some(Ok(tokio_tungstenite::tungstenite::Message::Binary(data))) => {
if tcp_write.write_all(&data).await.is_err() {
break;
}
}
Some(Ok(tokio_tungstenite::tungstenite::Message::Close(_))) | None => break,
_ => {}
}
}
_ = cancel_tcp.cancelled() => break,
}
}
});
tokio::select! {
_ = forward_tcp => {},
_ = forward_ws => {},
_ = cancel.cancelled() => {},
}
});
Ok(local_port)
}
async fn run_host_relay(
_proxy_state: &ProxyGuard,
ws_url: &str,
auth_token: &str,
game_port: u16,
cancel: CancellationToken,
) -> Result<(), String> {
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
let mut request = ws_url
.into_client_request()
.map_err(|e| format!("Failed to build WS request: {}", e))?;
request.headers_mut().insert(
http::header::AUTHORIZATION,
format!("Bearer {}", auth_token)
.parse()
.map_err(|_| "Invalid auth header value".to_string())?,
);
request.headers_mut().insert(
http::header::USER_AGENT,
"MCLCE-LceLive/1.0"
.parse()
.map_err(|_| "Invalid UA header value".to_string())?,
);
let (ws_stream, _) = tokio_tungstenite::connect_async(request)
.await
.map_err(|e| format!("Host relay WS connect failed: {}", e))?;
let game_stream = loop {
match tokio::net::TcpStream::connect(format!("127.0.0.1:{}", game_port)).await {
Ok(stream) => break stream,
Err(_) => {
tokio::select! {
_ = cancel.cancelled() => return Err("Host relay cancelled".into()),
_ = tokio::time::sleep(std::time::Duration::from_secs(2)) => {}
}
}
}
};
let (game_read, game_write) = game_stream.into_split();
let (ws_write, ws_read) = ws_stream.split();
let cancel_ws = cancel.clone();
let forward_game = tokio::spawn(async move {
let mut ws_write = ws_write;
let mut game_read = game_read;
let mut buf = [0u8; 65536];
loop {
tokio::select! {
result = game_read.read(&mut buf) => {
match result {
Ok(0) | Err(_) => break,
Ok(n) => {
if ws_write.send(tokio_tungstenite::tungstenite::Message::Binary(buf[..n].to_vec())).await.is_err() {
break;
}
}
}
}
_ = cancel_ws.cancelled() => break,
}
}
});
let cancel_ws2 = cancel.clone();
let forward_ws = tokio::spawn(async move {
let ws_read = ws_read;
let mut game_write = game_write;
tokio::pin!(ws_read);
loop {
tokio::select! {
result = ws_read.next() => {
match result {
Some(Ok(tokio_tungstenite::tungstenite::Message::Binary(data))) => {
if game_write.write_all(&data).await.is_err() {
break;
}
}
Some(Ok(tokio_tungstenite::tungstenite::Message::Close(_))) | None => break,
_ => {}
}
}
_ = cancel_ws2.cancelled() => break,
}
}
});
tokio::select! {
_ = forward_game => {},
_ = forward_ws => {},
_ = cancel.cancelled() => {},
}
Ok(())
}
async fn run_direct_proxy(
proxy_state: &ProxyGuard,
target_ip: &str,
target_port: u16,
cancel: CancellationToken,
) -> Result<u16, String> {
let remote = tokio::net::TcpStream::connect(format!("{}:{}", target_ip, target_port))
.await
.map_err(|e| format!("Direct TCP connect failed: {}", e))?;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.map_err(|e| format!("Bind failed: {}", e))?;
let local_port = listener.local_addr().map_err(|e| e.to_string())?.port();
{
let mut port = proxy_state.local_port.lock().await;
*port = Some(local_port);
}
let (local_stream, _) = tokio::select! {
result = listener.accept() => result.map_err(|e| format!("Accept failed: {}", e))?,
_ = cancel.cancelled() => return Err("Proxy cancelled".into()),
};
let (mut a_read, mut a_write) = remote.into_split();
let (mut b_read, mut b_write) = local_stream.into_split();
let cancel_a = cancel.clone();
let task_a = tokio::spawn(async move {
let mut buf = [0u8; 65536];
loop {
tokio::select! {
result = a_read.read(&mut buf) => {
match result {
Ok(0) | Err(_) => break,
Ok(n) => {
if b_write.write_all(&buf[..n]).await.is_err() { break; }
}
}
}
_ = cancel_a.cancelled() => break,
}
}
});
let cancel_b = cancel.clone();
let task_b = tokio::spawn(async move {
let mut buf = [0u8; 65536];
loop {
tokio::select! {
result = b_read.read(&mut buf) => {
match result {
Ok(0) | Err(_) => break,
Ok(n) => {
if a_write.write_all(&buf[..n]).await.is_err() { break; }
}
}
}
_ = cancel_b.cancelled() => break,
}
}
});
tokio::select! {
_ = task_a => {},
_ = task_b => {},
_ = cancel.cancelled() => {},
}
Ok(local_port)
}
#[tauri::command]
async fn start_direct_proxy(
proxy_state: State<'_, ProxyGuard>,
target_ip: String,
target_port: u16,
) -> Result<u16, String> {
let cancel = CancellationToken::new();
{
let mut token = proxy_state.cancel_token.lock().await;
if let Some(old) = token.take() {
old.cancel();
}
*token = Some(cancel.clone());
}
let local_port = run_direct_proxy(&proxy_state, &target_ip, target_port, cancel).await?;
Ok(local_port)
}
#[tauri::command]
async fn start_relay_proxy(
proxy_state: State<'_, ProxyGuard>,
api_base_url: String,
access_token: String,
session_id: String,
) -> Result<u16, String> {
let ws_base = ws_base_url(&api_base_url);
let ws_url = format!("{}/api/relay/ws?sessionId={}&role=joiner", ws_base, session_id);
let cancel = CancellationToken::new();
{
let mut token = proxy_state.cancel_token.lock().await;
if let Some(old) = token.take() {
old.cancel();
}
*token = Some(cancel.clone());
}
let local_port = run_relay_proxy(&proxy_state, &ws_url, &access_token, cancel).await?;
Ok(local_port)
}
#[tauri::command]
async fn start_host_relay(
proxy_state: State<'_, ProxyGuard>,
api_base_url: String,
access_token: String,
session_id: String,
game_port: u16,
) -> Result<(), String> {
let ws_base = ws_base_url(&api_base_url);
let ws_url = format!("{}/api/relay/ws?sessionId={}&role=host", ws_base, session_id);
let cancel = CancellationToken::new();
{
let mut token = proxy_state.cancel_token.lock().await;
if let Some(old) = token.take() {
old.cancel();
}
*token = Some(cancel.clone());
}
run_host_relay(&proxy_state, &ws_url, &access_token, game_port, cancel).await
}
#[tauri::command]
async fn stop_proxy(proxy_state: State<'_, ProxyGuard>) -> Result<(), String> {
let mut token = proxy_state.cancel_token.lock().await;
if let Some(t) = token.take() {
t.cancel();
}
let mut port = proxy_state.local_port.lock().await;
*port = None;
Ok(())
}
#[tauri::command]
async fn join_game(
app: AppHandle,
game_state: State<'_, GameState>,
proxy_state: State<'_, ProxyGuard>,
api_base_url: String,
access_token: String,
host_ip: String,
host_port: u16,
session_id: String,
instance_id: String,
) -> Result<(), String> {
let cancel = CancellationToken::new();
{
let mut token = proxy_state.cancel_token.lock().await;
if let Some(old) = token.take() {
old.cancel();
}
*token = Some(cancel.clone());
}
let ws_base = ws_base_url(&api_base_url);
let relay_url = format!("{}/api/relay/ws?sessionId={}&role=joiner", ws_base, session_id);
let host_ip_clone = host_ip.clone();
let cancel_clone = cancel.clone();
let proxy_state_ref: &ProxyGuard = &*proxy_state;
let proxy_fut = async move {
let direct = tokio::time::timeout(
std::time::Duration::from_secs(3),
run_direct_proxy(proxy_state_ref, &host_ip_clone, host_port, cancel_clone.clone()),
).await;
match direct {
Ok(Ok(port)) => Ok(port),
_ => {
run_relay_proxy(proxy_state_ref, &relay_url, &access_token, cancel_clone).await
}
}
};
let proxy_port = proxy_fut.await?;
let server = McServer {
name: host_ip.clone(),
ip: "127.0.0.1".into(),
port: proxy_port,
};
launch_game(app, game_state, instance_id, vec![server]).await
}
#[tauri::command]
fn get_instance_path(app: tauri::AppHandle, instance_id: String) -> String {
get_instance_working_dir(&app, &instance_id).to_string_lossy().to_string()
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.manage(DownloadState { token: Arc::new(Mutex::new(None)) })
.manage(GameState { child: Arc::new(Mutex::new(None)) })
.manage(ProxyGuard { cancel_token: Arc::new(Mutex::new(None)), local_port: Arc::new(Mutex::new(None)) })
.plugin(tauri_plugin_gamepad::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_drpc::init())
.register_uri_scheme_protocol("screenshots", |_app, request| {
let uri = request.uri().path();
let decoded_path = percent_encoding::percent_decode_str(uri).decode_utf8_lossy();
let mut path_str = decoded_path.to_string();
#[cfg(target_os = "windows")]
if path_str.starts_with('/') {
path_str = path_str[1..].to_string();
}
let path = std::path::Path::new(&path_str);
match std::fs::read(path) {
Ok(data) => {
tauri::http::Response::builder()
.header("Content-Type", "image/png")
.header("Access-Control-Allow-Origin", "*")
.body(data)
.unwrap()
}
Err(_) => {
tauri::http::Response::builder()
.status(404)
.body(Vec::new())
.unwrap()
}
}
})
.invoke_handler(tauri::generate_handler![setup_macos_runtime, launch_game, stop_game, check_game_installed, save_config, load_config, download_and_install, open_instance_folder, cancel_download, get_available_runners, get_external_palettes, import_theme, pick_folder, download_runner, delete_instance, sync_dlc, fetch_skin, workshop_install, workshop_uninstall, workshop_list_installed, get_screenshots, delete_screenshot, open_screenshot_folder, save_global_skin_pck, check_game_update, check_macos_runtime_installed, check_macos_runtime_installed_fast, download_logo, pick_file, save_file_dialog, write_binary_file, read_binary_file, add_to_steam])
.invoke_handler(tauri::generate_handler![setup_macos_runtime, launch_game, stop_game, check_game_installed, save_config, load_config, download_and_install, open_instance_folder, cancel_download, get_available_runners, get_external_palettes, import_theme, pick_folder, download_runner, delete_instance, sync_dlc, fetch_skin, workshop_install, workshop_uninstall, workshop_list_installed, get_screenshots, delete_screenshot, open_screenshot_folder, save_global_skin_pck, check_game_update, check_macos_runtime_installed, check_macos_runtime_installed_fast, download_logo, pick_file, save_file_dialog, write_binary_file, read_binary_file, read_screenshot_as_data_url, add_to_steam, http_proxy_request, get_instance_path, stun_discover, start_direct_proxy, start_relay_proxy, start_host_relay, stop_proxy, join_game])
.setup(|app| {
let args: Vec<String> = std::env::args().collect();
if args.len() > 1 && !args[1].starts_with('-') {
+65 -57
View File
@@ -1,57 +1,65 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "LCE Emerald Launcher",
"version": "1.1.0",
"identifier": "com.emerald.legacy",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"label": "main",
"title": "LCE Emerald Launcher",
"width": 1280,
"height": 720,
"minWidth": 1280,
"minHeight": 720,
"decorations": true,
"transparent": false,
"resizable": true,
"center": true,
"visible": true
}
]
},
"bundle": {
"active": true,
"targets": "all",
"linux": {
"appimage": {
"bundleMediaFramework": true
}
},
"resources": [
"resources/DLC"
],
"icon": [
"icons/32x32.png",
"icons/64x64.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.png",
"icons/icon.icns",
"icons/icon.ico"
],
"macOS": {
"entitlements": null,
"exceptionDomain": "",
"frameworks": [],
"signingIdentity": null,
"providerShortName": "Emerald"
}
}
}
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "LCE Emerald Launcher",
"version": "1.1.0",
"identifier": "com.emerald.legacy",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"label": "main",
"title": "LCE Emerald Launcher",
"width": 1280,
"height": 720,
"minWidth": 1280,
"minHeight": 720,
"decorations": true,
"transparent": false,
"resizable": true,
"center": true,
"visible": true
}
]
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDM1NTk5MjQ4QjMxQjFCQgpSV1M3c1RHTEpKbFZBMEtxMnNUZ3F5V2FmQmQrREY5Z0pkbnJuUlBYL2d1cUtzRkJIeDFkUkR4RQo=",
"endpoints": ["https://github.com/LCE-Hub/LCE-Emerald-Launcher/releases/latest/download/latest.json"],
"windows": {"installMode": "passive"}
}
},
"bundle": {
"active": true,
"targets": "all",
"createUpdaterArtifacts": true,
"linux": {
"appimage": {
"bundleMediaFramework": true
}
},
"resources": [
"resources/DLC"
],
"icon": [
"icons/32x32.png",
"icons/64x64.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.png",
"icons/icon.icns",
"icons/icon.ico"
],
"macOS": {
"entitlements": null,
"exceptionDomain": "",
"frameworks": [],
"signingIdentity": null,
"providerShortName": "Emerald"
}
}
}
+72 -70
View File
@@ -1,70 +1,72 @@
import React, { useEffect, useRef, useState } from 'react';
import { useUI } from '../../context/LauncherContext';
interface PanoramaProps {
profile: string;
isDay: boolean;
}
const PanoramaBackground = React.memo(({ profile, isDay }: PanoramaProps) => {
const { isWindowVisible } = useUI();
const PANORAMA_PROFILES = ['legacy_evolved', 'vanilla_tu19', '360revived', 'vanilla_tu24'];
const baseId = profile;
const profileId = PANORAMA_PROFILES.includes(baseId) ? baseId : 'vanilla_tu19';
const currentPanorama = `/panorama/${profileId}_Panorama_Background_${isDay ? 'Day' : 'Night'}.png`;
const [bgWidth, setBgWidth] = useState<number | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let active = true;
const updateWidth = () => {
if (!containerRef.current) return;
const img = new Image();
img.src = currentPanorama;
img.onload = () => {
if (!active || !containerRef.current) return;
const height = containerRef.current.clientHeight;
const aspectRatio = img.naturalWidth / img.naturalHeight;
setBgWidth(Math.ceil(height * aspectRatio));
};
};
updateWidth();
window.addEventListener('resize', updateWidth);
return () => {
active = false;
window.removeEventListener('resize', updateWidth);
};
}, [currentPanorama]);
return (
<>
{bgWidth && (
<style>{`
@keyframes panoramaLoop {
0% { transform: translate3d(0, 0, 0); }
100% { transform: translate3d(-${bgWidth}px, 0, 0); }
}
`}</style>
)}
<div ref={containerRef} className="absolute inset-0 overflow-hidden pointer-events-none transition-opacity duration-500">
{isWindowVisible && (
<div
className="absolute top-0 left-0 h-full will-change-transform"
style={{
width: bgWidth ? `calc(100vw + ${bgWidth}px)` : '200vw',
backgroundImage: `url("${currentPanorama}")`,
backgroundSize: bgWidth ? `${bgWidth}px 100%` : 'auto 100%',
backgroundRepeat: 'repeat-x',
animation: bgWidth ? 'panoramaLoop 140s linear infinite' : 'none'
}}
/>
)}
</div>
<div className="absolute inset-0 bg-black/35 pointer-events-none" />
</>
);
});
export default PanoramaBackground;
import React, { useEffect, useRef, useState } from "react";
import { useUI } from "../../context/LauncherContext";
interface PanoramaProps {
profile: string;
isDay: boolean;
}
const PanoramaBackground = React.memo(({ profile, isDay }: PanoramaProps) => {
const { isWindowVisible } = useUI();
const baseId = profile;
const profileId = baseId ? baseId : "vanilla_tu19";
const currentPanorama = `/panorama/${profileId}_Panorama_Background_${isDay ? "Day" : "Night"}.png`;
const [bgWidth, setBgWidth] = useState<number | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let active = true;
const updateWidth = () => {
if (!containerRef.current) return;
const img = new Image();
img.src = currentPanorama;
img.onload = () => {
if (!active || !containerRef.current) return;
const height = containerRef.current.clientHeight;
const aspectRatio = img.naturalWidth / img.naturalHeight;
setBgWidth(Math.ceil(height * aspectRatio));
};
};
updateWidth();
window.addEventListener("resize", updateWidth);
return () => {
active = false;
window.removeEventListener("resize", updateWidth);
};
}, [currentPanorama]);
return (
<>
{bgWidth && (
<style>{`
@keyframes panoramaLoop {
0% { transform: translate3d(0, 0, 0); }
100% { transform: translate3d(-${bgWidth}px, 0, 0); }
}
`}</style>
)}
<div
ref={containerRef}
className="absolute inset-0 overflow-hidden pointer-events-none transition-opacity duration-500"
>
{isWindowVisible && (
<div
className="absolute top-0 left-0 h-full will-change-transform"
style={{
width: bgWidth ? `calc(100vw + ${bgWidth}px)` : "200vw",
backgroundImage: `url("${currentPanorama}")`,
backgroundSize: bgWidth ? `${bgWidth}px 100%` : "auto 100%",
backgroundRepeat: "repeat-x",
animation: bgWidth ? "panoramaLoop 140s linear infinite" : "none",
}}
/>
)}
</div>
<div className="absolute inset-0 bg-black/35 pointer-events-none" />
</>
);
});
export default PanoramaBackground;
+54
View File
@@ -0,0 +1,54 @@
import { useState, useEffect, useRef } from "react";
import { TauriService } from "../../services/TauriService";
interface ScreenshotImageProps {
path: string;
className?: string;
alt?: string;
loading?: "lazy" | "eager";
style?: React.CSSProperties;
fallbackSrc?: string;
}
export function ScreenshotImage({
path,
className,
alt,
loading,
style,
fallbackSrc,
}: ScreenshotImageProps) {
const [src, setSrc] = useState<string | undefined>(fallbackSrc);
const imgRef = useRef<HTMLImageElement>(null);
useEffect(() => {
let cancelled = false;
setSrc(fallbackSrc);
TauriService.readScreenshotAsDataUrl(path)
.then((url) => {
if (!cancelled) setSrc(url);
})
.catch(() => {
if (!cancelled && fallbackSrc) setSrc(fallbackSrc);
});
return () => {
cancelled = true;
};
}, [path, fallbackSrc]);
const handleError = () => {
if (fallbackSrc) setSrc(fallbackSrc);
};
return (
<img
ref={imgRef}
src={src}
className={className}
alt={alt}
loading={loading}
style={style}
onError={handleError}
/>
);
}
+13
View File
@@ -256,6 +256,9 @@ const SkinViewer = memo(function SkinViewer({ username, setUsername, playPressSo
} else if (focusIndex === 4) {
playPressSound();
setActiveView('screenshots');
} else if (focusIndex === 5) {
playPressSound();
setActiveView('lcelive');
}
}
};
@@ -346,6 +349,16 @@ const SkinViewer = memo(function SkinViewer({ username, setUsername, playPressSo
>
<img src="/images/Screenshots_Icon.png" alt="Screenshots" className="w-8 h-8 object-contain" style={{ imageRendering: 'pixelated' }} />
</button>
<button
data-focus="5" tabIndex={0}
onMouseEnter={() => isFocusedSection && setFocusIndex(5)}
onClick={() => { playPressSound(); setActiveView('lcelive'); }}
className={`mc-sq-btn w-12 h-12 flex items-center justify-center outline-none border-none transition-all ${isFocusedSection && focusIndex === 5 ? 'scale-110' : ''}`}
style={isFocusedSection && focusIndex === 5 ? { backgroundImage: "url('/images/Button_Square_Highlighted.png')" } : {}}
title="LCELive"
>
<img src="/images/friends.png" alt="LCELive" className="w-8 h-8 object-contain" style={{ imageRendering: 'pixelated' }} />
</button>
</div>
</motion.div>
);
@@ -0,0 +1,287 @@
import { useState, useEffect } from "react";
import { motion } from "framer-motion";
import { TauriService } from "../../services/TauriService";
import { lceLiveService, GameInvite } from "../../services/LceLiveService";
export default function ChooseInstanceModal({
isOpen,
onClose,
playPressSound,
playBackSound,
editions,
installs,
invite,
}: {
isOpen: boolean;
onClose: () => void;
playPressSound: (s?: string) => void;
playBackSound: (s?: string) => void;
editions: any[];
installs: string[];
invite: GameInvite | null;
}) {
const [selectedInstance, setSelectedInstance] = useState<string>("");
const [status, setStatus] = useState<string>("");
const [error, setError] = useState<string>("");
const [isJoining, setIsJoining] = useState(false);
const [focusIndex, setFocusIndex] = useState(0);
const [connectionMethod, setConnectionMethod] = useState<"direct" | "relay">("direct");
const validInstances = editions.filter((e: any) =>
installs.includes(e.instanceId)
);
const hasRelay = !!(invite?.signalingSessionId);
useEffect(() => {
if (!isOpen) {
setSelectedInstance("");
setStatus("");
setError("");
setIsJoining(false);
setFocusIndex(0);
setConnectionMethod("direct");
}
}, [isOpen]);
useEffect(() => {
if (isOpen && validInstances.length > 0 && !selectedInstance) {
setSelectedInstance(validInstances[0].instanceId);
}
}, [isOpen, validInstances, selectedInstance]);
const handleJoin = async () => {
if (!invite || !selectedInstance) return;
playPressSound();
setIsJoining(true);
setError("");
setStatus("Accepting invite...");
try {
const inviteData = await lceLiveService.acceptGameInvite(invite.inviteId);
const hostIp = inviteData.hostIp || (typeof invite.from !== 'string' && (invite as any).from?.hostIp);
const hostPort = inviteData.hostPort || invite.hostPort;
const sessionId = inviteData.signalingSessionId || invite.signalingSessionId || "";
if (connectionMethod === "relay") {
if (!sessionId) {
throw new Error("Relay connection is not available for this invite.");
}
setStatus("Connecting via relay...");
const baseUrl = lceLiveService.apiBaseUrl;
const accessToken = lceLiveService.accessToken ?? "";
const port = await TauriService.startRelayProxy(baseUrl, accessToken, sessionId);
setStatus("Launching game...");
await TauriService.launchGame(selectedInstance, [
{ name: invite.hostName || "LCELive Game", ip: "127.0.0.1", port }
]);
} else {
setStatus("Launching game...");
await TauriService.stopProxy();
await TauriService.launchGame(selectedInstance, [
{ name: invite.hostName || "LCELive Game", ip: hostIp, port: hostPort }
]);
}
onClose();
} catch (e: any) {
setError(e.toString());
setStatus("");
setIsJoining(false);
}
};
useEffect(() => {
if (!isOpen) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
playBackSound();
if (isJoining) return;
onClose();
} else if (e.key === "ArrowDown" || e.key === "Tab") {
e.preventDefault();
const max = 1 + (validInstances.length > 0 ? 1 : 0) + 1;
setFocusIndex((prev) => (prev + 1) % max);
} else if (e.key === "ArrowUp") {
e.preventDefault();
const max = 1 + (validInstances.length > 0 ? 1 : 0) + 1;
setFocusIndex((prev) => (prev - 1 + max) % max);
} else if (e.key === "Enter") {
if (focusIndex === 0 && validInstances.length > 0) {
const currentIdx = validInstances.findIndex((i: any) => i.instanceId === selectedInstance);
const next = (currentIdx + 1) % validInstances.length;
setSelectedInstance(validInstances[next].instanceId);
playPressSound();
} else if (focusIndex === 1 && !isJoining) {
handleJoin();
} else if (focusIndex === (validInstances.length > 0 ? 2 : 1) && !isJoining) {
onClose();
}
}
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [isOpen, focusIndex, selectedInstance, validInstances, isJoining]);
if (!isOpen) return null;
const hostName = invite ? (typeof invite.from === 'string' ? invite.from : invite.from.displayName) : "";
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 w-screen h-screen z-[100] flex items-center justify-center bg-black/80 backdrop-blur-md outline-none border-none"
>
<div
className="relative w-[450px] p-6 flex flex-col items-center shadow-2xl"
style={{
backgroundImage: "url('/images/frame_background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
{!isJoining ? (
<>
<h2 className="text-[#FFFF55] text-2xl mc-text-shadow mb-2 border-b-2 border-[#373737] pb-2 w-full text-center uppercase">
Join Game
</h2>
<p className="text-white text-sm mc-text-shadow mb-4 text-center">
Joining {hostName}'s game. Choose an instance:
</p>
{validInstances.length > 0 ? (
<div className="w-full mb-4 flex flex-col gap-2 max-h-[300px] overflow-y-auto"
style={{ scrollbarWidth: "thin", scrollbarColor: "#373737 transparent" }}>
{validInstances.map((inst: any) => {
const isSelected = selectedInstance === inst.instanceId;
return (
<div
key={inst.instanceId}
onClick={() => { playPressSound(); setSelectedInstance(inst.instanceId); }}
onMouseEnter={() => setFocusIndex(0)}
className={`w-full px-4 py-3 cursor-pointer flex items-center gap-3 transition-all outline-none border-none ${isSelected ? "bg-white/15 border-l-4 border-[#FFFF55]" : "bg-black/20 hover:bg-black/30 border-l-4 border-transparent"}`}
style={{ imageRendering: "pixelated" }}
>
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${isSelected ? "border-[#FFFF55]" : "border-gray-500"}`}>
{isSelected && <div className="w-2 h-2 rounded-full bg-[#FFFF55]" />}
</div>
<div className="flex flex-col">
<span className="text-white text-lg font-bold mc-text-shadow">{inst.name}</span>
{inst.selectedBranch && (
<span className="text-gray-400 text-xs">{inst.selectedBranch}</span>
)}
</div>
</div>
);
})}
</div>
) : (
<p className="text-red-400 text-sm mc-text-shadow mb-4 text-center">
No installed instances available. Install a version first.
</p>
)}
<div className="w-full mb-3 flex flex-col items-center gap-1">
<span className="text-gray-400 text-xs mc-text-shadow uppercase tracking-wider">
Connection Method
</span>
<div className="flex gap-2">
<button
onClick={() => setConnectionMethod("direct")}
className={`px-4 py-1.5 text-sm font-bold mc-text-shadow uppercase tracking-wider outline-none border-none transition-all ${
connectionMethod === "direct"
? "text-[#FFFF55] bg-white/15 border-l-2 border-[#FFFF55]"
: "text-gray-400 bg-black/20 hover:bg-black/30 border-l-2 border-transparent"
}`}
style={{ imageRendering: "pixelated" }}
>
Direct
</button>
<button
onClick={() => hasRelay && setConnectionMethod("relay")}
className={`px-4 py-1.5 text-sm font-bold mc-text-shadow uppercase tracking-wider outline-none border-none transition-all ${
connectionMethod === "relay"
? "text-[#FFFF55] bg-white/15 border-l-2 border-[#FFFF55]"
: hasRelay
? "text-gray-400 bg-black/20 hover:bg-black/30 border-l-2 border-transparent"
: "text-gray-600 bg-black/10 border-l-2 border-transparent cursor-not-allowed"
}`}
style={{ imageRendering: "pixelated" }}
title={!hasRelay ? "Relay not available for this invite" : "Connect via LCELive relay server"}
>
Relay
</button>
</div>
{!hasRelay && (
<span className="text-gray-600 text-[10px] mc-text-shadow">Relay not available for this invite</span>
)}
</div>
{error && (
<div className="text-red-500 text-center mc-text-shadow uppercase text-xs tracking-widest mb-3">
{error}
</div>
)}
<div className="flex gap-4 w-full justify-center">
<button
onMouseEnter={() => {
const cancelIdx = validInstances.length > 0 ? 2 : 1;
setFocusIndex(cancelIdx);
}}
onClick={() => { playBackSound(); onClose(); }}
className={`w-32 h-10 flex items-center justify-center text-xl mc-text-shadow transition-colors outline-none border-none ${(() => {
const cancelIdx = validInstances.length > 0 ? 2 : 1;
return focusIndex === cancelIdx ? "text-[#FFFF55]" : "text-white";
})()}`}
style={{
backgroundImage: (() => {
const cancelIdx = validInstances.length > 0 ? 2 : 1;
return focusIndex === cancelIdx
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')";
})(),
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Cancel
</button>
{validInstances.length > 0 && (
<button
onMouseEnter={() => setFocusIndex(1)}
onClick={handleJoin}
className={`w-32 h-10 flex items-center justify-center text-xl mc-text-shadow transition-colors outline-none border-none ${focusIndex === 1 ? "text-[#FFFF55]" : "text-white"}`}
style={{
backgroundImage: focusIndex === 1
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Join
</button>
)}
</div>
</>
) : (
<>
<h2 className="text-[#FFFF55] text-2xl mc-text-shadow mb-4 border-b-2 border-[#373737] pb-2 w-full text-center uppercase">
Joining Game
</h2>
<div className="flex flex-col items-center gap-4 py-8">
<div className="w-12 h-12 border-4 border-[#FFFF55] border-t-transparent rounded-full animate-spin" />
<p className="text-white text-lg mc-text-shadow text-center">{status}</p>
</div>
{error && (
<div className="text-red-500 text-center mc-text-shadow uppercase text-xs tracking-widest mb-3">
{error}
</div>
)}
</>
)}
</div>
</motion.div>
);
}
+276
View File
@@ -0,0 +1,276 @@
import { useState, useEffect } from "react";
import { motion } from "framer-motion";
import { TauriService } from "../../services/TauriService";
export default function SetUidModal({
isOpen,
onClose,
playPressSound,
playBackSound,
instances,
installedVersions,
targetInstanceId,
}: any) {
const [mode, setMode] = useState<"manual" | "copy">("manual");
const [uid, setUid] = useState("0xFF02F0C87E8AC1F2");
const [selectedInstance, setSelectedInstance] = useState("");
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [error, setError] = useState("");
const [focusIndex, setFocusIndex] = useState(0);
useEffect(() => {
if (!isOpen) {
setMode("manual");
setUid("0xFF02F0C87E8AC1F2");
setSelectedInstance("");
setError("");
setFocusIndex(0);
setIsDropdownOpen(false);
} else if (targetInstanceId) {
(async () => {
try {
const targetPath = await TauriService.getInstancePath(targetInstanceId);
const data = await TauriService.readBinaryFile(`${targetPath}/uid.dat`);
const currentUid = new TextDecoder().decode(data);
if (currentUid.trim()) {
setUid(currentUid);
} else {
setUid("0xFF02F0C87E8AC1F2");
}
} catch (e) {
setUid("0xFF02F0C87E8AC1F2");
}
})();
}
}, [isOpen, targetInstanceId]);
const validInstances = instances.filter((i: any) => installedVersions.includes(i.instanceId) && i.instanceId !== targetInstanceId);
const handleSave = async () => {
playPressSound("save_click.wav");
try {
let finalUid = uid;
if (mode === "copy") {
if (!selectedInstance) {
setError("Select an instance to copy from.");
return;
}
const sourcePath = await TauriService.getInstancePath(selectedInstance);
try {
const sourceData = await TauriService.readBinaryFile(`${sourcePath}/uid.dat`);
finalUid = new TextDecoder().decode(sourceData);
} catch (e) {
setError("Source instance has no uid.dat or it could not be read.");
return;
}
}
if (!finalUid) {
setError("UID cannot be empty.");
return;
}
const encodedUid = new TextEncoder().encode(finalUid);
const targetPath = await TauriService.getInstancePath(targetInstanceId);
await TauriService.writeBinaryFile(`${targetPath}/uid.dat`, encodedUid);
onClose();
} catch (e: any) {
setError(e.toString());
}
};
useEffect(() => {
if (!isOpen) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
playBackSound("close_click.wav");
onClose();
} else if (e.key === "ArrowDown" || e.key === "Tab") {
e.preventDefault();
setFocusIndex((prev) => (prev + 1) % 5);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setFocusIndex((prev) => (prev - 1 + 5) % 5);
} else if (e.key === "Enter") {
if (focusIndex === 0) {
playPressSound();
setMode("manual");
} else if (focusIndex === 1) {
playPressSound();
setMode("copy");
} else if (focusIndex === 2 && mode === "copy") {
playPressSound();
setIsDropdownOpen(!isDropdownOpen);
} else if (focusIndex === 3) {
playBackSound("close_click.wav");
onClose();
} else if (focusIndex === 4) {
handleSave();
}
}
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [isOpen, focusIndex, mode, uid, selectedInstance, isDropdownOpen]);
if (!isOpen) return null;
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 w-screen h-screen z-[100] flex items-center justify-center bg-black/80 backdrop-blur-md outline-none border-none"
>
<div
className="relative w-[400px] p-6 flex flex-col items-center shadow-2xl"
style={{
backgroundImage: "url('/images/frame_background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
<h2 className="text-[#FFFF55] text-2xl mc-text-shadow mb-4 border-b-2 border-[#373737] pb-2 w-full text-center uppercase">
Set UID
</h2>
<div className="flex gap-4 mb-5 w-full justify-center">
<button
onMouseEnter={() => setFocusIndex(0)}
onClick={() => { playPressSound(); setMode("manual"); }}
className={`w-32 h-10 flex items-center justify-center text-sm mc-text-shadow transition-all outline-none border-none bg-transparent ${focusIndex === 0 ? "text-[#FFFF55]" : mode === "manual" ? "text-white" : "text-gray-400"}`}
style={{
backgroundImage: focusIndex === 0 || mode === "manual"
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Manual
</button>
<button
onMouseEnter={() => setFocusIndex(1)}
onClick={() => { playPressSound(); setMode("copy"); }}
className={`w-32 h-10 flex items-center justify-center text-sm mc-text-shadow transition-all outline-none border-none bg-transparent ${focusIndex === 1 ? "text-[#FFFF55]" : mode === "copy" ? "text-white" : "text-gray-400"}`}
style={{
backgroundImage: focusIndex === 1 || mode === "copy"
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Copy
</button>
</div>
<div className="flex flex-col gap-4 w-full min-h-[80px]">
{mode === "manual" ? (
<div className="flex flex-col gap-1 items-center w-full">
<label className="text-gray text-xs mc-text-shadow uppercase tracking-widest text-[#AAAAAA]">
Enter UID
</label>
<input
type="text"
autoFocus
value={uid}
onChange={(e) => setUid(e.target.value)}
onFocus={() => setFocusIndex(2)}
placeholder="0xFF02F0C87E8AC1F2"
className={`w-full h-10 px-3 bg-black/40 border-2 ${focusIndex === 2 ? 'border-white' : 'border-[#373737]'} text-white text-base outline-none font-['Mojangles'] text-center`}
style={{ imageRendering: "pixelated", filter: focusIndex === 2 ? 'brightness(1.2)' : 'none' }}
/>
</div>
) : (
<div className="flex flex-col gap-1 items-center w-full relative">
<label className="text-gray text-xs mc-text-shadow uppercase tracking-widest text-[#AAAAAA]">
Select Installed Instance
</label>
<div
onClick={() => { playPressSound(); setIsDropdownOpen(!isDropdownOpen); }}
onFocus={() => { setFocusIndex(2); setIsDropdownOpen(false); }}
tabIndex={0}
className={`w-full h-10 px-3 bg-black/40 border-2 ${focusIndex === 2 ? 'border-white' : 'border-[#373737]'} flex items-center justify-between text-white text-base outline-none font-['Mojangles'] cursor-pointer`}
style={{ imageRendering: "pixelated", filter: focusIndex === 2 ? 'brightness(1.2)' : 'none' }}
>
<span className="truncate">
{selectedInstance
? (() => {
const i = validInstances.find((inst: any) => inst.instanceId === selectedInstance);
return i ? `${i.name} ${i.selectedBranch ? `(${i.selectedBranch})` : ""}` : "-- Select an Instance --";
})()
: "-- Select an Instance --"}
</span>
<span className="text-xs"></span>
</div>
{isDropdownOpen && validInstances.length > 0 && (
<div className="absolute top-[60px] left-0 w-full max-h-40 overflow-y-auto bg-black/90 border-2 border-[#373737] z-50 flex flex-col custom-scrollbar shadow-xl" style={{ imageRendering: "pixelated" }}>
{validInstances.map((i: any) => (
<div
key={i.instanceId}
onClick={() => {
playPressSound();
setSelectedInstance(i.instanceId);
setIsDropdownOpen(false);
}}
className="px-3 py-2 text-white text-sm cursor-pointer hover:bg-white/20 transition-colors truncate font-['Mojangles']"
>
{i.name} {i.selectedBranch ? `(${i.selectedBranch})` : ""}
</div>
))}
</div>
)}
{validInstances.length === 0 && (
<p className="text-red-400 text-xs text-center mt-1">No other installed instances available.</p>
)}
</div>
)}
{error && (
<div className="text-red-500 text-center mc-text-shadow uppercase text-xs tracking-widest mt-2">
{error}
</div>
)}
</div>
<div className="flex gap-4 mt-6 w-full justify-center">
<button
onMouseEnter={() => setFocusIndex(3)}
onClick={() => {
playBackSound("close_click.wav");
onClose();
}}
className={`w-32 h-10 flex items-center justify-center text-xl mc-text-shadow transition-colors outline-none border-none ${focusIndex === 3 ? "text-[#FFFF55]" : "text-white"}`}
style={{
backgroundImage: focusIndex === 3
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Cancel
</button>
<button
onMouseEnter={() => setFocusIndex(4)}
onClick={handleSave}
className={`w-32 h-10 flex items-center justify-center text-xl mc-text-shadow transition-colors outline-none border-none ${focusIndex === 4 ? "text-[#FFFF55]" : "text-white"}`}
style={{
backgroundImage: focusIndex === 4
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Save
</button>
</div>
</div>
</motion.div>
);
}
+42 -15
View File
@@ -48,17 +48,37 @@ const HomeView = memo(function HomeView() {
: isGameRunning
? stopGame
: isDownloading
? () => { }
? () => {}
: isInstalled
? handleLaunch
: () => toggleInstall(profile),
isDanger: isGameRunning,
disabled: isDownloading,
},
{ label: "Help & Options", action: () => setActiveView("settings"), disabled: false, id: "settings" },
{ label: "Versions", action: () => setActiveView("versions"), disabled: false, id: "versions" },
{ label: "Workshop", action: () => setActiveView("workshop"), disabled: false, id: "workshop" },
{ label: "Developer Tools", action: () => setActiveView("devtools"), disabled: false, id: "devtools" },
{
label: "Help & Options",
action: () => setActiveView("settings"),
disabled: false,
id: "settings",
},
{
label: "Versions",
action: () => setActiveView("versions"),
disabled: false,
id: "versions",
},
{
label: "Workshop",
action: () => setActiveView("workshop"),
disabled: false,
id: "workshop",
},
{
label: "Developer Tools",
action: () => setActiveView("devtools"),
disabled: false,
id: "devtools",
},
],
[
isDownloading,
@@ -111,7 +131,9 @@ const HomeView = memo(function HomeView() {
{buttons.map((btn: any, i: number) => (
<div key={i} className="relative w-full group">
<button
onMouseEnter={() => isFocusedSection && !btn.disabled && setMenuFocus(i)}
onMouseEnter={() =>
isFocusedSection && !btn.disabled && setMenuFocus(i)
}
onMouseLeave={() => setMenuFocus(null)}
onClick={() => {
if (isFocusedSection && !btn.disabled) {
@@ -133,14 +155,19 @@ const HomeView = memo(function HomeView() {
}}
>
<div className="w-full h-full flex items-center justify-center relative">
<span>{btn.label}</span>
{btn.id === "versions" && Object.values(updatesAvailable || {}).some((v) => v) && (
<img
src="/images/Update_Icon.png"
className="absolute right-4 w-6 h-6 object-contain"
style={{ imageRendering: "pixelated", filter: "drop-shadow(0 0 2px rgba(255, 255, 0, 0.8)) sepia(100%) saturate(500%) hue-rotate(5deg) brightness(1.2)" }}
/>
)}
<span>{btn.label}</span>
{btn.id === "versions" &&
Object.values(updatesAvailable || {}).some((v) => v) && (
<img
src="/images/Update_Icon.png"
className="absolute right-4 w-6 h-6 object-contain"
style={{
imageRendering: "pixelated",
filter:
"drop-shadow(0 0 2px rgba(255, 255, 0, 0.8)) sepia(100%) saturate(500%) hue-rotate(5deg) brightness(1.2)",
}}
/>
)}
</div>
</button>
</div>
@@ -150,7 +177,7 @@ const HomeView = memo(function HomeView() {
<div className="pt-4 flex flex-col items-center w-full gap-3">
<div className="flex gap-8">
<a
href="https://discord.gg/NPMp9JhGYH"
href="https://discord.gg/cQVKhQXcCx"
target="_blank"
rel="noopener noreferrer"
onClick={() => {
+960
View File
@@ -0,0 +1,960 @@
import { useState, useEffect, useRef, useMemo, memo } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
useUI,
useConfig,
useAudio,
useGame,
} from "../../context/LauncherContext";
import {
lceLiveService,
LceLiveAccount,
FriendRequest,
GameInvite,
} from "../../services/LceLiveService";
import { TauriService } from "../../services/TauriService";
import ChooseInstanceModal from "../modals/ChooseInstanceModal";
const LceLiveView = memo(function LceLiveView() {
const { setActiveView } = useUI();
const { animationsEnabled } = useConfig();
const { playPressSound, playBackSound } = useAudio();
const { editions, installs } = useGame();
const [isSignedIn, setIsSignedIn] = useState(lceLiveService.signedIn);
const [currentTab, setCurrentTab] = useState<
"friends" | "requests" | "invites" | "device_link"
>("friends");
const [focusIndex, setFocusIndex] = useState<number | null>(0);
const [acceptInvite, setAcceptInvite] = useState<GameInvite | null>(null);
const [friends, setFriends] = useState<LceLiveAccount[]>([]);
const [incomingReqs, setIncomingReqs] = useState<FriendRequest[]>([]);
const [outgoingReqs, setOutgoingReqs] = useState<FriendRequest[]>([]);
const [invites, setInvites] = useState<GameInvite[]>([]);
const [linkData, setLinkData] = useState<any>(null);
const [linkError, setLinkError] = useState<string | null>(null);
const [isHosting, setIsHosting] = useState(false);
const [hostStatus, setHostStatus] = useState("");
const [hostIp, setHostIp] = useState("");
const [hostPort, setHostPort] = useState(19132);
const [isDiscovering, setIsDiscovering] = useState(false);
const [invitedFriends, setInvitedFriends] = useState<Set<string>>(new Set());
const [showHostMethodPicker, setShowHostMethodPicker] = useState(false);
const [hostSessionId, setHostSessionId] = useState<string>("");
const [hostMethodPick, setHostMethodPick] = useState<"direct" | "relay">(
"direct",
);
const [relayStarted, setRelayStarted] = useState(false);
const [isAddingFriend, setIsAddingFriend] = useState(false);
const [addFriendUsername, setAddFriendUsername] = useState("");
const addFriendInputRef = useRef<HTMLInputElement>(null);
const [errorModal, setErrorModal] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const fetchSocialData = async () => {
if (!lceLiveService.signedIn) return;
try {
const [f, reqs, invs] = await Promise.all([
lceLiveService.getFriends(),
lceLiveService.getPendingRequests(),
lceLiveService.getGameInvites(),
]);
setFriends(f);
setIncomingReqs(reqs.incoming);
setOutgoingReqs(reqs.outgoing);
setInvites(invs.filter((i: GameInvite) => i.status === "pending"));
} catch (e: any) {
console.error(e);
}
};
useEffect(() => {
if (isSignedIn) {
if (currentTab === "device_link") setCurrentTab("friends");
fetchSocialData();
const pollInvites = setInterval(async () => {
try {
const invs = await lceLiveService.getGameInvites();
setInvites(invs.filter((i: GameInvite) => i.status === "pending"));
} catch (e) {
console.warn("Failed to poll invites", e);
}
}, 5000);
return () => clearInterval(pollInvites);
} else {
setCurrentTab("device_link");
}
}, [isSignedIn, currentTab]);
useEffect(() => {
if (currentTab !== "device_link") return;
let mounted = true;
let pollInterval: any = null;
const startLink = async () => {
try {
if (!linkData) {
const data = await lceLiveService.startDeviceLink();
if (mounted) setLinkData(data);
}
} catch (e: any) {
if (mounted) setLinkError(e.message);
}
};
startLink();
if (linkData?.deviceCode) {
pollInterval = setInterval(
async () => {
try {
const res = await lceLiveService.pollDeviceLink(
linkData.deviceCode,
);
if (res.isLinked && mounted) {
setIsSignedIn(true);
setLinkData(null);
clearInterval(pollInterval);
}
} catch (e: any) {
console.warn("Poll failed", e);
}
},
Math.max(linkData.intervalSeconds * 1000, 2000),
);
}
return () => {
mounted = false;
if (pollInterval) clearInterval(pollInterval);
};
}, [currentTab, linkData]);
const handleLogout = () => {
playPressSound();
lceLiveService.logoutLocal();
setIsSignedIn(false);
setLinkData(null);
};
const handleAction = async (action: () => Promise<void>) => {
playPressSound();
try {
await action();
fetchSocialData();
} catch (e: any) {
setErrorModal(e.message || "An error occurred");
}
};
const handleStartHosting = () => {
playPressSound();
setRelayStarted(false);
setShowHostMethodPicker(true);
};
const handleHostDirect = async () => {
setShowHostMethodPicker(false);
setIsDiscovering(true);
setHostStatus("Discovering external IP...");
try {
const endpoint = await TauriService.stunDiscover();
setHostIp(endpoint.ip);
setHostPort(25565);
setIsHosting(true);
setHostStatus(`Hosting at ${endpoint.ip}:25565`);
setInvitedFriends(new Set());
setHostSessionId("");
} catch (e: any) {
const msg = typeof e === "string" ? e : e?.message || "Unknown error";
setErrorModal("STUN discovery failed: " + msg);
setHostStatus("");
} finally {
setIsDiscovering(false);
}
};
const handleHostRelay = async () => {
setShowHostMethodPicker(false);
setIsDiscovering(true);
setHostStatus("Discovering external IP for invite...");
const sessionId = crypto.randomUUID();
setHostSessionId(sessionId);
try {
const endpoint = await TauriService.stunDiscover();
setHostIp(endpoint.ip);
setHostPort(25565);
} catch {
setHostIp("127.0.0.1");
setHostPort(25565);
}
setRelayStarted(false);
setIsHosting(true);
setHostStatus("Relay ready - invite friends to activate");
setInvitedFriends(new Set());
setIsDiscovering(false);
};
const handleStopHosting = async () => {
playPressSound();
try {
await TauriService.stopProxy();
await lceLiveService.deactivateGameInvites();
} catch (e: any) {
console.warn("Stop hosting failed", e);
}
setIsHosting(false);
setHostStatus("");
setHostIp("");
setInvitedFriends(new Set());
setHostSessionId("");
setRelayStarted(false);
};
const handleInviteFriend = async (friend: LceLiveAccount) => {
playPressSound();
const name = lceLiveService.displayUsername;
try {
await lceLiveService.sendGameInvite(
friend.accountId,
hostIp,
hostPort,
name,
hostSessionId || undefined,
);
setInvitedFriends((prev) => new Set(prev).add(friend.accountId));
if (hostSessionId && !relayStarted) {
setRelayStarted(true);
setHostStatus("Connecting relay...");
const baseUrl = lceLiveService.apiBaseUrl;
const accessToken = lceLiveService.accessToken ?? "";
TauriService.startHostRelay(baseUrl, accessToken, hostSessionId, 25565)
.then(() => setHostStatus("Relay active"))
.catch((relayErr: any) => {
const relayMsg =
typeof relayErr === "string"
? relayErr
: relayErr?.message || "Unknown error";
console.warn("Relay failed:", relayMsg);
setHostStatus("Relay disconnected");
});
}
} catch (e: any) {
const msg = typeof e === "string" ? e : e?.message || "Unknown error";
setErrorModal("Failed to send invite: " + msg);
}
};
type MenuItem = {
id: string;
type: "button" | "friend" | "request_in" | "request_out" | "invite";
label: string;
onClick: () => void;
onClickSecondary?: () => void;
};
const menuItems = useMemo<MenuItem[]>(() => {
const items: MenuItem[] = [];
if (currentTab === "device_link") {
if (linkData) {
items.push({
id: "link_retry",
type: "button",
label: "Restart Link",
onClick: () => {
setLinkData(null);
playPressSound();
},
});
}
} else if (currentTab === "friends") {
if (!isDiscovering && !showHostMethodPicker) {
if (!isHosting) {
items.push({
id: "host_game",
type: "button",
label: "Host Game",
onClick: handleStartHosting,
});
} else {
items.push({
id: "stop_hosting",
type: "button",
label: "Stop Hosting",
onClick: handleStopHosting,
});
}
}
items.push({
id: "add_friend",
type: "button",
label: "Add Friend",
onClick: () => {
playPressSound();
setIsAddingFriend(true);
setAddFriendUsername("");
},
});
items.push({
id: "sign_out",
type: "button",
label: "Sign Out",
onClick: handleLogout,
});
friends.forEach((f) => {
items.push({
id: `friend_${f.accountId}`,
type: "friend",
label: f.displayName,
onClick: isHosting
? () => handleInviteFriend(f)
: () =>
handleAction(() => lceLiveService.removeFriend(f.accountId)),
onClickSecondary: isHosting
? () => handleAction(() => lceLiveService.removeFriend(f.accountId))
: undefined,
});
});
} else if (currentTab === "requests") {
incomingReqs.forEach((r) => {
items.push({
id: `req_in_${r.accountId}`,
type: "request_in",
label: r.displayName,
onClick: () =>
handleAction(() => lceLiveService.acceptFriendRequest(r.accountId)),
onClickSecondary: () =>
handleAction(() =>
lceLiveService.declineFriendRequest(r.accountId),
),
});
});
outgoingReqs.forEach((r) => {
items.push({
id: `req_out_${r.accountId}`,
type: "request_out",
label: r.displayName,
onClick: () =>
handleAction(() =>
lceLiveService.declineFriendRequest(r.accountId),
),
});
});
} else if (currentTab === "invites") {
invites.forEach((inv) => {
items.push({
id: `inv_${inv.inviteId}`,
type: "invite",
label:
typeof inv.from === "string" ? "Unknown" : inv.from.displayName,
onClick: () => {
playPressSound();
setAcceptInvite(inv);
},
onClickSecondary: () =>
handleAction(() => lceLiveService.declineGameInvite(inv.inviteId)),
});
});
}
return items;
}, [
currentTab,
friends,
incomingReqs,
outgoingReqs,
invites,
linkData,
playPressSound,
isHosting,
isDiscovering,
showHostMethodPicker,
]);
const tabs = ["friends", "requests", "invites"];
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (errorModal) {
if (e.key === "Escape" || e.key === "Enter") {
setErrorModal(null);
}
return;
}
if (isAddingFriend) {
if (e.key === "Escape") {
setIsAddingFriend(false);
playBackSound();
} else if (e.key === "Enter") {
if (addFriendUsername.trim() !== "") {
handleAction(() =>
lceLiveService.sendFriendRequest(addFriendUsername.trim()),
);
setIsAddingFriend(false);
}
}
return;
}
if (showHostMethodPicker) {
if (e.key === "Escape" || e.key === "Backspace") {
setShowHostMethodPicker(false);
playBackSound();
} else if (
e.key === "ArrowLeft" ||
e.key === "ArrowRight" ||
e.key === "q" ||
e.key === "Q" ||
e.key === "e" ||
e.key === "E"
) {
setHostMethodPick((prev: "direct" | "relay") =>
prev === "direct" ? "relay" : "direct",
);
playPressSound();
} else if (e.key === "Enter") {
if (hostMethodPick === "direct") handleHostDirect();
else handleHostRelay();
}
return;
}
if (e.key === "Escape" || e.key === "Backspace") {
playBackSound();
setActiveView("main");
return;
}
if (currentTab !== "device_link") {
const curIdx = tabs.indexOf(currentTab);
if (e.key === "q" || e.key === "Q" || e.key === "ArrowLeft") {
const next = curIdx > 0 ? tabs[curIdx - 1] : tabs[tabs.length - 1];
setCurrentTab(next as any);
setFocusIndex(0);
playPressSound();
return;
}
if (e.key === "e" || e.key === "E" || e.key === "ArrowRight") {
const next = curIdx < tabs.length - 1 ? tabs[curIdx + 1] : tabs[0];
setCurrentTab(next as any);
setFocusIndex(0);
playPressSound();
return;
}
}
const itemCount = menuItems.length;
if (itemCount > 0) {
if (e.key === "ArrowDown") {
setFocusIndex((prev) =>
prev === null || prev >= itemCount - 1 ? 0 : prev + 1,
);
} else if (e.key === "ArrowUp") {
setFocusIndex((prev) =>
prev === null || prev <= 0 ? itemCount - 1 : prev - 1,
);
} else if (e.key === "Enter" && focusIndex !== null) {
menuItems[focusIndex]?.onClick();
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [
focusIndex,
menuItems,
currentTab,
playBackSound,
setActiveView,
isAddingFriend,
addFriendUsername,
errorModal,
showHostMethodPicker,
hostMethodPick,
handleHostDirect,
handleHostRelay,
]);
useEffect(() => {
if (isAddingFriend && addFriendInputRef.current) {
addFriendInputRef.current.focus();
} else if (focusIndex !== null) {
const el = containerRef.current?.querySelector(
`[data-index="${focusIndex}"]`,
) as HTMLElement;
if (el) {
el.focus();
if (scrollRef.current) {
const rect = el.getBoundingClientRect();
const scrollRect = scrollRef.current.getBoundingClientRect();
if (rect.bottom > scrollRect.bottom || rect.top < scrollRect.top) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
}
}
}
}
}, [focusIndex, isAddingFriend]);
const renderContent = () => {
if (currentTab === "device_link") {
return (
<div className="flex flex-col items-center justify-center space-y-6 flex-1 text-center py-20">
{!linkData ? (
<p className="text-xl text-[#2a2a2a] font-bold">
{linkError || "Starting device link..."}
</p>
) : (
<>
<p className="text-2xl text-[#2a2a2a] font-bold">
Open this link in your browser:
</p>
<p className="text-[#111] text-3xl font-bold tracking-widest break-words bg-black/10 px-6 py-2 rounded shadow-inner">
{linkData.verificationUri}
</p>
<p className="text-2xl text-[#2a2a2a] font-bold mt-8">
And enter the code:
</p>
<p className="text-[#111] text-6xl tracking-[0.2em] font-bold mt-2 bg-black/10 px-8 py-4 rounded shadow-inner">
{linkData.userCode}
</p>
</>
)}
</div>
);
}
const topButtons = menuItems.filter((m) => m.type === "button");
const listItems = menuItems.filter((m) => m.type !== "button");
return (
<div className="flex flex-col h-full space-y-4">
{showHostMethodPicker ? (
<div className="flex gap-4 flex-wrap">
<button
onClick={handleHostDirect}
className={`flex-1 h-12 flex items-center justify-center text-xl font-bold uppercase tracking-widest outline-none border-none transition-all ${hostMethodPick === "direct" ? "text-[#FFFF55] mc-text-shadow scale-[1.02] z-10 relative drop-shadow-md" : "text-white mc-text-shadow hover:text-gray-200"}`}
style={{
backgroundImage:
hostMethodPick === "direct"
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Direct (STUN)
</button>
<button
onClick={handleHostRelay}
className={`flex-1 h-12 flex items-center justify-center text-xl font-bold uppercase tracking-widest outline-none border-none transition-all ${hostMethodPick === "relay" ? "text-[#FFFF55] mc-text-shadow scale-[1.02] z-10 relative drop-shadow-md" : "text-white mc-text-shadow hover:text-gray-200"}`}
style={{
backgroundImage:
hostMethodPick === "relay"
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Relay
</button>
<button
onClick={() => {
setShowHostMethodPicker(false);
playBackSound();
}}
className="flex-1 h-12 flex items-center justify-center text-xl font-bold uppercase tracking-widest outline-none border-none transition-all text-white mc-text-shadow hover:text-gray-200"
style={{
backgroundImage: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Cancel
</button>
</div>
) : (
topButtons.length > 0 && (
<div className="flex gap-4 flex-wrap">
{topButtons.map((btn) => {
const idx = menuItems.indexOf(btn);
const isFocused = focusIndex === idx;
return (
<button
key={btn.id}
data-index={idx}
onMouseEnter={() => setFocusIndex(idx)}
onClick={btn.onClick}
className={`flex-1 h-12 flex items-center justify-center text-xl font-bold uppercase tracking-widest outline-none border-none transition-all ${isFocused ? "text-[#FFFF55] mc-text-shadow scale-[1.02] z-10 relative drop-shadow-md" : "text-white mc-text-shadow hover:text-gray-200"}`}
style={{
backgroundImage: isFocused
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
{btn.label}
</button>
);
})}
</div>
)
)}
{hostStatus && (
<div className="text-center text-sm text-[#FFFF55] mc-text-shadow py-1 tracking-wider">
{hostStatus}
</div>
)}
<div className="flex flex-col flex-1 bg-black/5 shadow-inner rounded overflow-hidden border-4 border-[#222]">
<div className="bg-black/10 px-4 py-3 text-[#2a2a2a] font-bold tracking-widest uppercase border-b-4 border-[#222] flex justify-between shadow-sm z-10">
<span>
{currentTab === "friends"
? "Joinable Friends"
: currentTab === "requests"
? "Pending Requests"
: "Game Invites"}
</span>
<span className="text-[#111]">{listItems.length}</span>
</div>
<div ref={scrollRef} className="flex-1 overflow-y-auto w-full">
{listItems.length === 0 ? (
<div className="flex items-center justify-center h-[200px] text-[#555] font-bold">
None available
</div>
) : (
<div className="flex flex-col p-2 space-y-2">
{listItems.map((item) => {
const idx = menuItems.indexOf(item);
const isFocused = focusIndex === idx;
return (
<div
key={item.id}
data-index={idx}
onMouseEnter={() => setFocusIndex(idx)}
className={`w-full flex items-center justify-between px-4 py-3 relative outline-none border-none rounded transition-all ${isFocused ? "bg-black/15 shadow-inner" : "bg-transparent hover:bg-black/5"}`}
tabIndex={-1}
>
<div className="flex items-center w-full">
<div className="flex flex-col ml-2 flex-1 min-w-0">
<span className="text-[#2a2a2a] font-bold text-2xl truncate pr-4">
{item.label}
</span>
<span className="text-[#555] text-base font-bold truncate">
@
{menuItems.find((m) => m.id === item.id)?.type ===
"friend"
? friends.find(
(f) => `friend_${f.accountId}` === item.id,
)?.username
: item.type === "request_in"
? incomingReqs.find(
(f) => `req_in_${f.accountId}` === item.id,
)?.username
: item.type === "request_out"
? outgoingReqs.find(
(f) => `req_out_${f.accountId}` === item.id,
)?.username
: "Invite"}
</span>
</div>
</div>
<div className="flex space-x-3 pr-2 shrink-0">
{item.type === "friend" && !isHosting && (
<button
className={`px-6 h-12 flex items-center justify-center font-bold text-base outline-none uppercase tracking-widest mc-text-shadow transition-transform ${isFocused ? "text-white scale-105 shadow-md" : "text-gray-300"}`}
style={{
backgroundImage:
"url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
onClick={item.onClick}
>
REMOVE
</button>
)}
{item.type === "friend" && isHosting && (
<>
<button
className={`px-6 h-12 flex items-center justify-center font-bold text-base outline-none uppercase tracking-widest mc-text-shadow transition-transform ${isFocused ? "text-white scale-105 shadow-md" : "text-gray-300"}`}
style={{
backgroundImage:
"url('/images/button_highlighted.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
onClick={item.onClick}
>
{invitedFriends.has(
item.id.replace("friend_", ""),
)
? "INVITED"
: "INVITE"}
</button>
<button
className={`px-6 h-12 flex items-center justify-center font-bold text-base outline-none uppercase tracking-widest mc-text-shadow transition-transform ${isFocused ? "text-white scale-105 shadow-md" : "text-gray-300"}`}
style={{
backgroundImage:
"url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
onClick={(e) => {
e.stopPropagation();
item.onClickSecondary?.();
}}
>
REMOVE
</button>
</>
)}
{item.type === "request_out" && (
<button
className={`px-6 h-12 flex items-center justify-center font-bold text-base outline-none uppercase tracking-widest mc-text-shadow transition-transform ${isFocused ? "text-white scale-105 shadow-md" : "text-gray-300"}`}
style={{
backgroundImage:
"url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
onClick={item.onClick}
>
CANCEL
</button>
)}
{(item.type === "request_in" ||
item.type === "invite") && (
<>
<button
className={`px-6 h-12 flex items-center justify-center font-bold text-base outline-none uppercase tracking-widest mc-text-shadow transition-transform ${isFocused ? "text-white scale-105 shadow-md" : "text-gray-300"}`}
style={{
backgroundImage:
"url('/images/button_highlighted.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
onClick={item.onClick}
>
ACCEPT
</button>
<button
className={`px-6 h-12 flex items-center justify-center font-bold text-base outline-none uppercase tracking-widest mc-text-shadow transition-transform ${isFocused ? "text-white scale-105 shadow-md" : "text-gray-300"}`}
style={{
backgroundImage:
"url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
onClick={(e) => {
e.stopPropagation();
item.onClickSecondary?.();
}}
>
DECLINE
</button>
</>
)}
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
);
};
return (
<motion.div
ref={containerRef}
tabIndex={-1}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: animationsEnabled ? 0.3 : 0 }}
className="flex flex-col items-center justify-center w-full h-full absolute inset-0 outline-none p-12"
>
<div className="w-full max-w-5xl h-full flex flex-col mt-[4vh] mb-[4vh] relative drop-shadow-2xl">
{currentTab !== "device_link" && (
<div
className="flex z-10 space-x-2 px-12 relative w-full items-end"
style={{ marginBottom: "-4px" }}
>
{tabs.map((t) => (
<button
key={t}
className={`flex-1 font-bold text-xl outline-none uppercase transition-all duration-200 ease-in-out ${currentTab === t ? "text-[#2a2a2a] z-20 pb-6 pt-5 text-2xl drop-shadow-[5px_-5px_15px_rgba(0,0,0,0.3)] rounded-t border-4 border-[#222] border-b-0" : "text-[#555] mt-2 py-4 hover:bg-black/30 bg-black/10 hover:text-[#222] border-4 border-transparent border-b-0"}`}
style={{
backgroundImage: "url('/images/background.png')",
backgroundSize: "100% 100%",
backgroundRepeat: "no-repeat",
backgroundPosition: "bottom",
imageRendering: "pixelated",
}}
onClick={() => {
setCurrentTab(t as any);
setFocusIndex(0);
playPressSound();
}}
>
<div className="flex items-center justify-center">
{t}
{t === "requests" && incomingReqs.length > 0 && (
<span
className={`ml-3 text-white text-base px-3 py-1 rounded-full shadow-inner border-2 font-normal ${currentTab === t ? "bg-[#d72f2f] border-[#8a1a1a]" : "bg-[#a81f1f] border-[#111]"}`}
>
{incomingReqs.length}
</span>
)}
{t === "invites" && invites.length > 0 && (
<span
className={`ml-3 text-white text-base px-3 py-1 rounded-full shadow-inner border-2 font-normal ${currentTab === t ? "bg-[#30872a] border-[#1b5e16]" : "bg-[#23681d] border-[#111]"}`}
>
{invites.length}
</span>
)}
</div>
</button>
))}
</div>
)}
<div
className="flex-1 flex flex-col p-8 z-10 relative overflow-hidden rounded-b shadow-[0_0_30px_rgba(0,0,0,0.6)] border-4 border-[#222] border-t-0"
style={{
backgroundImage: "url('/images/background.png')",
backgroundSize: "100% auto",
backgroundRepeat: "repeat",
backgroundPosition: "top",
imageRendering: "pixelated",
}}
>
{renderContent()}
</div>
</div>
<AnimatePresence>
{isAddingFriend && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm outline-none border-none"
>
<div
className="relative w-[420px] p-8 flex flex-col items-center shadow-2xl"
style={{
backgroundImage: "url('/images/frame_background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
<h2 className="text-[#FFFF55] text-3xl mc-text-shadow mb-6 border-b-2 border-[#373737] pb-2 w-full text-center uppercase tracking-widest">
Add Friend
</h2>
<input
ref={addFriendInputRef}
type="text"
className="bg-black/20 border-4 border-[#555] text-white p-4 w-full text-2xl font-bold outline-none focus:border-[#FFFF55] transition-colors placeholder:text-[#888] mb-6 mc-text-shadow"
placeholder="Username"
value={addFriendUsername}
onChange={(e) => setAddFriendUsername(e.target.value)}
/>
<div className="flex gap-4 w-full">
<button
className="h-12 flex-1 flex items-center justify-center text-white mc-text-shadow text-xl font-bold uppercase tracking-widest transition-transform hover:text-[#FFFF55] hover:scale-105 outline-none border-none"
style={{
backgroundImage: "url('/images/button_highlighted.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
onClick={() => {
playPressSound();
if (addFriendUsername.trim() !== "") {
handleAction(() =>
lceLiveService.sendFriendRequest(
addFriendUsername.trim(),
),
);
setIsAddingFriend(false);
}
}}
>
Send
</button>
<button
className="h-12 flex-1 flex items-center justify-center text-white mc-text-shadow text-xl font-bold uppercase tracking-widest transition-transform hover:text-[#FFFF55] hover:scale-105 outline-none border-none"
style={{
backgroundImage: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
onClick={() => {
setIsAddingFriend(false);
playBackSound();
}}
>
Cancel
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{errorModal && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/80 backdrop-blur-sm outline-none border-none"
>
<div
className="relative w-[400px] p-8 flex flex-col items-center shadow-2xl"
style={{
backgroundImage: "url('/images/frame_background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
<h2 className="text-[#FFFF55] text-2xl mc-text-shadow mb-4 border-b-2 border-[#373737] pb-2 w-full text-center uppercase tracking-widest">
Error
</h2>
<p className="text-white text-lg mc-text-shadow text-center mb-6">
{errorModal}
</p>
<button
className="h-12 w-48 flex items-center justify-center text-white mc-text-shadow text-xl font-bold uppercase tracking-widest transition-transform hover:text-[#FFFF55] hover:scale-105 outline-none border-none"
style={{
backgroundImage: "url('/images/button_highlighted.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
onClick={() => setErrorModal(null)}
>
OK
</button>
</div>
</motion.div>
)}
</AnimatePresence>
<ChooseInstanceModal
isOpen={acceptInvite !== null}
onClose={() => {
setAcceptInvite(null);
fetchSocialData();
}}
playPressSound={playPressSound}
playBackSound={playBackSound}
editions={editions}
installs={installs}
invite={acceptInvite}
/>
</motion.div>
);
});
export default LceLiveView;
+7 -12
View File
@@ -10,6 +10,7 @@ import {
ScreenshotService,
ScreenshotInfo,
} from "../../services/ScreenshotService";
import { ScreenshotImage } from "../common/ScreenshotImage";
const ScreenshotsView = memo(function ScreenshotsView() {
const { setActiveView } = useUI();
const { playPressSound, playBackSound } = useAudio();
@@ -207,15 +208,12 @@ const ScreenshotsView = memo(function ScreenshotsView() {
}}
>
<div className="w-full h-full relative overflow-hidden bg-black/50">
<img
src={`screenshots://localhost/${ss.path.replace(/\\/g, "/")}`}
<ScreenshotImage
path={ss.path}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
loading="lazy"
alt={ss.name}
onError={(e) => {
(e.target as HTMLImageElement).src =
"/images/Folder_Icon.png";
}}
fallbackSrc="/images/Folder_Icon.png"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-60" />
@@ -288,13 +286,10 @@ const ScreenshotsView = memo(function ScreenshotsView() {
onClick={(e) => e.stopPropagation()}
>
<div className="relative w-full aspect-video bg-black/60 overflow-hidden border border-[#444] rounded-sm">
<img
src={`screenshots://localhost/${selectedScreenshot.path.replace(/\\/g, "/")}`}
<ScreenshotImage
path={selectedScreenshot.path}
className="w-full h-full object-contain"
onError={(e) => {
(e.target as HTMLImageElement).src =
"/images/Pack_Icon.png";
}}
fallbackSrc="/images/Pack_Icon.png"
/>
<div className="absolute bottom-4 left-6 right-6 flex items-end justify-between pointer-events-none">
<div className="flex flex-col gap-1">
+198 -101
View File
@@ -2,16 +2,56 @@ import { useState, useEffect, useRef, useMemo, memo } from "react";
import { motion } from "framer-motion";
import { TauriService, Runner } from "../../services/TauriService";
import { usePlatform } from "../../hooks/usePlatform";
import { useUI, useConfig, useAudio, useGame } from "../../context/LauncherContext";
import {
useUI,
useConfig,
useAudio,
useGame,
} from "../../context/LauncherContext";
const SettingsView = memo(function SettingsView() {
const { setActiveView } = useUI();
const { vfxEnabled, setVfxEnabled, animationsEnabled, setAnimationsEnabled, musicVol: musicVolume, setMusicVol: setMusicVolume, sfxVol: sfxVolume, setSfxVol: setSfxVolume, layout, setLayout, linuxRunner, setLinuxRunner, perfBoost, setPerfBoost, rpcEnabled, setRpcEnabled, legacyMode, setLegacyMode } = useConfig();
const { currentTrack, setCurrentTrack, tracks, playPressSound, playBackSound } = useAudio();
const { isGameRunning, stopGame, isRunnerDownloading, runnerDownloadProgress, downloadRunner } = useGame();
const {
vfxEnabled,
setVfxEnabled,
animationsEnabled,
setAnimationsEnabled,
musicVol: musicVolume,
setMusicVol: setMusicVolume,
sfxVol: sfxVolume,
setSfxVol: setSfxVolume,
layout,
setLayout,
linuxRunner,
setLinuxRunner,
perfBoost,
setPerfBoost,
rpcEnabled,
setRpcEnabled,
legacyMode,
setLegacyMode,
mangohudEnabled,
setMangohudEnabled,
} = useConfig();
const {
currentTrack,
setCurrentTrack,
tracks,
playPressSound,
playBackSound,
} = useAudio();
const {
isGameRunning,
stopGame,
isRunnerDownloading,
runnerDownloadProgress,
downloadRunner,
} = useGame();
const { isLinux, isMac } = usePlatform();
const [focusIndex, setFocusIndex] = useState<number | null>(null);
const [currentSubMenu, setCurrentSubMenu] = useState<"main" | "audio" | "video" | "controls" | "launcher">("main");
const [currentSubMenu, setCurrentSubMenu] = useState<
"main" | "audio" | "video" | "controls" | "launcher"
>("main");
const [runners, setRunners] = useState<Runner[]>([]);
const containerRef = useRef<HTMLDivElement>(null);
@@ -53,6 +93,11 @@ const SettingsView = memo(function SettingsView() {
setLegacyMode(!legacyMode);
};
const handleMangohudToggle = () => {
playPressSound();
setMangohudEnabled(!mangohudEnabled);
};
const handleRunnerToggle = () => {
playPressSound();
if (runners.length === 0) return;
@@ -69,35 +114,37 @@ const SettingsView = memo(function SettingsView() {
const handleResetSetup = () => {
playPressSound();
// Create styled confirmation dialog
const dialog = document.createElement('div');
dialog.className = 'fixed inset-0 bg-black/80 flex items-center justify-center z-50';
const dialog = document.createElement("div");
dialog.className =
"fixed inset-0 bg-black/80 flex items-center justify-center z-50";
dialog.innerHTML = `
<div class="relative p-8 max-w-md mx-4" style="background-image: url('/images/frame_background.png'); background-size: 100% 100%; background-repeat: no-repeat; image-rendering: pixelated;">
<h3 class="text-2xl font-bold text-white mb-4 text-center" style="text-shadow: 2px 2px 0px rgba(0,0,0,0.8)">Reset Setup</h3>
<p class="text-white mb-6 text-center">Are you sure you want to reset launcher setup?</p>
<div class="flex gap-4 justify-center">
<button id="reset-yes" class="mc-sq-btn px-6 py-3 text-white hover:scale-105 active:scale-95 transition-transform">Yes</button>
<button id="reset-no" class="mc-sq-btn px-6 py-3 text-white hover:scale-105 active:scale-95 transition-transform">No</button>
<div class="w-[420px] p-4 flex flex-col items-center mc-options-bg">
<h3 class="text-2xl font-bold text-[#333333] mb-4 text-left w-full px-4 mc-text-shadow">Reset Setup</h3>
<p class="text-[#333333] mb-8 text-left w-full px-4">Are you sure you want to reset launcher setup?</p>
<div class="flex flex-col gap-3 w-full px-4">
<button id="reset-cancel" class="w-full h-10 flex items-center justify-center text-lg mc-text-shadow text-white hover:text-[#ffff00]" style="background-image: url('/images/Button_Background.png'); background-size: 100% 100%; image-rendering: pixelated; border: none; cursor: pointer;" onmouseenter="this.style.backgroundImage='url(/images/button_highlighted.png)'" onmouseleave="this.style.backgroundImage='url(/images/Button_Background.png)'">Cancel</button>
<button id="reset-ok" class="w-full h-10 flex items-center justify-center text-lg mc-text-shadow text-white hover:text-[#ffff00]" style="background-image: url('/images/Button_Background.png'); background-size: 100% 100%; image-rendering: pixelated; border: none; cursor: pointer;" onmouseenter="this.style.backgroundImage='url(/images/button_highlighted.png)'" onmouseleave="this.style.backgroundImage='url(/images/Button_Background.png)'">OK</button>
</div>
</div>
`;
document.body.appendChild(dialog);
const handleYes = () => {
const handleOk = () => {
document.body.removeChild(dialog);
showSecondConfirmation();
};
const handleNo = () => {
const handleCancel = () => {
document.body.removeChild(dialog);
};
dialog.querySelector('#reset-yes')?.addEventListener('click', handleYes);
dialog.querySelector('#reset-no')?.addEventListener('click', handleNo);
dialog.querySelector("#reset-ok")?.addEventListener("click", handleOk);
dialog
.querySelector("#reset-cancel")
?.addEventListener("click", handleCancel);
dialog.addEventListener('click', (e) => {
dialog.addEventListener("click", (e) => {
if (e.target === dialog) {
document.body.removeChild(dialog);
}
@@ -105,43 +152,48 @@ const SettingsView = memo(function SettingsView() {
};
const showSecondConfirmation = () => {
const dialog = document.createElement('div');
dialog.className = 'fixed inset-0 bg-black/80 flex items-center justify-center z-50';
const dialog = document.createElement("div");
dialog.className =
"fixed inset-0 bg-black/80 flex items-center justify-center z-50";
dialog.innerHTML = `
<div class="relative p-8 max-w-md mx-4" style="background-image: url('/images/frame_background.png'); background-size: 100% 100%; background-repeat: no-repeat; image-rendering: pixelated;">
<h3 class="text-2xl font-bold text-yellow-400 mb-4 text-center" style="text-shadow: 2px 2px 0px rgba(0,0,0,0.8)">CONFIRM RESET</h3>
<div class="text-white mb-6 text-left">
<div class="w-[420px] p-4 flex flex-col items-center mc-options-bg">
<h3 class="text-2xl font-bold text-[#333333] mb-2 text-left w-full px-4 mc-text-shadow">CONFIRM RESET</h3>
<div class="text-[#333333] mb-6 text-left w-full px-4">
<p class="mb-2">⚠️ This will:</p>
<ul class="list-disc list-inside space-y-1 text-sm">
<ul class="list-none space-y-1 text-sm">
<li>Clear all launcher settings</li>
<li>Reset your username</li>
<li>Show setup screen again</li>
<li>Require reconfiguration</li>
</ul>
<p class="mt-3 text-yellow-400 font-bold">This action cannot be undone!</p>
<p class="mt-3 text-[#333333] font-bold">This action cannot be undone!</p>
</div>
<div class="flex gap-4 justify-center">
<button id="reset-final-yes" class="mc-sq-btn px-6 py-3 text-yellow-400 hover:scale-105 active:scale-95 transition-transform">YES, RESET</button>
<button id="reset-final-no" class="mc-sq-btn px-6 py-3 text-white hover:scale-105 active:scale-95 transition-transform">Cancel</button>
<div class="flex flex-col gap-3 w-full px-4">
<button id="reset-final-cancel" class="w-full h-10 flex items-center justify-center text-lg mc-text-shadow text-white hover:text-[#ffff00]" style="background-image: url('/images/Button_Background.png'); background-size: 100% 100%; image-rendering: pixelated; border: none; cursor: pointer;" onmouseenter="this.style.backgroundImage='url(/images/button_highlighted.png)'" onmouseleave="this.style.backgroundImage='url(/images/Button_Background.png)'">Cancel</button>
<button id="reset-final-ok" class="w-full h-10 flex items-center justify-center text-lg mc-text-shadow text-white hover:text-[#ffff00]" style="background-image: url('/images/Button_Background.png'); background-size: 100% 100%; image-rendering: pixelated; border: none; cursor: pointer;" onmouseenter="this.style.backgroundImage='url(/images/button_highlighted.png)'" onmouseleave="this.style.backgroundImage='url(/images/Button_Background.png)'">OK</button>
</div>
</div>
`;
document.body.appendChild(dialog);
const handleFinalYes = () => {
const handleFinalOk = () => {
document.body.removeChild(dialog);
performReset();
};
const handleFinalNo = () => {
const handleFinalCancel = () => {
document.body.removeChild(dialog);
};
dialog.querySelector('#reset-final-yes')?.addEventListener('click', handleFinalYes);
dialog.querySelector('#reset-final-no')?.addEventListener('click', handleFinalNo);
dialog
.querySelector("#reset-final-ok")
?.addEventListener("click", handleFinalOk);
dialog
.querySelector("#reset-final-cancel")
?.addEventListener("click", handleFinalCancel);
dialog.addEventListener('click', (e) => {
dialog.addEventListener("click", (e) => {
if (e.target === dialog) {
document.body.removeChild(dialog);
}
@@ -149,13 +201,10 @@ const SettingsView = memo(function SettingsView() {
};
const performReset = () => {
// Clear all localStorage data
localStorage.clear();
// Set setup as not completed
localStorage.setItem('lce-setup-completed', 'false');
localStorage.setItem("lce-setup-completed", "false");
// Force reload to show setup screen
window.location.reload();
};
@@ -163,11 +212,9 @@ const SettingsView = memo(function SettingsView() {
if (tracks && tracks.length > 0) {
const fullPath = tracks[currentTrack];
if (fullPath) {
trackName = fullPath
.split("/")
.pop()
?.replace(".ogg", "")
.replace(".wav", "") || "Unknown";
trackName =
fullPath.split("/").pop()?.replace(".ogg", "").replace(".wav", "") ||
"Unknown";
}
}
@@ -176,20 +223,20 @@ const SettingsView = memo(function SettingsView() {
type SettingsItem =
| {
id: string;
label: string;
type: "slider";
value: number;
onChange: (val: any) => void;
}
id: string;
label: string;
type: "slider";
value: number;
onChange: (val: any) => void;
}
| {
id: string;
label: string;
type: "button";
onClick: () => void;
small?: boolean;
color?: string;
};
id: string;
label: string;
type: "button";
onClick: () => void;
small?: boolean;
color?: string;
};
const settingsItems = useMemo<SettingsItem[]>(() => {
const items: SettingsItem[] = [];
@@ -199,25 +246,41 @@ const SettingsView = memo(function SettingsView() {
id: "audio_menu",
label: "Audio",
type: "button",
onClick: () => { playPressSound(); setCurrentSubMenu("audio"); setFocusIndex(0); },
onClick: () => {
playPressSound();
setCurrentSubMenu("audio");
setFocusIndex(0);
},
});
items.push({
id: "video_menu",
label: "Video",
type: "button",
onClick: () => { playPressSound(); setCurrentSubMenu("video"); setFocusIndex(0); },
onClick: () => {
playPressSound();
setCurrentSubMenu("video");
setFocusIndex(0);
},
});
items.push({
id: "controls_menu",
label: "Controls",
type: "button",
onClick: () => { playPressSound(); setCurrentSubMenu("controls"); setFocusIndex(0); },
onClick: () => {
playPressSound();
setCurrentSubMenu("controls");
setFocusIndex(0);
},
});
items.push({
id: "launcher_menu",
label: "Launcher",
type: "button",
onClick: () => { playPressSound(); setCurrentSubMenu("launcher"); setFocusIndex(0); },
onClick: () => {
playPressSound();
setCurrentSubMenu("launcher");
setFocusIndex(0);
},
});
} else if (currentSubMenu === "audio") {
items.push({
@@ -288,6 +351,12 @@ const SettingsView = memo(function SettingsView() {
type: "button",
onClick: handleRunnerToggle,
});
items.push({
id: "mangohud",
label: `MangoHud: ${mangohudEnabled ? "ON" : "OFF"}`,
type: "button",
onClick: handleMangohudToggle,
});
items.push({
id: "download_runner",
label: isRunnerDownloading
@@ -296,7 +365,10 @@ const SettingsView = memo(function SettingsView() {
type: "button",
onClick: () => {
if (!isRunnerDownloading) {
downloadRunner("GE-Proton9-25", "https://github.com/GloriousEggroll/proton-ge-custom/releases/download/GE-Proton9-25/GE-Proton9-25.tar.gz");
downloadRunner(
"GE-Proton9-25",
"https://github.com/GloriousEggroll/proton-ge-custom/releases/download/GE-Proton9-25/GE-Proton9-25.tar.gz",
);
}
},
small: true,
@@ -349,6 +421,7 @@ const SettingsView = memo(function SettingsView() {
animationsEnabled,
layout,
isLinux,
mangohudEnabled,
selectedRunnerName,
isRunnerDownloading,
runnerDownloadProgress,
@@ -363,6 +436,7 @@ const SettingsView = memo(function SettingsView() {
handleLayoutToggle,
handleRunnerToggle,
handlePerfToggle,
handleMangohudToggle,
handleResetSetup,
stopGame,
downloadRunner,
@@ -435,11 +509,16 @@ const SettingsView = memo(function SettingsView() {
backgroundImage: "url('/images/Button_Background2.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated" as const,
color: focusIndex === index ? "#FFFF55" : "white",
color: focusIndex === index ? "#ffff00" : "white",
});
const isToggleOption = (label: string): boolean => {
return label.includes("ON") || label.includes("OFF") || label.includes("Enabled") || label.includes("Disabled");
return (
label.includes("ON") ||
label.includes("OFF") ||
label.includes("Enabled") ||
label.includes("Disabled")
);
};
const getToggleState = (label: string): boolean => {
@@ -454,10 +533,18 @@ const SettingsView = memo(function SettingsView() {
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: animationsEnabled ? 0.3 : 0 }}
className="flex flex-col items-center w-full max-w-2xl outline-none"
className="flex flex-col items-center w-full max-w-3xl outline-none"
>
<h2 className="text-2xl text-white mc-text-shadow mt-2 mb-4 border-b-2 border-[#373737] pb-2 w-[40%] max-w-[200px] text-center tracking-widest uppercase opacity-80 font-bold whitespace-nowrap px-4">
{currentSubMenu === "main" ? "Settings" : currentSubMenu === "audio" ? "Audio" : currentSubMenu === "video" ? "Video" : currentSubMenu === "controls" ? "Controls" : "Launcher"}
{currentSubMenu === "main"
? "Settings"
: currentSubMenu === "audio"
? "Audio"
: currentSubMenu === "video"
? "Video"
: currentSubMenu === "controls"
? "Controls"
: "Launcher"}
</h2>
{currentSubMenu === "main" ? (
@@ -472,11 +559,11 @@ const SettingsView = memo(function SettingsView() {
data-index={index}
tabIndex={0}
onMouseEnter={() => setFocusIndex(index)}
className="relative w-[360px] h-10 flex items-center justify-center cursor-pointer transition-all outline-none border-none hover:text-[#FFFF55] shrink-0"
className="relative w-[360px] h-10 flex items-center justify-center cursor-pointer transition-all outline-none border-none hover:text-[#ffff00] shrink-0"
style={getSliderStyle(index)}
>
<span
className={`absolute z-10 text-xl mc-text-shadow pointer-events-none transition-colors tracking-widest ${focusIndex === index ? "text-[#FFFF55]" : "text-white"}`}
className={`absolute z-10 text-xl mc-text-shadow pointer-events-none transition-colors tracking-widest ${focusIndex === index ? "text-[#ffff00]" : "text-white"}`}
>
{item.label}
</span>
@@ -505,14 +592,15 @@ const SettingsView = memo(function SettingsView() {
data-index={index}
onMouseEnter={() => setFocusIndex(index)}
onClick={item.onClick}
className={`w-[360px] h-10 flex items-center justify-center px-4 relative z-30 transition-colors outline-none border-none shrink-0 ${isRed
? focusIndex === index
? "text-red-400"
: "text-red-200"
: focusIndex === index
? "text-[#FFFF55]"
: "text-white"
} ${isRed ? "hover:text-red-500" : "hover:text-[#FFFF55]"}`}
className={`w-[360px] h-10 flex items-center justify-center px-4 relative z-30 transition-colors outline-none border-none shrink-0 ${
isRed
? focusIndex === index
? "text-red-400"
: "text-red-200"
: focusIndex === index
? "text-[#ffff00]"
: "text-white"
} ${isRed ? "hover:text-red-500" : "hover:text-[#ffff00]"}`}
style={getItemStyle(index)}
>
<span
@@ -525,15 +613,7 @@ const SettingsView = memo(function SettingsView() {
})}
</div>
) : (
<div
className="min-w-[420px] w-fit p-6 flex flex-col items-center"
style={{
backgroundImage: "url('/images/background.png')",
backgroundSize: "100% 100%",
backgroundRepeat: "no-repeat",
imageRendering: "pixelated",
}}
>
<div className="min-w-[420px] w-fit p-4 flex flex-col items-center mc-options-bg">
<div className="w-full space-y-3 flex flex-col items-center overflow-y-auto max-h-[50vh] py-2">
{settingsItems.map((item, index) => {
if (item.id === "back") return null;
@@ -545,11 +625,11 @@ const SettingsView = memo(function SettingsView() {
data-index={index}
tabIndex={0}
onMouseEnter={() => setFocusIndex(index)}
className="relative w-[360px] h-10 flex items-center justify-center cursor-pointer transition-all outline-none border-none hover:text-[#FFFF55] shrink-0"
className="relative w-[360px] h-10 flex items-center justify-center cursor-pointer transition-all outline-none border-none hover:text-[#ffff00] shrink-0"
style={getSliderStyle(index)}
>
<span
className={`absolute z-10 text-xl pointer-events-none transition-colors tracking-widest ${focusIndex === index ? "text-[#FFFF55]" : item.id === "music" || item.id === "sfx" ? "text-white" : "text-[#2a2a2a]"}`}
className={`absolute z-10 text-xl pointer-events-none transition-colors tracking-widest ${focusIndex === index ? "text-[#ffff00]" : item.id === "music" || item.id === "sfx" ? "text-white" : "text-[#2a2a2a]"}`}
>
{item.label}
</span>
@@ -560,7 +640,9 @@ const SettingsView = memo(function SettingsView() {
max="100"
step="1"
value={item.value}
onChange={(e) => item.onChange(parseInt(e.target.value))}
onChange={(e) =>
item.onChange(parseInt(e.target.value))
}
onMouseUp={playPressSound}
className="mc-slider-custom w-[calc(100%+16px)] h-full opacity-100 cursor-pointer z-20 outline-none m-0"
/>
@@ -580,21 +662,35 @@ const SettingsView = memo(function SettingsView() {
data-index={index}
onMouseEnter={() => setFocusIndex(index)}
onClick={item.onClick}
className={`w-[360px] h-10 flex items-center justify-between px-4 relative z-30 transition-all outline-none border-none shrink-0 rounded ${focusIndex === index ? "bg-black/10" : "bg-transparent"} ${isRed ? "text-red-600" : "text-[#2a2a2a]"} hover:bg-black/15`}
className={`w-[360px] h-10 flex items-center pl-1.5 pr-4 relative z-30 outline-none border-none shrink-0 rounded ${focusIndex === index ? "text-[#ffff00]" : isRed ? "text-red-600" : "text-[#333333]"}`}
>
{isToggle && (
<div className="relative w-6 h-6 mr-3 shrink-0">
<img
src={
focusIndex === index
? "/images/checkbox_highlighted.png"
: "/images/checkbox.png"
}
alt="checkbox"
className="w-full h-full object-contain"
style={{ imageRendering: "pixelated" }}
/>
{toggleState && (
<img
src="/images/check.png"
alt="checked"
className="absolute inset-0 w-full h-full object-contain"
style={{ imageRendering: "pixelated" }}
/>
)}
</div>
)}
<span
className={`tracking-widest uppercase ${isSmall ? "text-xs" : item.label.length > 25 ? "text-base" : "text-lg"} truncate text-left flex-1`}
className={`tracking-widest text-xl mc-text-shadow ${isSmall ? "text-xs" : item.label.length > 25 ? "text-base" : "text-lg"} truncate text-left`}
>
{isToggle ? item.label.split(":")[0] : item.label}
</span>
{isToggle && (
<img
src={toggleState ? "/images/Toggle_Switch_On.png" : "/images/Toggle_Switch_Off.png"}
alt={toggleState ? "ON" : "OFF"}
className="w-12 h-6 object-contain shrink-0 ml-2"
style={{ imageRendering: "pixelated" }}
/>
)}
</button>
);
})}
@@ -612,11 +708,12 @@ const SettingsView = memo(function SettingsView() {
data-index={backIndex}
onMouseEnter={() => setFocusIndex(backIndex)}
onClick={backItem.onClick}
className={`w-72 h-10 flex items-center justify-center transition-colors text-xl mc-text-shadow outline-none border-none hover:text-[#FFFF55] mt-4 ${focusIndex === backIndex ? "text-[#FFFF55]" : "text-white"}`}
className={`w-40 h-10 flex items-center justify-center transition-colors text-xl mc-text-shadow outline-none border-none hover:text-[#ffff00] mt-4 ${focusIndex === backIndex ? "text-[#ffff00]" : "text-white"}`}
style={{
backgroundImage: focusIndex === backIndex
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundImage:
focusIndex === backIndex
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
+7
View File
@@ -46,7 +46,14 @@ const DEFAULT_SKINS: SavedSkin[] = [
},
{ id: "peter", name: "Peter", url: "/Skins/Peter.png", isSlim: false },
{ id: "piebot", name: "piebot", url: "/Skins/piebot.png", isSlim: false },
{ id: "andipog", name: "Andi_Pog", url: "/Skins/andi.png", isSlim: false },
{ id: "sevenhundred", name: "700", url: "/Skins/700.png", isSlim: false },
{
id: "prismachunk0",
name: "PrismaChunk0",
url: "/Skins/PrismaChunk0.png",
isSlim: false,
},
];
const SkinsView = memo(function SkinsView() {
+203 -75
View File
@@ -2,12 +2,14 @@ import { useState, useEffect, useRef, memo } from "react";
import { motion } from "framer-motion";
import { TauriService } from "../../services/TauriService";
import CustomTUModal from "../modals/CustomTUModal";
import SetUidModal from "../modals/SetUidModal";
import {
useUI,
useConfig,
useAudio,
useGame,
} from "../../context/LauncherContext";
import { ScreenshotImage } from "../common/ScreenshotImage";
interface DeleteConfirmButtonProps {
label: string;
onClick: () => void;
@@ -26,8 +28,9 @@ const DeleteConfirmButton = memo(function DeleteConfirmButton({
onClick={onClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className={`w-24 h-10 flex items-center justify-center mc-text-shadow transition-colors ${isDanger ? "text-red-500" : "text-white"
} ${isHovered ? (isDanger ? "text-red-400" : "text-[#FFFF55]") : ""}`}
className={`w-24 h-10 flex items-center justify-center mc-text-shadow transition-colors ${
isDanger ? "text-red-500" : "text-white"
} ${isHovered ? (isDanger ? "text-red-400" : "text-[#FFFF55]") : ""}`}
style={{
backgroundImage: isHovered
? "url('/images/button_highlighted.png')"
@@ -68,6 +71,8 @@ const VersionsView = memo(function VersionsView() {
const [focusIndex, setFocusIndex] = useState<number>(0);
const [focusBtn, setFocusBtn] = useState<number>(0);
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
const [isSetUidModalOpen, setIsSetUidModalOpen] = useState(false);
const [setUidTargetId, setSetUidTargetId] = useState("");
const [editingEdition, setEditingEdition] = useState<any>(null);
const [initialPath, setInitialPath] = useState<string>("");
const [hoveredBtn, setHoveredBtn] = useState<{
@@ -166,7 +171,7 @@ const VersionsView = memo(function VersionsView() {
toggleInstall,
handleCancelDownload,
addToSteam,
isDayTime
isDayTime,
]);
useEffect(() => {
@@ -208,7 +213,7 @@ const VersionsView = memo(function VersionsView() {
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ duration: animationsEnabled ? 0.25 : 0 }}
className="flex flex-col items-center w-full max-w-2xl outline-none"
className="flex flex-col items-center w-full max-w-5xl outline-none"
>
<h2 className="text-2xl text-white mc-text-shadow mt-2 mb-4 pb-2 w-[40%] max-w-[200px] text-center tracking-widest uppercase font-bold">
Versions
@@ -229,7 +234,9 @@ const VersionsView = memo(function VersionsView() {
>
<div className="flex flex-col gap-1">
{editions.map((edition: any, i: number) => {
const isInstalled = installedVersions.includes(edition.instanceId);
const isInstalled = installedVersions.includes(
edition.instanceId,
);
const hasAnyInstall = installedVersions.length > 0;
const isSelected =
hasAnyInstall && selectedProfile === edition.instanceId;
@@ -242,9 +249,11 @@ const VersionsView = memo(function VersionsView() {
<div
key={edition.id}
data-index={i}
className={`w-[calc(100%-16px)] mx-2 flex items-center gap-3 p-2 rounded-sm ${isSelected && !isComingSoon ? "bg-[#404040]/50" : ""
} ${isFocused && !isComingSoon ? "ring-2 ring-white" : ""} ${isComingSoon ? "opacity-50 cursor-not-allowed" : ""
} relative ${openMenuId === edition.id ? "z-50" : "z-0"}`}
className={`w-[calc(100%-16px)] mx-2 flex items-center gap-3 p-2 rounded-sm ${
isSelected && !isComingSoon ? "bg-[#404040]/50" : ""
} ${isFocused && !isComingSoon ? "ring-2 ring-white" : ""} ${
isComingSoon ? "opacity-50 cursor-not-allowed" : ""
} relative ${openMenuId === edition.id ? "z-50" : "z-0"}`}
onMouseEnter={() => !isComingSoon && setFocusIndex(i)}
>
<div className="w-6 flex items-center justify-center flex-shrink-0">
@@ -280,28 +289,34 @@ const VersionsView = memo(function VersionsView() {
onClick={() =>
!isComingSoon && handleEditionClick(edition, i)
}
className={`flex-1 text-left min-w-0 outline-none rounded cursor-pointer ${focusIndex === i && focusBtn === 0 && !isComingSoon
? "ring-2 ring-white"
: ""
} ${isComingSoon ? "cursor-not-allowed" : ""}`}
className={`flex-1 text-left min-w-0 outline-none rounded cursor-pointer ${
focusIndex === i && focusBtn === 0 && !isComingSoon
? "ring-2 ring-white"
: ""
} ${isComingSoon ? "cursor-not-allowed" : ""}`}
>
<div className="flex items-center gap-2">
{edition.logo && (
<img
src={
edition.logo.startsWith("http") ||
edition.logo.startsWith("/images")
? edition.logo
: `screenshots://localhost/${edition.logo.replace(/\\/g, "/")}`
}
alt=""
className="w-5 h-5 object-contain flex-shrink-0"
style={{ imageRendering: "pixelated" }}
/>
)}
{edition.logo &&
(edition.logo.startsWith("http") ||
edition.logo.startsWith("/images") ? (
<img
src={edition.logo}
alt=""
className="w-5 h-5 object-contain flex-shrink-0"
style={{ imageRendering: "pixelated" }}
/>
) : (
<ScreenshotImage
path={edition.logo}
alt=""
className="w-5 h-5 object-contain flex-shrink-0"
style={{ imageRendering: "pixelated" }}
/>
))}
<span
className={`text-xl tracking-wide truncate ${isSelected ? "text-white" : "text-black"
}`}
className={`text-xl tracking-wide truncate ${
isSelected ? "text-white" : "text-black"
}`}
style={{ textShadow: "none" }}
>
{edition.name}
@@ -322,8 +337,9 @@ const VersionsView = memo(function VersionsView() {
)}
</div>
<p
className={`text-base font-medium leading-tight ${isSelected ? "text-[#DDDDDD]" : "text-[#666666]"
}`}
className={`text-base font-medium leading-tight ${
isSelected ? "text-[#DDDDDD]" : "text-[#666666]"
}`}
>
{edition.desc}
</p>
@@ -340,23 +356,40 @@ const VersionsView = memo(function VersionsView() {
handleCancelDownload();
}
}}
onMouseEnter={() => setHoveredBtn({ row: i, btn: "main" })}
onMouseEnter={() =>
setHoveredBtn({ row: i, btn: "main" })
}
onMouseLeave={() => setHoveredBtn(null)}
className={`w-9 h-9 flex items-center justify-center ${(isDownloading || (!!downloadingId && !isInstalled)) ? "opacity-50" : ""
}`}
className={`w-9 h-9 flex items-center justify-center ${
isDownloading || (!!downloadingId && !isInstalled)
? "opacity-50"
: ""
}`}
style={{
backgroundImage: (hoveredBtn?.row === i && hoveredBtn?.btn === "main") || (focusIndex === i && focusBtn === 0)
? "url('/images/Button_Square_Highlighted.png')"
: "url('/images/Button_Square.png')",
backgroundImage:
(hoveredBtn?.row === i &&
hoveredBtn?.btn === "main") ||
(focusIndex === i && focusBtn === 0)
? "url('/images/Button_Square_Highlighted.png')"
: "url('/images/Button_Square.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
<img
src={isDownloading ? "/images/Trash_Bin_Icon.png" : "/images/Download_Icon.png"}
src={
isDownloading
? "/images/Trash_Bin_Icon.png"
: "/images/Download_Icon.png"
}
alt=""
className="w-5 h-5 object-contain"
style={{ imageRendering: "pixelated", filter: isDownloading ? "hue-rotate(300deg)" : "none" }}
style={{
imageRendering: "pixelated",
filter: isDownloading
? "hue-rotate(300deg)"
: "none",
}}
/>
</button>
) : (
@@ -364,23 +397,39 @@ const VersionsView = memo(function VersionsView() {
onClick={(e) => {
e.stopPropagation();
playPressSound();
setOpenMenuId(openMenuId === edition.id ? null : edition.id);
setOpenMenuId(
openMenuId === edition.id ? null : edition.id,
);
}}
onMouseEnter={() => setHoveredBtn({ row: i, btn: "menu" })}
onMouseEnter={() =>
setHoveredBtn({ row: i, btn: "menu" })
}
onMouseLeave={() => setHoveredBtn(null)}
className="w-9 h-9 flex flex-col items-center justify-center gap-1 transition-colors relative"
style={{
backgroundImage: (hoveredBtn?.row === i && hoveredBtn?.btn === "menu") || (focusIndex === i && (focusBtn === 0 || focusBtn === 1))
? "url('/images/Button_Square_Highlighted.png')"
: "url('/images/Button_Square.png')",
backgroundImage:
(hoveredBtn?.row === i &&
hoveredBtn?.btn === "menu") ||
(focusIndex === i &&
(focusBtn === 0 || focusBtn === 1))
? "url('/images/Button_Square_Highlighted.png')"
: "url('/images/Button_Square.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
filter: updatesAvailable?.[edition.instanceId] ? "drop-shadow(0 0 4px rgba(255,255,0,0.8))" : "none"
filter: updatesAvailable?.[edition.instanceId]
? "drop-shadow(0 0 4px rgba(255,255,0,0.8))"
: "none",
}}
>
<div className={`w-1.5 h-1.5 ${updatesAvailable?.[edition.instanceId] ? "bg-[#ffff55]" : "bg-white"}`} />
<div className={`w-1.5 h-1.5 ${updatesAvailable?.[edition.instanceId] ? "bg-[#ffff55]" : "bg-white"}`} />
<div className={`w-1.5 h-1.5 ${updatesAvailable?.[edition.instanceId] ? "bg-[#ffff55]" : "bg-white"}`} />
<div
className={`w-1.5 h-1.5 ${updatesAvailable?.[edition.instanceId] ? "bg-[#ffff55]" : "bg-white"}`}
/>
<div
className={`w-1.5 h-1.5 ${updatesAvailable?.[edition.instanceId] ? "bg-[#ffff55]" : "bg-white"}`}
/>
<div
className={`w-1.5 h-1.5 ${updatesAvailable?.[edition.instanceId] ? "bg-[#ffff55]" : "bg-white"}`}
/>
</button>
)}
@@ -388,7 +437,7 @@ const VersionsView = memo(function VersionsView() {
<div
className="absolute right-0 top-11 w-48 bg-[#1a1a1a] border-2 border-[#555] z-[100] shadow-2xl p-0.5 animate-in fade-in zoom-in duration-75"
style={{
imageRendering: "pixelated"
imageRendering: "pixelated",
}}
>
{updatesAvailable?.[edition.instanceId] && (
@@ -401,27 +450,33 @@ const VersionsView = memo(function VersionsView() {
}}
className="w-full text-left px-3 py-1.5 text-xs text-[#ffff55] hover:text-white hover:bg-[#ffff55]/20 flex items-center gap-2 group transition-colors mc-text-shadow font-bold border-b border-white/5 mb-1"
>
<img src="/images/Download_Icon.png" alt="" className="w-3 h-3 object-contain" style={{ imageRendering: "pixelated" }} />
<img
src="/images/Download_Icon.png"
alt=""
className="w-3 h-3 object-contain"
style={{ imageRendering: "pixelated" }}
/>
Update Available!
</button>
)}
{Array.isArray(edition.branches) && edition.branches.length > 0 && (
<button
onClick={(e) => {
e.stopPropagation();
playPressSound();
cycleBranch(edition.id);
}}
className="w-full text-left px-3 py-1.5 text-[10px] text-white mc-text-shadow hover:bg-white/10 flex items-center justify-between group transition-colors"
>
<span className="text-[#AAAAAA] group-hover:text-white font-bold">
Channel
</span>
<span className="text-[#ffff55] font-bold">
{edition.selectedBranch ?? "Latest"}
</span>
</button>
)}
{Array.isArray(edition.branches) &&
edition.branches.length > 0 && (
<button
onClick={(e) => {
e.stopPropagation();
playPressSound();
cycleBranch(edition.id);
}}
className="w-full text-left px-3 py-1.5 text-[10px] text-white mc-text-shadow hover:bg-white/10 flex items-center justify-between group transition-colors"
>
<span className="text-[#AAAAAA] group-hover:text-white font-bold">
Channel
</span>
<span className="text-[#ffff55] font-bold">
{edition.selectedBranch ?? "Latest"}
</span>
</button>
)}
<div className="h-[1px] bg-white/5 my-0.5 mx-1" />
<button
onClick={(e) => {
@@ -432,24 +487,72 @@ const VersionsView = memo(function VersionsView() {
}}
className="w-full text-left px-3 py-2 text-xs text-[#dddddd] hover:text-white hover:bg-white/10 flex items-center gap-2 transition-colors mc-text-shadow"
>
<img src="/images/Folder_Icon.png" alt="" className="w-3.5 h-3.5 object-contain" style={{ imageRendering: "pixelated" }} />
<img
src="/images/Folder_Icon.png"
alt=""
className="w-3.5 h-3.5 object-contain"
style={{ imageRendering: "pixelated" }}
/>
Open Folder
</button>
<button
onClick={(e) => {
e.stopPropagation();
playPressSound();
const PANORAMA_PROFILES = ['legacy_evolved', 'vanilla_tu19', '360revived', 'vanilla_tu24'];
const panoId = PANORAMA_PROFILES.includes(edition.id) ? edition.id : 'vanilla_tu19';
const panoramaUrl = `/panorama/${panoId}_Panorama_Background_${isDayTime ? 'Day' : 'Night'}.png`;
addToSteam(edition.instanceId, edition.name, edition.titleImage, panoramaUrl);
const PANORAMA_PROFILES = [
"legacy_evolved",
"vanilla_tu19",
"360revived",
"vanilla_tu24",
];
const panoId = PANORAMA_PROFILES.includes(
edition.id,
)
? edition.id
: "vanilla_tu19";
const panoramaUrl = `/panorama/${panoId}_Panorama_Background_${isDayTime ? "Day" : "Night"}.png`;
addToSteam(
edition.instanceId,
edition.name,
edition.titleImage,
panoramaUrl,
);
setOpenMenuId(null);
}}
className="w-full text-left px-3 py-2 text-xs text-[#dddddd] hover:text-white hover:bg-white/10 flex items-center gap-2 transition-colors mc-text-shadow"
>
<img src="/images/steam.png" alt="" className="w-3.5 h-3.5 object-contain invert brightness-0" style={{ imageRendering: "pixelated" }} />
<img
src="/images/steam.png"
alt=""
className="w-3.5 h-3.5 object-contain invert brightness-0"
style={{ imageRendering: "pixelated" }}
/>
Add to Steam
</button>
<button
onClick={(e) => {
e.stopPropagation();
playPressSound();
setSetUidTargetId(edition.instanceId);
setIsSetUidModalOpen(true);
setOpenMenuId(null);
}}
className="w-full text-left px-3 py-2 text-xs text-[#dddddd] hover:text-white hover:bg-white/10 flex items-center gap-2 transition-colors mc-text-shadow"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className="w-3.5 h-3.5"
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
Set UID
</button>
{isCustom ? (
<button
onClick={(e) => {
@@ -461,7 +564,17 @@ const VersionsView = memo(function VersionsView() {
}}
className="w-full text-left px-3 py-2 text-xs text-[#aaaaaa] hover:text-white hover:bg-white/10 flex items-center gap-2 transition-colors mc-text-shadow"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="w-3.5 h-3.5"><path d="M12 20h9M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" /></svg>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className="w-3.5 h-3.5"
>
<path d="M12 20h9M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
</svg>
Edit Custom
</button>
) : null}
@@ -479,7 +592,12 @@ const VersionsView = memo(function VersionsView() {
}}
className="w-full text-left px-3 py-2 text-xs text-red-500 hover:text-red-400 hover:bg-red-500/10 flex items-center gap-2 transition-colors mc-text-shadow font-bold"
>
<img src="/images/Trash_Bin_Icon.png" alt="" className="w-3.5 h-3.5 object-contain" style={{ imageRendering: "pixelated" }} />
<img
src="/images/Trash_Bin_Icon.png"
alt=""
className="w-3.5 h-3.5 object-contain"
style={{ imageRendering: "pixelated" }}
/>
{isCustom ? "Remove Custom" : "Uninstall"}
</button>
</div>
@@ -503,7 +621,7 @@ const VersionsView = memo(function VersionsView() {
backgroundImage:
(hoveredBtn?.row === editions.length &&
hoveredBtn?.btn === "add") ||
focusIndex === editions.length
focusIndex === editions.length
? "url('/images/Button_Square_Highlighted.png')"
: "url('/images/Button_Square.png')",
backgroundSize: "100% 100%",
@@ -536,7 +654,7 @@ const VersionsView = memo(function VersionsView() {
backgroundImage:
(hoveredBtn?.row === editions.length &&
hoveredBtn?.btn === "folder_import") ||
focusIndex === editions.length + 1
focusIndex === editions.length + 1
? "url('/images/Button_Square_Highlighted.png')"
: "url('/images/Button_Square.png')",
backgroundSize: "100% 100%",
@@ -598,6 +716,16 @@ const VersionsView = memo(function VersionsView() {
initialPath={initialPath}
/>
<SetUidModal
isOpen={isSetUidModalOpen}
onClose={() => setIsSetUidModalOpen(false)}
playPressSound={playPressSound}
playBackSound={playBackSound}
instances={editions}
installedVersions={installedVersions}
targetInstanceId={setUidTargetId}
/>
{deleteConfirmEdition && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
<div
+4 -2
View File
@@ -65,7 +65,7 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
configRaw.username, configRaw.theme, configRaw.layout, configRaw.vfxEnabled,
configRaw.rpcEnabled, configRaw.musicVol, configRaw.sfxVol, configRaw.isDayTime,
configRaw.profile, configRaw.linuxRunner, configRaw.perfBoost, configRaw.customEditions,
configRaw.legacyMode, configRaw.animationsEnabled
configRaw.legacyMode, configRaw.animationsEnabled, configRaw.mangohudEnabled
]);
const game = useMemo(() => gameRaw, [
@@ -129,13 +129,15 @@ export function LauncherProvider({ children }: { children: React.ReactNode }) {
musicVol: config.musicVol,
sfxVol: config.sfxVol,
legacyMode: config.legacyMode,
mangohudEnabled: config.mangohudEnabled,
}).catch(console.error);
}
}, [
config.username, skinSync.skinBase64, config.theme, config.linuxRunner,
config.perfBoost, config.customEditions, config.profile,
config.vfxEnabled, config.animationsEnabled,
config.rpcEnabled, config.musicVol, config.sfxVol, config.legacyMode, config.isLoaded
config.rpcEnabled, config.musicVol, config.sfxVol, config.legacyMode,
config.mangohudEnabled, config.isLoaded
]);
useEffect(() => {
+312 -227
View File
@@ -1,227 +1,312 @@
@import "tailwindcss";
@font-face {
font-family: 'Mojangles';
src: url('/fonts/Mojangles.ttf') format('truetype');
font-weight: normal;
font-style: normal;
}
.gamepad-connected-indicator {
position: fixed;
bottom: 10px;
left: 10px;
color: #FFFF55;
font-size: 10px;
z-index: 1000;
opacity: 0.5;
}
.mc-setup-nav-btn {
border: 4px solid transparent;
border-image: url('/images/Button_Background.png') 4 stretch;
image-rendering: pixelated;
background: rgba(0,0,0,0.6);
transition: all 0.2s ease;
}
.mc-setup-nav-btn:hover:not(:disabled) {
transform: scale(1.05);
}
.mc-setup-nav-btn:active:not(:disabled) {
transform: scale(0.95);
}
.mc-setup-nav-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
* {
cursor: url('/images/pointer.png') 16 16, auto;
}
body {
margin: 0;
background-color: #000;
font-family: 'Mojangles', monospace;
-webkit-font-smoothing: none;
}
.mc-button {
background-image: url('/images/button.png');
background-size: 100% 100%;
image-rendering: pixelated;
transition: none;
}
.mc-button:hover:not(:disabled) {
background-image: url('/images/button_highlighted.png');
}
.mc-text-shadow {
text-shadow: 1px 1px 0px rgba(63, 63, 63, 1);
}
.mc-text-shadow-hover {
text-shadow: 1px 1px 0px rgba(63, 90, 63, 1);
}
@keyframes sga-burst {
0% {
transform: translate(-50%, -50%) scale(0.5);
opacity: 1;
}
100% {
transform: translate(calc(-50% + var(--vX)), calc(-50% + var(--vY))) scale(1.2) rotate(var(--rot));
opacity: 0;
}
}
.particle-burst {
animation: sga-burst 0.8s ease-out forwards;
filter: drop-shadow(0 0 8px rgba(180, 100, 255, 0.9));
}
::-webkit-scrollbar {
width: 14px;
}
::-webkit-scrollbar-track {
background: url('/images/SliderHandlerBackground.png') center;
background-size: 100% 100%;
image-rendering: pixelated;
}
::-webkit-scrollbar-thumb {
background: url('/images/Slider_Handle.png') no-repeat center;
background-size: 100% 100%;
image-rendering: pixelated;
min-height: 44px;
}
::-webkit-scrollbar-thumb:hover {
filter: brightness(1.2);
}
.custom-scrollbar::-webkit-scrollbar {
width: 14px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: url('/images/SliderHandlerBackground.png') center;
background-size: 100% 100%;
image-rendering: pixelated;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: url('/images/Slider_Handle.png') no-repeat center;
background-size: 100% 100%;
image-rendering: pixelated;
min-height: 44px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
filter: brightness(1.2);
}
.no-animations * {
transition: none !important;
animation: none !important;
}
.workshop-markdown h1,
.workshop-markdown h2,
.workshop-markdown h3 {
color: #FFFF55;
margin-top: 1rem;
margin-bottom: 0.5rem;
text-transform: uppercase;
}
.workshop-markdown strong {
color: #FFFF55;
}
.workshop-markdown ul,
.workshop-markdown ol {
margin-left: 1.5rem;
margin-bottom: 1rem;
}
.workshop-markdown li {
margin-bottom: 0.25rem;
}
.workshop-markdown a {
color: #55FFFF;
text-decoration: underline;
}
.workshop-markdown code {
background: rgba(0,0,0,0.4);
padding: 2px 4px;
border-radius: 2px;
color: #FF55FF;
}
.workshop-markdown table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
margin-bottom: 1rem;
background: rgba(0,0,0,0.3);
border: 2px solid #555;
image-rendering: pixelated;
}
.workshop-markdown th {
background: rgba(0,0,0,0.5);
color: #FFFF55;
padding: 8px;
border: 1px solid #444;
text-align: left;
text-transform: uppercase;
font-size: 0.8rem;
}
.workshop-markdown td {
padding: 8px;
border: 1px solid #333;
color: #E0E0E0;
font-size: 0.9rem;
}
.workshop-markdown tr:nth-child(even) {
background: rgba(255,255,255,0.05);
}
.workshop-markdown blockquote {
border-left: 4px solid #555555;
padding-left: 1rem;
margin: 1rem 0;
color: #A0A0A0;
font-style: italic;
background: rgba(255,255,255,0.02);
}
.workshop-markdown del {
color: #AA0000;
text-decoration: line-through;
}
.workshop-markdown input[type="checkbox"] {
accent-color: #FFFF55;
margin-right: 0.5rem;
transform: scale(1.2);
vertical-align: middle;
}
.workshop-markdown hr {
border: none;
border-top: 2px solid #373737;
margin: 1.5rem 0;
}
@import "tailwindcss";
@font-face {
font-family: "Mojangles";
src: url("/fonts/Mojangles.ttf") format("truetype");
font-weight: normal;
font-style: normal;
}
.gamepad-connected-indicator {
position: fixed;
bottom: 10px;
left: 10px;
color: #ffff55;
font-size: 10px;
z-index: 1000;
opacity: 0.5;
}
.mc-setup-nav-btn {
border: 4px solid transparent;
border-image: url("/images/Button_Background.png") 4 stretch;
image-rendering: pixelated;
background: rgba(0, 0, 0, 0.6);
transition: all 0.2s ease;
}
.mc-setup-nav-btn:hover:not(:disabled) {
transform: scale(1.05);
}
.mc-setup-nav-btn:active:not(:disabled) {
transform: scale(0.95);
}
.mc-setup-nav-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.mc-options-bg {
border-image: url("/images/options_background.png") 4 fill;
border-width: 12px;
border-style: solid;
border-color: transparent;
image-rendering: pixelated;
}
.mc-lce-tab {
background: linear-gradient(180deg, #c0c0c0 0%, #a0a0a0 100%);
border: 2px solid #808080;
border-bottom: none;
border-radius: 4px 4px 0 0;
box-shadow:
inset 2px 2px 0 #e0e0e0,
inset -2px 0 0 #808080,
0 -2px 0 #606060;
image-rendering: pixelated;
position: relative;
transform: translateY(2px);
transition: all 0.1s ease;
}
.mc-lce-tab.active {
background: linear-gradient(180deg, #d0d0d0 0%, #b0b0b0 100%);
transform: translateY(0);
z-index: 10;
box-shadow:
inset 2px 2px 0 #f0f0f0,
inset -2px 0 0 #909090,
0 -3px 0 #707070;
}
.mc-lce-content {
background: linear-gradient(180deg, #b0b0b0 0%, #909090 100%);
border: 3px solid #808080;
border-radius: 0 4px 4px 4px;
box-shadow:
inset 3px 3px 0 #d0d0d0,
inset -3px -3px 0 #606060;
position: relative;
}
.mc-lce-content::before {
content: "";
position: absolute;
top: 8px;
left: 8px;
right: 8px;
bottom: 8px;
border: 2px solid #ffffff;
pointer-events: none;
opacity: 0.6;
}
.mc-lce-tab-bg {
border: 4px solid transparent;
border-image: url("/images/options_background.png") 4 fill;
image-rendering: pixelated;
position: relative;
transform: translateY(4px);
z-index: 1;
transition: all 0.15s ease;
min-height: 40px;
background-color: rgba(0, 0, 0, 0.3);
}
.mc-lce-tab-bg.active {
transform: translateY(-4px);
z-index: 20;
}
.mc-lce-content-bg {
border-image: url("/images/options_background.png") 4 fill;
border-width: 12px;
border-style: solid;
border-color: transparent;
image-rendering: pixelated;
position: relative;
z-index: 10;
margin-top: -8px;
}
* {
cursor:
url("/images/pointer.png") 16 16,
auto;
}
body {
margin: 0;
background-color: #000;
font-family: "Mojangles", monospace;
-webkit-font-smoothing: none;
}
.mc-button {
background-image: url("/images/button.png");
background-size: 100% 100%;
image-rendering: pixelated;
transition: none;
}
.mc-button:hover:not(:disabled) {
background-image: url("/images/button_highlighted.png");
}
.mc-text-shadow {
text-shadow: 1px 1px 0px rgba(63, 63, 63, 1);
}
.mc-text-shadow-hover {
text-shadow: 1px 1px 0px rgba(63, 90, 63, 1);
}
@keyframes sga-burst {
0% {
transform: translate(-50%, -50%) scale(0.5);
opacity: 1;
}
100% {
transform: translate(calc(-50% + var(--vX)), calc(-50% + var(--vY)))
scale(1.2) rotate(var(--rot));
opacity: 0;
}
}
.particle-burst {
animation: sga-burst 0.8s ease-out forwards;
filter: drop-shadow(0 0 8px rgba(180, 100, 255, 0.9));
}
::-webkit-scrollbar {
width: 14px;
}
::-webkit-scrollbar-track {
background: url("/images/SliderHandlerBackground.png") center;
background-size: 100% 100%;
image-rendering: pixelated;
}
::-webkit-scrollbar-thumb {
background: url("/images/Slider_Handle.png") no-repeat center;
background-size: 100% 100%;
image-rendering: pixelated;
min-height: 44px;
}
::-webkit-scrollbar-thumb:hover {
filter: brightness(1.2);
}
.custom-scrollbar::-webkit-scrollbar {
width: 14px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: url("/images/SliderHandlerBackground.png") center;
background-size: 100% 100%;
image-rendering: pixelated;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: url("/images/Slider_Handle.png") no-repeat center;
background-size: 100% 100%;
image-rendering: pixelated;
min-height: 44px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
filter: brightness(1.2);
}
.no-animations * {
transition: none !important;
animation: none !important;
}
.workshop-markdown h1,
.workshop-markdown h2,
.workshop-markdown h3 {
color: #ffff55;
margin-top: 1rem;
margin-bottom: 0.5rem;
text-transform: uppercase;
}
.workshop-markdown strong {
color: #ffff55;
}
.workshop-markdown ul,
.workshop-markdown ol {
margin-left: 1.5rem;
margin-bottom: 1rem;
}
.workshop-markdown li {
margin-bottom: 0.25rem;
}
.workshop-markdown a {
color: #55ffff;
text-decoration: underline;
}
.workshop-markdown code {
background: rgba(0, 0, 0, 0.4);
padding: 2px 4px;
border-radius: 2px;
color: #ff55ff;
}
.workshop-markdown table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
margin-bottom: 1rem;
background: rgba(0, 0, 0, 0.3);
border: 2px solid #555;
image-rendering: pixelated;
}
.workshop-markdown th {
background: rgba(0, 0, 0, 0.5);
color: #ffff55;
padding: 8px;
border: 1px solid #444;
text-align: left;
text-transform: uppercase;
font-size: 0.8rem;
}
.workshop-markdown td {
padding: 8px;
border: 1px solid #333;
color: #e0e0e0;
font-size: 0.9rem;
}
.workshop-markdown tr:nth-child(even) {
background: rgba(255, 255, 255, 0.05);
}
.workshop-markdown blockquote {
border-left: 4px solid #555555;
padding-left: 1rem;
margin: 1rem 0;
color: #a0a0a0;
font-style: italic;
background: rgba(255, 255, 255, 0.02);
}
.workshop-markdown del {
color: #aa0000;
text-decoration: line-through;
}
.workshop-markdown input[type="checkbox"] {
accent-color: #ffff55;
margin-right: 0.5rem;
transform: scale(1.2);
vertical-align: middle;
}
.workshop-markdown hr {
border: none;
border-top: 2px solid #373737;
margin: 1.5rem 0;
}
+6 -1
View File
@@ -18,6 +18,7 @@ export function useAppConfig() {
const [linuxRunner, setLinuxRunner] = useState<string | undefined>();
const [perfBoost, setPerfBoost] = useState(false);
const [customEditions, setCustomEditions] = useState<any[]>([]);
const [mangohudEnabled, setMangohudEnabled] = useState(false);
useEffect(() => {
TauriService.loadConfig().then((config) => {
if (config.username) setUsername(config.username);
@@ -33,6 +34,7 @@ export function useAppConfig() {
if (config.musicVol !== undefined && config.musicVol !== null) setMusicVol(config.musicVol);
if (config.sfxVol !== undefined && config.sfxVol !== null) setSfxVol(config.sfxVol);
if (config.legacyMode !== undefined) setLegacyMode(config.legacyMode);
if (config.mangohudEnabled !== undefined) setMangohudEnabled(config.mangohudEnabled);
setIsLoaded(true);
});
}, []);
@@ -52,9 +54,10 @@ export function useAppConfig() {
musicVol,
sfxVol,
legacyMode,
mangohudEnabled,
}).catch(console.error);
}
}, [username, theme, linuxRunner, perfBoost, profile, customEditions, animationsEnabled, vfxEnabled, rpcEnabled, musicVol, sfxVol, legacyMode, isLoaded]);
}, [username, theme, linuxRunner, perfBoost, profile, customEditions, animationsEnabled, vfxEnabled, rpcEnabled, musicVol, sfxVol, legacyMode, mangohudEnabled, isLoaded]);
return {
username,
@@ -88,5 +91,7 @@ export function useAppConfig() {
isLoaded,
hasCompletedSetup,
setHasCompletedSetup,
mangohudEnabled,
setMangohudEnabled,
};
}
+1
View File
@@ -52,6 +52,7 @@ export function useDiscordRPC({
devtools: "Developing for LCE",
skins: "Changing Skins",
workshop: "Browsing Workshop",
lcelive: "Browsing Friends",
"pck-editor": "Editing a PCK file",
"options-editor": "Editing Options Files",
"arc-editor": "Editing an ARC file",
+162 -95
View File
@@ -24,7 +24,8 @@ const BASE_EDITIONS = [
url: "https://github.com/pieeebot/neoLegacy/releases/download/latest/neoLegacyWindows64.zip",
titleImage: "/images/minecraft_title_neoLegacy.png",
supportsSlimSkins: true,
logo: "/images/neoLegacy.png"
logo: "/images/neoLegacy.png",
panorama: "legacy_evolved",
},
{
id: "revelations",
@@ -33,6 +34,7 @@ const BASE_EDITIONS = [
url: "https://github.com/itsRevela/LCE-Revelations/releases/download/Nightly/LCE-Revelations-Client-Win64.zip",
titleImage: "/images/minecraft_title_revelations.png",
supportsSlimSkins: false,
panorama: "vanilla_tu24",
},
{
id: "360revived",
@@ -41,7 +43,8 @@ const BASE_EDITIONS = [
url: "https://github.com/BluTac10/360Revived/releases/download/nightly/LCEWindows64.zip",
titleImage: "/images/minecraft_title_360revived.png",
supportsSlimSkins: false,
logo: "/images/360_revived.png"
logo: "/images/360_revived.png",
panorama: "360revived",
},
{
id: "legacy_nether_fork",
@@ -50,8 +53,9 @@ const BASE_EDITIONS = [
url: "https://github.com/deadvoxelx/HellishEnds/releases/download/nightly/LCEWindows64.zip",
titleImage: "/images/minecraft_title_hellishends.png",
supportsSlimSkins: false,
logo: "/images/netherrack_0.png"
}
logo: "/images/netherrack_0.png",
panorama: "vanilla_tu19",
},
];
const PARTNERSHIP_SERVERS = [
@@ -101,22 +105,28 @@ export function useGameManager({
number | null
>(null);
const [error, setError] = useState<string | null>(null);
const [gameUpdateMessage, setGameUpdateMessage] = useState<string | null>(null);
const [steamSuccessMessage, setSteamSuccessMessage] = useState<string | null>(null);
const [gameUpdateMessage, setGameUpdateMessage] = useState<string | null>(
null,
);
const [steamSuccessMessage, setSteamSuccessMessage] = useState<string | null>(
null,
);
const [dynamicUrls, setDynamicUrls] = useState<Record<string, string>>({});
const [branches, setBranches] = useState<Record<string, string[]>>({});
const [selectedBranches, setSelectedBranches] = useState<Record<string, string>>({});
const [selectedBranches, setSelectedBranches] = useState<
Record<string, string>
>({});
const branchesFetched = useRef<Set<string>>(new Set());
const initialBranchesSet = useRef(false);
useEffect(() => {
if (initialBranchesSet.current || !profile) return;
BASE_EDITIONS.forEach(e => {
BASE_EDITIONS.forEach((e) => {
if (profile.startsWith(e.id + "_")) {
const branch = profile.replace(e.id + "_", "");
setSelectedBranches(prev => ({ ...prev, [e.id]: branch }));
setSelectedBranches((prev) => ({ ...prev, [e.id]: branch }));
} else if (profile === e.id) {
setSelectedBranches(prev => ({ ...prev, [e.id]: "Stable" }));
setSelectedBranches((prev) => ({ ...prev, [e.id]: "Stable" }));
}
});
initialBranchesSet.current = true;
@@ -125,12 +135,19 @@ export function useGameManager({
useEffect(() => {
async function fetchLatestReleases() {
try {
const response = await fetch("https://api.github.com/repos/pieeebot/neoLegacy/releases/latest");
const response = await fetch(
"https://api.github.com/repos/pieeebot/neoLegacy/releases/latest",
);
if (response.ok) {
const data = await response.json();
const asset = data.assets.find((a: any) => a.name === "neoLegacyWindows64.zip");
const asset = data.assets.find(
(a: any) => a.name === "neoLegacyWindows64.zip",
);
if (asset) {
setDynamicUrls(prev => ({ ...prev, legacy_evolved: asset.browser_download_url }));
setDynamicUrls((prev) => ({
...prev,
legacy_evolved: asset.browser_download_url,
}));
}
}
} catch (e) {
@@ -140,85 +157,105 @@ export function useGameManager({
fetchLatestReleases();
}, []);
const fetchBranchesForEdition = useCallback(async (editionId: string, url: string) => {
if (branchesFetched.current.has(editionId)) return;
if (!url.includes("github.com")) return;
const parts = url.split("github.com/")[1].split("/");
const owner = parts[0];
const repo = parts[1];
try {
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/releases`);
if (response.ok) {
const data = await response.json();
let tags: string[] = data
.map((r: any) => r.tag_name)
.filter((t: string) => !t.toLowerCase().includes("server"));
const fetchBranchesForEdition = useCallback(
async (editionId: string, url: string) => {
if (branchesFetched.current.has(editionId)) return;
if (!url.includes("github.com")) return;
const parts = url.split("github.com/")[1].split("/");
const owner = parts[0];
const repo = parts[1];
try {
const response = await fetch(
`https://api.github.com/repos/${owner}/${repo}/releases`,
);
if (response.ok) {
const data = await response.json();
let tags: string[] = data
.map((r: any) => r.tag_name)
.filter((t: string) => !t.toLowerCase().includes("server"));
const vTags = tags.filter(t => t.startsWith("v")).sort(compareVersions);
const bestVTag = vTags[vTags.length - 1];
if (!tags.includes("Stable")) tags.unshift("Stable");
if (bestVTag) {
setDynamicUrls(prev => ({ ...prev, [`${editionId}_Stable`]: bestVTag }));
const vTags = tags
.filter((t) => t.startsWith("v"))
.sort(compareVersions);
const bestVTag = vTags[vTags.length - 1];
if (!tags.includes("Stable")) tags.unshift("Stable");
if (bestVTag) {
setDynamicUrls((prev) => ({
...prev,
[`${editionId}_Stable`]: bestVTag,
}));
}
setBranches((prev) => ({ ...prev, [editionId]: tags }));
branchesFetched.current.add(editionId);
}
setBranches(prev => ({ ...prev, [editionId]: tags }));
branchesFetched.current.add(editionId);
} catch (e) {
console.error(`Failed to fetch branches for ${editionId}:`, e);
}
} catch (e) {
console.error(`Failed to fetch branches for ${editionId}:`, e);
}
}, []);
},
[],
);
useEffect(() => {
BASE_EDITIONS.forEach(e => fetchBranchesForEdition(e.id, e.url));
BASE_EDITIONS.forEach((e) => fetchBranchesForEdition(e.id, e.url));
}, [fetchBranchesForEdition]);
const cycleBranch = useCallback((editionId: string) => {
const available = branches[editionId] || ["Stable"];
if (available.length <= 1) return;
setSelectedBranches(prev => {
const current = prev[editionId] || available[0];
let currentIndex = available.indexOf(current);
let nextIndex = currentIndex;
let nextBranch = current;
do {
nextIndex = (nextIndex + 1) % available.length;
nextBranch = available[nextIndex];
} while (nextBranch.startsWith("v") && nextIndex !== currentIndex);
const oldInstanceId =
current === "Stable" ? editionId : `${editionId}_${current}`;
const newInstanceId =
nextBranch === "Stable" ? editionId : `${editionId}_${nextBranch}`;
if (profile === oldInstanceId) {
setProfile(newInstanceId);
}
return { ...prev, [editionId]: nextBranch };
});
}, [branches, profile, setProfile]);
const cycleBranch = useCallback(
(editionId: string) => {
const available = branches[editionId] || ["Stable"];
if (available.length <= 1) return;
setSelectedBranches((prev) => {
const current = prev[editionId] || available[0];
let currentIndex = available.indexOf(current);
let nextIndex = currentIndex;
let nextBranch = current;
do {
nextIndex = (nextIndex + 1) % available.length;
nextBranch = available[nextIndex];
} while (nextBranch.startsWith("v") && nextIndex !== currentIndex);
const oldInstanceId =
current === "Stable" ? editionId : `${editionId}_${current}`;
const newInstanceId =
nextBranch === "Stable" ? editionId : `${editionId}_${nextBranch}`;
if (profile === oldInstanceId) {
setProfile(newInstanceId);
}
return { ...prev, [editionId]: nextBranch };
});
},
[branches, profile, setProfile],
);
const editions = useMemo(() => {
return [...BASE_EDITIONS.map(e => {
const availableBranches = branches[e.id] || ["Stable"];
const selectedBranch = selectedBranches[e.id] || availableBranches[0];
let url = dynamicUrls[e.id] || e.url;
const defaultBranchFromUrl = e.url.includes("/releases/download/")
? e.url.split("/releases/download/")[1].split("/")[0]
: "nightly";
const branchToUse = selectedBranch === "Stable" ? (dynamicUrls[`${e.id}_Stable`] || defaultBranchFromUrl) : selectedBranch;
if (e.url.includes("github.com")) {
const baseUrl = e.url.split("/releases/download/")[0];
const filename = e.url.split("/").pop();
url = `${baseUrl}/releases/download/${branchToUse}/${filename}`;
}
return [
...BASE_EDITIONS.map((e) => {
const availableBranches = branches[e.id] || ["Stable"];
const selectedBranch = selectedBranches[e.id] || availableBranches[0];
let url = dynamicUrls[e.id] || e.url;
const defaultBranchFromUrl = e.url.includes("/releases/download/")
? e.url.split("/releases/download/")[1].split("/")[0]
: "nightly";
const branchToUse =
selectedBranch === "Stable"
? dynamicUrls[`${e.id}_Stable`] || defaultBranchFromUrl
: selectedBranch;
if (e.url.includes("github.com")) {
const baseUrl = e.url.split("/releases/download/")[0];
const filename = e.url.split("/").pop();
url = `${baseUrl}/releases/download/${branchToUse}/${filename}`;
}
return {
...e,
url,
branches: availableBranches,
selectedBranch,
instanceId: selectedBranch === "Stable" ? e.id : `${e.id}_${selectedBranch}`
};
}), ...customEditions.map(e => ({ ...e, instanceId: e.id }))];
return {
...e,
url,
branches: availableBranches,
selectedBranch,
instanceId:
selectedBranch === "Stable" ? e.id : `${e.id}_${selectedBranch}`,
};
}),
...customEditions.map((e) => ({ ...e, instanceId: e.id })),
];
}, [customEditions, dynamicUrls, branches, selectedBranches]);
const checkInstalls = useCallback(async () => {
@@ -231,19 +268,25 @@ export function useGameManager({
setInstalls(results.filter((id): id is string => id !== null));
}, [editions]);
const [updatesAvailable, setUpdatesAvailable] = useState<Record<string, boolean>>({});
const [updatesAvailable, setUpdatesAvailable] = useState<
Record<string, boolean>
>({});
const checkForGameUpdates = useCallback(async () => {
const checks = await Promise.all(
editions.map(async (edition) => {
if (!installs.includes(edition.instanceId)) return [edition.instanceId, false] as const;
if (!installs.includes(edition.instanceId))
return [edition.instanceId, false] as const;
try {
const isUpdate = await TauriService.checkGameUpdate(edition.instanceId, edition.url);
const isUpdate = await TauriService.checkGameUpdate(
edition.instanceId,
edition.url,
);
return [edition.instanceId, isUpdate] as const;
} catch (e) {
console.error(e);
return [edition.instanceId, false] as const;
}
})
}),
);
const newUpdates: Record<string, boolean> = {};
@@ -252,12 +295,16 @@ export function useGameManager({
}
setUpdatesAvailable(newUpdates);
const updatedGames = editions.filter(e => newUpdates[e.id]);
const updatedGames = editions.filter((e) => newUpdates[e.id]);
if (updatedGames.length > 0) {
if (updatedGames.length === 1) {
setGameUpdateMessage(`An update is available for ${updatedGames[0].name}!`);
setGameUpdateMessage(
`An update is available for ${updatedGames[0].name}!`,
);
} else {
setGameUpdateMessage(`Updates are available for ${updatedGames.length} versions!`);
setGameUpdateMessage(
`Updates are available for ${updatedGames.length} versions!`,
);
}
} else {
setGameUpdateMessage(null);
@@ -378,7 +425,15 @@ export function useGameManager({
}, [profile]);
const addCustomEdition = useCallback(
(edition: { name: string; desc: string; url: string; path?: string; category?: string[]; logo?: string; id?: string }) => {
(edition: {
name: string;
desc: string;
url: string;
path?: string;
category?: string[];
logo?: string;
id?: string;
}) => {
const id = edition.id || `custom_${Date.now()}`;
const newEdition = {
...edition,
@@ -400,7 +455,10 @@ export function useGameManager({
);
const updateCustomEdition = useCallback(
(id: string, updated: { name: string; desc: string; url: string; path?: string }) => {
(
id: string,
updated: { name: string; desc: string; url: string; path?: string },
) => {
setCustomEditions(
customEditions.map((e) => (e.id === id ? { ...e, ...updated } : e)),
);
@@ -409,18 +467,27 @@ export function useGameManager({
);
const addToSteam = useCallback(
async (id: string, name: string, titleImage: string, panoramaImage: string) => {
async (
id: string,
name: string,
titleImage: string,
panoramaImage: string,
) => {
try {
const titleBase64 = await imageUrlToBase64(titleImage);
const panoramaBase64 = await imageUrlToBase64(panoramaImage);
await TauriService.addToSteam(id, name, titleBase64, panoramaBase64);
setSteamSuccessMessage(`Added ${name} to Steam! (Restart Steam to see it)`);
setSteamSuccessMessage(
`Added ${name} to Steam! (Restart Steam to see it)`,
);
} catch (e: any) {
console.error(e);
setError(typeof e === "string" ? e : e.message || "Failed to add to Steam");
setError(
typeof e === "string" ? e : e.message || "Failed to add to Steam",
);
}
},
[setError, setSteamSuccessMessage]
[setError, setSteamSuccessMessage],
);
return {
+66
View File
@@ -0,0 +1,66 @@
import { useState, useEffect, useRef } from "react";
import { lceLiveService } from "../services/LceLiveService";
export function useLceLiveNotifications() {
const [friendRequestMessage, setFriendRequestMessage] = useState<string | null>(null);
const [gameInviteMessage, setGameInviteMessage] = useState<string | null>(null);
const seenRequests = useRef<Set<string>>(new Set());
const seenInvites = useRef<Set<string>>(new Set());
useEffect(() => {
let pollInterval: any;
const init = async () => {
if (lceLiveService.signedIn) {
try {
await lceLiveService.refreshSession();
} catch (e) { }
}
if (lceLiveService.signedIn) {
try {
const [reqs, invs] = await Promise.all([
lceLiveService.getPendingRequests(),
lceLiveService.getGameInvites()
]);
reqs.incoming.forEach((r: any) => seenRequests.current.add(r.accountId));
invs.filter((i: any) => i.status === "pending").forEach((i: any) => seenInvites.current.add(i.inviteId));
} catch (e) { }
}
pollInterval = setInterval(async () => {
if (!lceLiveService.signedIn) return;
try {
const [reqs, invs] = await Promise.all([
lceLiveService.getPendingRequests(),
lceLiveService.getGameInvites()
]);
reqs.incoming.forEach((r: any) => {
if (!seenRequests.current.has(r.accountId)) {
seenRequests.current.add(r.accountId);
setFriendRequestMessage(`New request from ${r.displayName}`);
}
});
invs.filter((i: any) => i.status === "pending").forEach((i: any) => {
if (!seenInvites.current.has(i.inviteId)) {
seenInvites.current.add(i.inviteId);
const fromName = typeof i.from === 'string' ? "Unknown" : i.from.displayName;
setGameInviteMessage(`${fromName} invited you to play!`);
}
});
} catch (e) { }
}, 10000);
};
init();
return () => {
if (pollInterval) clearInterval(pollInterval);
};
}, []);
return {
friendRequestMessage,
gameInviteMessage,
clearFriendRequestMessage: () => setFriendRequestMessage(null),
clearGameInviteMessage: () => setGameInviteMessage(null),
};
}
+30 -39
View File
@@ -1,49 +1,40 @@
import { useState, useEffect, useCallback } from "react";
declare const __BUILD_DATE__: string;
const LATEST_URL = "https://api.github.com/repos/LCE-Hub/LCE-Emerald-Launcher/releases/latest";
const NIGHTLY_URL = "https://api.github.com/repos/LCE-Hub/LCE-Emerald-Launcher/releases/tags/nightly";
import { check } from "@tauri-apps/plugin-updater";
import { relaunch } from "@tauri-apps/plugin-process";
export function useUpdateCheck() {
const [updateMessage, setUpdateMessage] = useState<string | null>(null);
const [updateUrl, setUpdateUrl] = useState<string | null>(null);
const [updateUrl, _setUpdateUrl] = useState<string | null>(null);
const checkUpdates = useCallback(async () => {
try {
const buildDate = new Date(__BUILD_DATE__);
let isNightly = false;
let latestData = null;
let latestDate = new Date(0);
try {
const latestResponse = await fetch(LATEST_URL);
if (latestResponse.ok) {
latestData = await latestResponse.json();
latestDate = new Date(latestData.published_at);
if (buildDate > latestDate) {
isNightly = true;
const update = await check();
if (update) {
setUpdateMessage(`Downloading update: ${update.version}...`);
let downloaded = 0;
let contentLength = 0;
await update.downloadAndInstall((event) => {
switch (event.event) {
case 'Started':
contentLength = event.data.contentLength || 0;
setUpdateMessage(`Starting download of ${update.version}...`);
break;
case 'Progress':
downloaded += event.data.chunkLength;
if (contentLength > 0) {
const percent = Math.round((downloaded / contentLength) * 100);
setUpdateMessage(`Downloading update: ${percent}%`);
}
break;
case 'Finished':
setUpdateMessage(`Installing update...`);
break;
}
} else {
isNightly = true;
}
} catch (e) {
isNightly = true;
}
});
if (isNightly) {
const nightlyResponse = await fetch(NIGHTLY_URL);
if (!nightlyResponse.ok) return;
const nightlyData = await nightlyResponse.json();
let releaseDate = new Date(nightlyData.published_at || nightlyData.updated_at);
if (nightlyData.assets && nightlyData.assets.length > 0) {
const assetDate = new Date(nightlyData.assets[0].updated_at);
if (assetDate > releaseDate) releaseDate = assetDate;
}
if (releaseDate > buildDate) {
setUpdateMessage(`A new Nightly build is available!`);
setUpdateUrl("https://github.com/LCE-Hub/LCE-Emerald-Launcher/releases/tag/nightly");
}
} else {
if (latestDate > buildDate && latestData) {
setUpdateMessage(`Version ${latestData.tag_name} is now available!`);
setUpdateUrl(latestData.html_url || LATEST_URL);
setUpdateMessage("Update installed. Restarting...");
try {
await relaunch();
} catch (e) {
console.warn("Could not relaunch immediately:", e);
}
}
} catch (e) {
+70 -31
View File
@@ -15,6 +15,7 @@ import ColEditorView from "../components/views/ColEditorView";
import OptionsEditorView from "../components/views/OptionsEditorView";
import ScreenshotsView from "../components/views/ScreenshotsView";
import SwfView from "../components/views/SwfView";
import LceLiveView from "../components/views/LceLiveView";
import SkinViewer from "../components/common/SkinViewer";
import TeamModal from "../components/modals/TeamModal";
import PanoramaBackground from "../components/common/PanoramaBackground";
@@ -29,6 +30,7 @@ import {
useSkin,
} from "../context/LauncherContext";
import { TauriService } from "../services/TauriService";
import { useLceLiveNotifications } from "../hooks/useLceLiveNotifications";
import pkg from "../../package.json";
export default function App() {
const {
@@ -52,6 +54,12 @@ export default function App() {
const audio = useAudio();
const game = useGame();
const { skinUrl, setSkinUrl, capeUrl } = useSkin();
const {
friendRequestMessage,
gameInviteMessage,
clearFriendRequestMessage,
clearGameInviteMessage,
} = useLceLiveNotifications();
const [showSetup, setShowSetup] = useState(true);
const [displayIsDay, setDisplayIsDay] = useState(config.isDayTime);
useEffect(() => {
@@ -64,7 +72,7 @@ export default function App() {
const selectedVersionName = selectedEdition?.name || "";
const hasAnyInstall = game.installs.length > 0;
const titleImage = hasAnyInstall
? (selectedEdition?.titleImage || "/images/MenuTitle.png")
? selectedEdition?.titleImage || "/images/MenuTitle.png"
: "/images/MenuTitle.png";
useEffect(() => {
@@ -124,7 +132,7 @@ export default function App() {
{...backgroundFade}
>
<PanoramaBackground
profile={config.profile}
profile={selectedEdition.panorama}
isDay={displayIsDay}
/>
</motion.div>
@@ -160,7 +168,10 @@ export default function App() {
message={updateMessage}
onClose={clearUpdateMessage}
onClick={() =>
TauriService.openUrl(updateUrl || "https://github.com/LCE-Hub/LCE-Emerald-Launcher/releases/latest")
TauriService.openUrl(
updateUrl ||
"https://github.com/LCE-Hub/LCE-Emerald-Launcher/releases/latest",
)
}
title="Update Available!"
variant="update"
@@ -275,36 +286,39 @@ export default function App() {
</motion.div>
)}
{isUiHidden && !displayIsDay && activeView == "devtools" && (
<motion.div
key="secret-swf-btn"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className="absolute inset-0 z-[100] flex items-center justify-center pointer-events-none"
>
<button
onClick={() => {
audio.playPressSound();
setIsUiHidden(false);
setActiveView("swf-editor");
}}
className="pointer-events-auto hover:scale-110 active:scale-95 transition-transform outline-none bg-transparent border-none flex flex-col items-center gap-2 group"
{isUiHidden &&
!displayIsDay &&
activeView == "devtools" && (
<motion.div
key="secret-swf-btn"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className="absolute inset-0 z-[100] flex items-center justify-center pointer-events-none"
>
<img
src="/images/tools/pck.png"
className="w-16 h-16 cursor-pointer object-contain opacity-50 group-hover:opacity-100 drop-shadow-[0_4px_4px_rgba(0,0,0,1)] grayscale group-hover:grayscale-0"
style={{ imageRendering: "pixelated" }}
onError={(e) => {
(e.target as HTMLImageElement).src = "/images/Button_Background.png";
<button
onClick={() => {
audio.playPressSound();
setIsUiHidden(false);
setActiveView("swf-editor");
}}
/>
<span className="text-[#FFFF55] text-sm mc-text-shadow opacity-0 group-hover:opacity-100 transition-opacity">
SWF Editor
</span>
</button>
</motion.div>
)}
className="pointer-events-auto hover:scale-110 active:scale-95 transition-transform outline-none bg-transparent border-none flex flex-col items-center gap-2 group"
>
<img
src="/images/tools/pck.png"
className="w-16 h-16 cursor-pointer object-contain opacity-50 group-hover:opacity-100 drop-shadow-[0_4px_4px_rgba(0,0,0,1)] grayscale group-hover:grayscale-0"
style={{ imageRendering: "pixelated" }}
onError={(e) => {
(e.target as HTMLImageElement).src =
"/images/Button_Background.png";
}}
/>
<span className="text-[#FFFF55] text-sm mc-text-shadow opacity-0 group-hover:opacity-100 transition-opacity">
SWF Editor
</span>
</button>
</motion.div>
)}
</>
)}
</AnimatePresence>
@@ -414,6 +428,9 @@ export default function App() {
{activeView === "swf-editor" && (
<SwfView key="swf-editor-view" />
)}
{activeView === "lcelive" && (
<LceLiveView key="lcelive-view" />
)}
{activeView === "skins" && <SkinsView key="skins-view" />}
{activeView === "screenshots" && (
<ScreenshotsView key="screenshots-view" />
@@ -447,6 +464,28 @@ export default function App() {
</motion.div>
)}
</AnimatePresence>
<AchievementToast
message={friendRequestMessage}
onClose={clearFriendRequestMessage}
onClick={() => {
clearFriendRequestMessage();
setActiveView("lcelive");
}}
title="Friend Request"
variant="update"
/>
<AchievementToast
message={gameInviteMessage}
onClose={clearGameInviteMessage}
onClick={() => {
clearGameInviteMessage();
setActiveView("lcelive");
}}
title="Game Invite"
variant="update"
/>
</div>
</MotionConfig>
);
+377
View File
@@ -0,0 +1,377 @@
const LOCAL_STORAGE_KEY = "lcelive_session";
const DEFAULT_BASE_URL = "https://api.lcelive.co.uk";
const MAX_REFRESH_RETRIES = 1;
import { TauriService } from "./TauriService";
export interface LceLiveAccount {
accountId: string;
username: string;
displayName: string;
}
export interface SessionData {
accessToken: string;
refreshToken: string;
account: LceLiveAccount;
}
export interface DeviceLinkStartResponse {
deviceCode: string;
userCode: string;
verificationUri: string;
verificationUriComplete?: string;
intervalSeconds: number;
expiresInSeconds?: number;
}
export interface DeviceLinkPollResponse {
status: string;
isLinked?: boolean;
accessToken?: string;
refreshToken?: string;
account?: LceLiveAccount;
}
export interface GameInvite {
inviteId: string;
from: LceLiveAccount | string;
hostIp: string;
hostPort: number;
hostName: string;
signalingSessionId?: string;
status: string;
}
export interface FriendRequest {
accountId: string;
username: string;
displayName: string;
}
export interface PendingRequests {
incoming: FriendRequest[];
outgoing: FriendRequest[];
}
export class LceLiveService {
private _session: SessionData | null = null;
private baseUrl: string = DEFAULT_BASE_URL;
private _refreshPromise: Promise<void> | null = null;
constructor() {
this.loadSession();
}
setBaseUrl(url: string) {
this.baseUrl = url.replace(/\/$/, "");
}
get signedIn(): boolean {
return this._session !== null;
}
get account(): LceLiveAccount | null {
return this._session?.account || null;
}
get displayUsername(): string {
if (!this._session) return "Not signed in";
return (
this._session.account.displayName ||
this._session.account.username ||
"Unknown"
);
}
get apiBaseUrl(): string {
return this.baseUrl;
}
get accessToken(): string | null {
return this._session?.accessToken || null;
}
public generateDeviceId(): string {
let id = localStorage.getItem("lcelive_device_id");
if (!id) {
id = crypto.randomUUID();
localStorage.setItem("lcelive_device_id", id);
}
return id;
}
public getDeviceName(): string {
return "LCE Emerald Launcher";
}
private loadSession() {
try {
const data = localStorage.getItem(LOCAL_STORAGE_KEY);
if (data) {
this._session = JSON.parse(data);
}
} catch (e) {
console.warn("Failed to load LceLive session", e);
}
}
private saveSession() {
if (this._session) {
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(this._session));
} else {
localStorage.removeItem(LOCAL_STORAGE_KEY);
}
}
private async request(
method: string,
path: string,
body?: any,
authed: boolean = true,
retryCount: number = 0,
): Promise<any> {
if (authed && this._session?.refreshToken && retryCount === 0) {
try {
await this.refreshSession(); //neo: i do this on every request only because it doesnt always return 401
} catch (err) {}
}
const headers: Record<string, string> = {
Accept: "application/json",
"User-Agent": "MCLCE-LceLive/1.0",
};
if (body) {
headers["Content-Type"] = "application/json";
}
if (authed && this._session?.accessToken) {
headers["Authorization"] = `Bearer ${this._session.accessToken}`;
}
const bodyStr = body ? JSON.stringify(body) : null;
let res;
try {
res = await TauriService.httpProxyRequest(
method,
`${this.baseUrl}${path}`,
bodyStr,
headers,
);
} catch (e) {
throw new Error(`Network error when calling ${path}: ${e}`);
}
if (
res.status === 401 &&
authed &&
this._session?.refreshToken &&
retryCount < MAX_REFRESH_RETRIES
) {
try {
await this.refreshSession();
return this.request(method, path, body, authed, retryCount + 1);
} catch (err) {
this.logoutLocal();
throw new Error("Session expired. Please log in again.");
}
}
let data;
try {
data = res.body ? JSON.parse(res.body) : {};
} catch {
data = { message: res.body };
}
if (res.status >= 400) {
const errorMsg =
data.message ||
data.detail ||
data.title ||
data.error ||
`HTTP ${res.status}`;
throw new Error(errorMsg);
}
return data;
}
async startDeviceLink(): Promise<DeviceLinkStartResponse> {
return this.request(
"POST",
"/api/auth/device/start",
{
deviceId: this.generateDeviceId(),
deviceName: this.getDeviceName(),
},
false,
);
}
async pollDeviceLink(deviceCode: string): Promise<DeviceLinkPollResponse> {
const data = await this.request(
"GET",
`/api/auth/device/poll/${deviceCode}`,
null,
false,
);
if (data.isLinked && data.accessToken) {
this._session = {
accessToken: data.accessToken,
refreshToken: data.refreshToken,
account: data.account,
};
this.saveSession();
}
return data;
}
async refreshSession(): Promise<void> {
if (!this._session?.refreshToken) throw new Error("No refresh token");
if (this._refreshPromise) {
return this._refreshPromise;
}
this._refreshPromise = (async () => {
try {
const data = await this.request(
"POST",
"/api/auth/refresh",
{
refreshToken: this._session!.refreshToken,
},
false,
);
this._session = {
accessToken: data.accessToken,
refreshToken: data.refreshToken,
account: data.account,
};
this.saveSession();
} finally {
this._refreshPromise = null;
}
})();
return this._refreshPromise;
}
async logout(): Promise<void> {
if (this._session?.refreshToken) {
try {
await this.request(
"POST",
"/api/auth/logout",
{
refreshToken: this._session.refreshToken,
},
true,
);
} catch (e) {
console.warn("Server logout failed", e);
}
}
this.logoutLocal();
}
logoutLocal(): void {
this._session = null;
this.saveSession();
}
async getFriends(): Promise<LceLiveAccount[]> {
const data = await this.request("GET", "/api/social/friends");
return data.friends || [];
}
async removeFriend(accountId: string): Promise<void> {
await this.request("DELETE", `/api/social/friends/${accountId}`);
}
async sendFriendRequest(username: string): Promise<void> {
await this.request("POST", "/api/social/request", { username });
}
async getPendingRequests(): Promise<PendingRequests> {
const data = await this.request("GET", "/api/social/requests");
return {
incoming: (data.incoming || []).map((r: any) => ({
accountId: r.requesterUserId || r.accountId || r.userId,
username: r.requesterUsername || r.username,
displayName: r.requesterDisplayName || r.displayName,
})),
outgoing: (data.outgoing || []).map((r: any) => ({
accountId: r.targetUserId || r.accountId || r.userId,
username: r.targetUsername || r.username,
displayName: r.targetDisplayName || r.displayName,
})),
};
}
async acceptFriendRequest(accountId: string): Promise<void> {
await this.request("POST", `/api/social/requests/${accountId}/accept`, {});
}
async declineFriendRequest(accountId: string): Promise<void> {
await this.request("POST", `/api/social/requests/${accountId}/decline`, {});
}
async getGameInvites(): Promise<GameInvite[]> {
const data = await this.request("GET", "/api/sessions/invites");
const incoming = data.incoming || [];
return incoming.map((inv: any) => ({
inviteId: inv.inviteId,
from: {
accountId: inv.senderAccountId,
username: inv.senderUsername,
displayName: inv.senderDisplayName,
},
hostIp: inv.hostIp,
hostPort: inv.hostPort,
hostName: inv.hostName,
status: inv.status,
signalingSessionId: inv.signalingSessionId,
}));
}
async sendGameInvite(
recipientAccountId: string,
hostIp: string,
hostPort: number,
hostName: string,
signalingSessionId?: string,
): Promise<void> {
await this.request("POST", "/api/sessions/invites", {
recipientAccountId,
hostIp,
hostPort,
hostName,
signalingSessionId,
});
}
async acceptGameInvite(inviteId: string): Promise<any> {
return this.request("POST", `/api/sessions/invites/${inviteId}/accept`, {});
}
async declineGameInvite(inviteId: string): Promise<void> {
await this.request("POST", `/api/sessions/invites/${inviteId}/decline`, {});
}
async deactivateGameInvites(): Promise<void> {
await this.request("POST", "/api/sessions/invites/deactivate", {});
}
async requestJoinTicket(): Promise<string> {
const data = await this.request("POST", "/api/sessions/ticket", {});
return data.ticket;
}
async validateJoinTicket(ticket: string): Promise<LceLiveAccount> {
return this.request("POST", "/api/sessions/validate", { ticket }, false);
}
}
export const lceLiveService = new LceLiveService();
+16 -6
View File
@@ -1,12 +1,18 @@
import { setActivity, start } from "tauri-plugin-drpc";
import { Activity, ActivityType, Assets, Timestamps, Button } from "tauri-plugin-drpc/activity";
import {
Activity,
ActivityType,
Assets,
Timestamps,
Button,
} from "tauri-plugin-drpc/activity";
class RPC {
private startTime: number = Date.now();
private initializationPromise: Promise<void> | null = null;
private initialized: boolean = false;
public async StartRPC() {
if (this.initialized) return;
if (sessionStorage.getItem('lce_rpc_started') === 'true') {
if (sessionStorage.getItem("lce_rpc_started") === "true") {
this.initialized = true;
return;
}
@@ -15,7 +21,7 @@ class RPC {
this.initializationPromise = (async () => {
try {
await start("1482504445152460871");
sessionStorage.setItem('lce_rpc_started', 'true');
sessionStorage.setItem("lce_rpc_started", "true");
this.initialized = true;
} catch (e) {
console.error("Failed to start RPC:", e);
@@ -26,7 +32,11 @@ class RPC {
return this.initializationPromise;
}
public async updateActivity(details: string, state: string, isPlaying: boolean = false) {
public async updateActivity(
details: string,
state: string,
isPlaying: boolean = false,
) {
if (!this.initialized) {
await this.StartRPC();
if (!this.initialized) return;
@@ -44,8 +54,8 @@ class RPC {
activity.setAssets(assets);
activity.setTimestamps(new Timestamps(this.startTime));
activity.setButton([
new Button("Discord", "https://discord.gg/NPMp9JhGYH"),
new Button("GitHub", "https://github.com/LCE-Hub/LCE-Emerald-Launcher")
new Button("Discord", "https://discord.gg/cQVKhQXcCx"),
new Button("GitHub", "https://github.com/LCE-Hub/LCE-Emerald-Launcher"),
]);
try {
+49
View File
@@ -39,6 +39,7 @@ export interface AppConfig {
musicVol?: number;
sfxVol?: number;
legacyMode?: boolean;
mangohudEnabled?: boolean;
}
export interface ThemePalette {
@@ -235,4 +236,52 @@ export class TauriService {
): Promise<void> {
return invoke("add_to_steam", { instanceId, name, titleBase64, panoramaBase64 });
}
static async stunDiscover(): Promise<{ ip: string; port: number }> {
return invoke("stun_discover");
}
static async startDirectProxy(targetIp: string, targetPort: number): Promise<number> {
return invoke("start_direct_proxy", { targetIp, targetPort });
}
static async startRelayProxy(apiBaseUrl: string, accessToken: string, sessionId: string): Promise<number> {
return invoke("start_relay_proxy", { apiBaseUrl, accessToken, sessionId });
}
static async startHostRelay(apiBaseUrl: string, accessToken: string, sessionId: string, gamePort: number): Promise<void> {
return invoke("start_host_relay", { apiBaseUrl, accessToken, sessionId, gamePort });
}
static async stopProxy(): Promise<void> {
return invoke("stop_proxy");
}
static async joinGame(
apiBaseUrl: string,
accessToken: string,
hostIp: string,
hostPort: number,
sessionId: string,
instanceId: string,
): Promise<void> {
return invoke("join_game", { apiBaseUrl, accessToken, hostIp, hostPort, sessionId, instanceId });
}
static async httpProxyRequest(
method: string,
url: string,
body: string | null,
headers: Record<string, string>
): Promise<{ status: number; body: string }> {
return invoke("http_proxy_request", { method, url, body, headers });
}
static async getInstancePath(instanceId: string): Promise<string> {
return invoke("get_instance_path", { instanceId });
}
static async readScreenshotAsDataUrl(path: string): Promise<string> {
return invoke("read_screenshot_as_data_url", { path });
}
}