Compare commits

..

2 Commits

Author SHA1 Message Date
Jai Agrawal
d841484b2c Merge branch 'main' into chore/posthog-ingestion 2025-04-28 13:27:23 -07:00
Michael H.
c8c60667f6 Update PostHog ingestion key 2025-04-25 23:15:09 +02:00
1045 changed files with 19900 additions and 38283 deletions

View File

@@ -1,6 +1,6 @@
# Windows has stack overflows when calling from Tauri, so we increase the default stack size used by the compiler # Windows has stack overflows when calling from Tauri, so we increase compiler size
[target.'cfg(windows)'] [target.'cfg(windows)']
rustflags = ["-C", "link-args=/STACK:16777220", "--cfg", "tokio_unstable"] rustflags = ["-C", "link-args=/STACK:16777220"]
[build] [build]
rustflags = ["--cfg", "tokio_unstable"] rustflags = ["--cfg", "tokio_unstable"]

View File

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

View File

@@ -14,5 +14,5 @@ max_line_length = 100
max_line_length = off max_line_length = off
trim_trailing_whitespace = false trim_trailing_whitespace = false
[*.{rs,java,kts}] [*.rs]
indent_size = 4 indent_size = 4

34
.gitattributes vendored
View File

@@ -1,35 +1 @@
* text=auto eol=lf * text=auto eol=lf
# SQLx calculates a checksum of migration scripts at build time to compare
# it with the checksum of the applied migration for the same version at
# runtime, to know if the migration script has been changed, and thus the
# DB schema went out of sync with the code.
#
# However, such checksum treats the script as a raw byte stream, taking
# into account inconsequential differences like different line endings
# in different OSes. When combined with Git's EOL conversion and mixed
# native and cross-compilation scenarios, this leads to existing
# migrations that didn't change having potentially different checksums
# according to the environment they were built in, which can break the
# migration system when deploying the Modrinth App, rendering it
# unusable.
#
# The gitattribute above ensures that all text files are checked out
# with LF line endings, but widely deployed app versions were built
# without this attribute set, which left such line endings variable to
# the platform. Thus, there is no perfect solution to this problem:
# forcing CRLF here would break Linux and macOS users, forcing LF
# breaks Windows users, and leaving it unspecified may still lead to
# line ending differences when cross-compiling from Linux to Windows
# or vice versa, or having Git configured with different line
# conversion settings. Moreover, there is no `eol=native` attribute,
# and using CI-only scripts to convert line endings would make the
# builds differ between CI and most local environments. So, let's pick
# the least bad option: let Git handle line endings using its
# configuration by leaving it unspecified, which works fine as long as
# people don't mess with Git's line ending settings, which is the vast
# majority of cases.
/packages/app-lib/migrations/20240711194701_init.sql !eol
/packages/app-lib/migrations/20240813205023_drop-active-unique.sql !eol
/packages/app-lib/migrations/20240930001852_disable-personalized-ads.sql !eol
/packages/app-lib/migrations/20241222013857_feature-flags.sql !eol

View File

@@ -6,7 +6,7 @@ body:
attributes: attributes:
label: Please confirm the following. label: Please confirm the following.
options: options:
- label: I checked the [existing issues](https://github.com/modrinth/code/issues?q=is%3Aissue) for duplicate problems - label: I checked the [existing issues](https://github.com/modrinth/code/issues) for duplicate problems
required: true required: true
- label: I have tried resolving the issue using the [support portal](https://support.modrinth.com) - label: I have tried resolving the issue using the [support portal](https://support.modrinth.com)
required: true required: true

View File

@@ -6,7 +6,7 @@ body:
attributes: attributes:
label: Please confirm the following. label: Please confirm the following.
options: options:
- label: I checked the [existing issues](https://github.com/modrinth/code/issues?q=is%3Aissue) for duplicate problems - label: I checked the [existing issues](https://github.com/modrinth/code/issues) for duplicate problems
required: true required: true
- label: I have tried resolving the issue using the [support portal](https://support.modrinth.com) - label: I have tried resolving the issue using the [support portal](https://support.modrinth.com)
required: true required: true

View File

@@ -6,7 +6,7 @@ body:
attributes: attributes:
label: Please confirm the following. label: Please confirm the following.
options: options:
- label: I checked the [existing issues](https://github.com/modrinth/code/issues?q=is%3Aissue) for duplicate problems - label: I checked the [existing issues](https://github.com/modrinth/code/issues) for duplicate problems
required: true required: true
- label: I have tried resolving the issue using the [support portal](https://support.modrinth.com) - label: I have tried resolving the issue using the [support portal](https://support.modrinth.com)
required: true required: true

View File

@@ -7,7 +7,7 @@ body:
attributes: attributes:
label: Please confirm the following. label: Please confirm the following.
options: options:
- label: I checked the [existing issues](https://github.com/modrinth/code/issues?q=is%3Aissue) for duplicate feature requests - label: I checked the [existing issues](https://github.com/modrinth/code/issues) for duplicate feature requests
required: true required: true
- label: I have checked that this feature request is not on our [roadmap](https://roadmap.modrinth.com) - label: I have checked that this feature request is not on our [roadmap](https://roadmap.modrinth.com)
required: true required: true

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

After

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -18,6 +18,9 @@ on:
jobs: jobs:
docker: docker:
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults:
run:
working-directory: ./apps/labrinth
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v2
@@ -35,6 +38,8 @@ jobs:
- name: Build and push - name: Build and push
id: docker_build id: docker_build
uses: docker/build-push-action@v2 uses: docker/build-push-action@v2
env:
SQLX_OFFLINE: true
with: with:
file: ./apps/labrinth/Dockerfile file: ./apps/labrinth/Dockerfile
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ name: CI
on: on:
push: push:
branches: [main] branches: ['main']
pull_request: pull_request:
types: [opened, synchronize] types: [opened, synchronize]
merge_group: merge_group:
@@ -10,69 +10,71 @@ on:
jobs: jobs:
build: build:
name: Lint and Test name: Build, Test, and Lint
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
env:
# Ensure pnpm output is colored in GitHub Actions logs
FORCE_COLOR: 3
# Make cargo nextest successfully ignore projects without tests
NEXTEST_NO_TESTS: pass
steps: steps:
- name: 📥 Check out code - name: Check out code
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 2 fetch-depth: 2
- name: 🧰 Install build dependencies - name: Cache turbo build setup
uses: actions/cache@v4
with:
path: .turbo
key: ${{ runner.os }}-turbo-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-
- name: Install build dependencies
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -yq libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- name: 🧰 Install pnpm - name: Setup Node.JS environment
uses: pnpm/action-setup@v4
- name: 🧰 Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version-file: .nvmrc node-version: 20
cache: pnpm
- name: 🧰 Setup Rust toolchain - name: Install pnpm via corepack
uses: actions-rust-lang/setup-rust-toolchain@v1 shell: bash
run: |
corepack enable
corepack prepare --activate
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- uses: actions/cache@v4
name: Setup pnpm cache
with: with:
rustflags: '' path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
components: clippy, rustfmt key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
cache: false restore-keys: |
${{ runner.os }}-pnpm-store-
- name: 🧰 Setup nextest - name: Install dependencies
uses: taiki-e/install-action@nextest
# cargo-binstall does not have pre-built binaries for sqlx-cli, so we fall
# back to a cached cargo install
- name: 🧰 Setup cargo-sqlx
uses: AlexTMjugador/cache-cargo-install-action@feat/features-support
with:
tool: sqlx-cli
locked: false
no-default-features: true
features: rustls,postgres
- name: 💨 Setup Turbo cache
uses: rharkor/caching-for-turbo@v1.8
- name: 🧰 Install dependencies
run: pnpm install run: pnpm install
- name: ⚙️ Start services - name: Build
run: docker compose up --wait run: pnpm build
env:
SQLX_OFFLINE: true
- name: ⚙️ Setup Labrinth environment and database - name: Lint
working-directory: apps/labrinth run: pnpm lint
run: | env:
cp .env.local .env SQLX_OFFLINE: true
sqlx database setup
- name: 🔍 Lint and test - name: Start docker compose
run: pnpm run ci run: docker compose up -d
- name: Test
run: pnpm test
env:
SQLX_OFFLINE: true
DATABASE_URL: postgresql://labrinth:labrinth@localhost/postgres

2
.idea/code.iml generated
View File

@@ -17,4 +17,4 @@
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />
</component> </component>
</module> </module>

2
.idea/modules.xml generated
View File

@@ -5,4 +5,4 @@
<module fileurl="file://$PROJECT_DIR$/.idea/code.iml" filepath="$PROJECT_DIR$/.idea/code.iml" /> <module fileurl="file://$PROJECT_DIR$/.idea/code.iml" filepath="$PROJECT_DIR$/.idea/code.iml" />
</modules> </modules>
</component> </component>
</project> </project>

6
.idea/vcs.xml generated
View File

@@ -1,11 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="CommitMessageInspectionProfile">
<profile version="1.0">
<inspection_tool class="CommitFormat" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="CommitNamingConvention" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>
<component name="VcsDirectoryMappings"> <component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" /> <mapping directory="" vcs="Git" />
</component> </component>

1
.nvmrc
View File

@@ -1 +0,0 @@
20.19.2

View File

@@ -2,7 +2,6 @@
"prettier.endOfLine": "lf", "prettier.endOfLine": "lf",
"editor.formatOnSave": true, "editor.formatOnSave": true,
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"], "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"],
"editor.detectIndentation": true,
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit" "source.fixAll.eslint": "explicit"
} }

5432
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,233 +1,25 @@
[workspace] [workspace]
resolver = "2" resolver = '2'
members = [ members = [
"apps/app", './packages/app-lib',
"apps/app-playground", './apps/app-playground',
"apps/daedalus_client", './apps/app',
"apps/labrinth", './apps/labrinth',
"packages/app-lib", './apps/daedalus_client',
"packages/ariadne", './packages/daedalus',
"packages/daedalus", './packages/ariadne',
] ]
[workspace.package]
edition = "2024"
[workspace.dependencies]
actix-cors = "0.7.1"
actix-files = "0.6.6"
actix-http = "3.11.0"
actix-multipart = "0.7.2"
actix-rt = "2.10.0"
actix-web = "4.11.0"
actix-web-prom = "0.10.0"
actix-ws = "0.3.0"
argon2 = { version = "0.5.3", features = ["std"] }
ariadne = { path = "packages/ariadne" }
async_zip = "0.0.17"
async-compression = { version = "0.4.25", default-features = false }
async-recursion = "1.1.1"
async-stripe = { version = "0.41.0", default-features = false, features = [
"runtime-tokio-hyper-rustls",
] }
async-trait = "0.1.88"
async-tungstenite = { version = "0.29.1", default-features = false, features = [
"futures-03-sink",
] }
async-walkdir = "2.1.0"
base64 = "0.22.1"
bitflags = "2.9.1"
bytemuck = "1.23.0"
bytes = "1.10.1"
censor = "0.3.0"
chardetng = "0.1.17"
chrono = "0.4.41"
clap = "4.5.40"
clickhouse = "0.13.3"
color-thief = "0.2.2"
console-subscriber = "0.4.1"
daedalus = { path = "packages/daedalus" }
dashmap = "6.1.0"
data-url = "0.3.1"
deadpool-redis = "0.21.1"
dirs = "6.0.0"
discord-rich-presence = "0.2.5"
dotenv-build = "0.1.1"
dotenvy = "0.15.7"
dunce = "1.0.5"
either = "1.15.0"
encoding_rs = "0.8.35"
enumset = "1.1.6"
flate2 = "1.1.2"
fs4 = { version = "0.13.1", default-features = false }
futures = { version = "0.3.31", default-features = false }
futures-util = "0.3.31"
hashlink = "0.10.0"
heck = "0.5.0"
hex = "0.4.3"
hickory-resolver = "0.25.2"
hmac = "0.12.1"
hyper-tls = "0.6.0"
hyper-util = "0.1.14"
iana-time-zone = "0.1.63"
image = { version = "0.25.6", default-features = false, features = ["rayon"] }
indexmap = "2.9.0"
indicatif = "0.17.11"
itertools = "0.14.0"
jemalloc_pprof = "0.7.0"
json-patch = { version = "4.0.0", default-features = false }
lettre = { version = "0.11.17", default-features = false, features = [
"builder",
"hostname",
"pool",
"ring",
"rustls",
"rustls-native-certs",
"smtp-transport",
] }
maxminddb = "0.26.0"
meilisearch-sdk = { version = "0.28.0", default-features = false }
murmur2 = "0.1.0"
native-dialog = "0.9.0"
notify = { version = "8.0.0", default-features = false }
notify-debouncer-mini = { version = "0.6.0", default-features = false }
p256 = "0.13.2"
paste = "1.0.15"
png = "0.17.16"
prometheus = "0.14.0"
quartz_nbt = "0.2.9"
quick-xml = "0.37.5"
rand = "=0.8.5" # Locked on 0.8 until argon2 and p256 update to 0.9
rand_chacha = "=0.3.1" # Locked on 0.3 until we can update rand to 0.9
redis = "=0.31.0" # Locked on 0.31 until deadpool-redis updates to 0.32
regex = "1.11.1"
reqwest = { version = "0.12.20", default-features = false }
rgb = "0.8.50"
rust_decimal = { version = "1.37.2", features = [
"serde-with-float",
"serde-with-str",
] }
rust_iso3166 = "0.1.14"
rust-s3 = { version = "0.35.1", default-features = false, features = [
"fail-on-err",
"tags",
"tokio-rustls-tls",
] }
rusty-money = "0.4.1"
sentry = { version = "0.41.0", default-features = false, features = [
"backtrace",
"contexts",
"debug-images",
"panic",
"reqwest",
"rustls",
] }
sentry-actix = "0.41.0"
serde = "1.0.219"
serde_bytes = "0.11.17"
serde_cbor = "0.11.2"
serde_ini = "0.2.0"
serde_json = "1.0.140"
serde_with = "3.13.0"
serde-xml-rs = "0.8.1" # Also an XML (de)serializer, consider dropping yaserde in favor of this
sha1 = "0.10.6"
sha1_smol = { version = "1.0.1", features = ["std"] }
sha2 = "0.10.9"
spdx = "0.10.8"
sqlx = { version = "0.8.6", default-features = false }
sysinfo = { version = "0.35.2", default-features = false }
tar = "0.4.44"
tauri = "2.6.1"
tauri-build = "2.3.0"
tauri-plugin-deep-link = "2.4.0"
tauri-plugin-dialog = "2.3.0"
tauri-plugin-http = "2.5.0"
tauri-plugin-opener = "2.4.0"
tauri-plugin-os = "2.3.0"
tauri-plugin-single-instance = "2.3.0"
tauri-plugin-updater = { version = "2.9.0", default-features = false, features = [
"rustls-tls",
"zip",
] }
tauri-plugin-window-state = "2.3.0"
tempfile = "3.20.0"
theseus = { path = "packages/app-lib" }
thiserror = "2.0.12"
tikv-jemalloc-ctl = "0.6.0"
tikv-jemallocator = "0.6.0"
tokio = "1.45.1"
tokio-stream = "0.1.17"
tokio-util = "0.7.15"
totp-rs = "5.7.0"
tracing = "0.1.41"
tracing-actix-web = "0.7.18"
tracing-error = "0.2.1"
tracing-subscriber = "0.3.19"
url = "2.5.4"
urlencoding = "2.1.3"
uuid = "1.17.0"
validator = "0.20.0"
webp = { version = "0.3.0", default-features = false }
whoami = "1.6.0"
winreg = "0.55.0"
woothee = "0.13.0"
yaserde = "0.12.0"
zip = { version = "4.2.0", default-features = false, features = [
"bzip2",
"deflate",
"deflate64",
"zstd",
] }
zxcvbn = "3.1.0"
[workspace.lints.clippy]
bool_to_int_with_if = "warn"
borrow_as_ptr = "warn"
cfg_not_test = "warn"
clear_with_drain = "warn"
cloned_instead_of_copied = "warn"
collection_is_never_read = "warn"
dbg_macro = "warn"
default_trait_access = "warn"
explicit_iter_loop = "warn"
filter_map_next = "warn"
flat_map_option = "warn"
format_push_string = "warn"
get_unwrap = "warn"
large_include_file = "warn"
large_stack_arrays = "warn"
manual_assert = "warn"
manual_instant_elapsed = "warn"
manual_is_variant_and = "warn"
manual_let_else = "warn"
map_unwrap_or = "warn"
match_bool = "warn"
needless_collect = "warn"
negative_feature_names = "warn"
non_std_lazy_statics = "warn"
pathbuf_init_then_push = "warn"
read_zero_byte_vec = "warn"
redundant_clone = "warn"
redundant_feature_names = "warn"
redundant_type_annotations = "warn"
todo = "warn"
unnested_or_patterns = "warn"
wildcard_dependencies = "warn"
[workspace.lints.rust]
# Turn warnings into errors by default
warnings = "deny"
[patch.crates-io]
wry = { git = "https://github.com/modrinth/wry", rev = "21db186" }
# Optimize for speed and reduce size on release builds # Optimize for speed and reduce size on release builds
[profile.release] [profile.release]
opt-level = "s" # Optimize for binary size panic = "abort" # Strip expensive panic clean-up logic
strip = true # Remove debug symbols codegen-units = 1 # Compile crates one after another so the compiler can optimize better
lto = true # Enables link to optimizations lto = true # Enables link to optimizations
panic = "abort" # Strip expensive panic clean-up logic opt-level = "s" # Optimize for binary size
codegen-units = 1 # Compile crates one after another so the compiler can optimize better strip = true # Remove debug symbols
[profile.dev.package.sqlx-macros] [profile.dev.package.sqlx-macros]
opt-level = 3 opt-level = 3
[patch.crates-io]
wry = { git = "https://github.com/modrinth/wry", rev = "51907c6" }

View File

@@ -1,2 +1 @@
**/dist **/dist
*.gltf

View File

@@ -1,7 +1,7 @@
{ {
"name": "@modrinth/app-frontend", "name": "@modrinth/app-frontend",
"private": true, "private": true,
"version": "1.0.0-local", "version": "0.9.4",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -9,31 +9,26 @@
"tsc:check": "vue-tsc --noEmit", "tsc:check": "vue-tsc --noEmit",
"lint": "eslint . && prettier --check .", "lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write .", "fix": "eslint . --fix && prettier --write .",
"intl:extract": "formatjs extract \"{,src/components,src/composables,src/helpers,src/pages,src/store}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore '**/*.d.ts' --ignore 'node_modules' --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace", "intl:extract": "formatjs extract \"{,src/components,src/composables,src/helpers,src/pages,src/store}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore '**/*.d.ts' --ignore 'node_modules' --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace"
"test": "vue-tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@geometrically/minecraft-motd-parser": "^1.1.4",
"@modrinth/assets": "workspace:*", "@modrinth/assets": "workspace:*",
"@modrinth/ui": "workspace:*", "@modrinth/ui": "workspace:*",
"@modrinth/utils": "workspace:*", "@modrinth/utils": "workspace:*",
"@sentry/vue": "^8.27.0", "@sentry/vue": "^8.27.0",
"@tauri-apps/api": "^2.5.0", "@geometrically/minecraft-motd-parser": "^1.1.4",
"@tauri-apps/plugin-dialog": "^2.2.1", "@tauri-apps/api": "^2.1.1",
"@tauri-apps/plugin-http": "^2.5.0", "@tauri-apps/plugin-dialog": "^2.2.0",
"@tauri-apps/plugin-opener": "^2.2.6", "@tauri-apps/plugin-os": "^2.2.0",
"@tauri-apps/plugin-os": "^2.2.1", "@tauri-apps/plugin-opener": "^2.2.1",
"@tauri-apps/plugin-updater": "^2.7.1", "@tauri-apps/plugin-updater": "^2.3.0",
"@tauri-apps/plugin-window-state": "^2.2.2", "@tauri-apps/plugin-window-state": "^2.2.0",
"@types/three": "^0.172.0",
"@vintl/vintl": "^4.4.1", "@vintl/vintl": "^4.4.1",
"@vueuse/core": "^11.1.0",
"dayjs": "^1.11.10", "dayjs": "^1.11.10",
"floating-vue": "^5.2.2", "floating-vue": "^5.2.2",
"ofetch": "^1.3.4", "ofetch": "^1.3.4",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"posthog-js": "^1.158.2", "posthog-js": "^1.158.2",
"three": "^0.172.0",
"vite-svg-loader": "^5.1.0", "vite-svg-loader": "^5.1.0",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-multiselect": "3.0.0", "vue-multiselect": "3.0.0",
@@ -44,12 +39,11 @@
"@eslint/compat": "^1.1.1", "@eslint/compat": "^1.1.1",
"@formatjs/cli": "^6.2.12", "@formatjs/cli": "^6.2.12",
"@nuxt/eslint-config": "^0.5.6", "@nuxt/eslint-config": "^0.5.6",
"@taijased/vue-render-tracker": "^1.0.7",
"@vitejs/plugin-vue": "^5.0.4", "@vitejs/plugin-vue": "^5.0.4",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"eslint": "^9.9.1", "eslint": "^9.9.1",
"eslint-config-custom": "workspace:*", "eslint-config-custom": "workspace:*",
"eslint-plugin-turbo": "^2.5.4", "eslint-plugin-turbo": "^2.1.1",
"postcss": "^8.4.39", "postcss": "^8.4.39",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"sass": "^1.74.1", "sass": "^1.74.1",
@@ -57,7 +51,8 @@
"tsconfig": "workspace:*", "tsconfig": "workspace:*",
"typescript": "^5.5.4", "typescript": "^5.5.4",
"vite": "^5.4.6", "vite": "^5.4.6",
"vue-tsc": "^2.1.6" "vue-tsc": "^2.1.6",
"@taijased/vue-render-tracker": "^1.0.7"
}, },
"packageManager": "pnpm@9.4.0", "packageManager": "pnpm@9.4.0",
"web-types": "../../web-types.json" "web-types": "../../web-types.json"

View File

@@ -1,9 +1,8 @@
<script setup> <script setup>
import { computed, onMounted, onUnmounted, ref, watch, provide } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { RouterView, useRoute, useRouter } from 'vue-router' import { RouterView, useRoute, useRouter } from 'vue-router'
import { import {
ArrowBigUpDashIcon, ArrowBigUpDashIcon,
ChangeSkinIcon,
CompassIcon, CompassIcon,
DownloadIcon, DownloadIcon,
HomeIcon, HomeIcon,
@@ -19,21 +18,13 @@ import {
SettingsIcon, SettingsIcon,
WorldIcon, WorldIcon,
XIcon, XIcon,
NewspaperIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { import { Avatar, Button, ButtonStyled, Notifications, OverflowMenu } from '@modrinth/ui'
Avatar,
Button,
ButtonStyled,
Notifications,
OverflowMenu,
NewsArticleCard,
} from '@modrinth/ui'
import { useLoading, useTheming } from '@/store/state' import { useLoading, useTheming } from '@/store/state'
import ModrinthAppLogo from '@/assets/modrinth_app.svg?component' import ModrinthAppLogo from '@/assets/modrinth_app.svg?component'
import AccountsCard from '@/components/ui/AccountsCard.vue' import AccountsCard from '@/components/ui/AccountsCard.vue'
import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue' import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue'
import { get } from '@/helpers/settings.ts' import { get } from '@/helpers/settings'
import Breadcrumbs from '@/components/ui/Breadcrumbs.vue' import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
import RunningAppBar from '@/components/ui/RunningAppBar.vue' import RunningAppBar from '@/components/ui/RunningAppBar.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue' import SplashScreen from '@/components/ui/SplashScreen.vue'
@@ -64,13 +55,12 @@ import NavButton from '@/components/ui/NavButton.vue'
import { get as getCreds, login, logout } from '@/helpers/mr_auth.js' import { get as getCreds, login, logout } from '@/helpers/mr_auth.js'
import { get_user } from '@/helpers/cache.js' import { get_user } from '@/helpers/cache.js'
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue' import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
import dayjs from 'dayjs'
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue' import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
import { hide_ads_window, init_ads_window } from '@/helpers/ads.js' import { hide_ads_window, init_ads_window } from '@/helpers/ads.js'
import FriendsList from '@/components/ui/friends/FriendsList.vue' import FriendsList from '@/components/ui/friends/FriendsList.vue'
import { openUrl } from '@tauri-apps/plugin-opener' import { openUrl } from '@tauri-apps/plugin-opener'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue' import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
import { get_available_capes, get_available_skins } from './helpers/skins'
import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer'
const themeStore = useTheming() const themeStore = useTheming()
@@ -178,7 +168,6 @@ async function setupApp() {
'criticalAnnouncements', 'criticalAnnouncements',
true, true,
) )
.then((response) => response.json())
.then((res) => { .then((res) => {
if (res && res.header && res.body) { if (res && res.header && res.body) {
criticalErrorMessage.value = res criticalErrorMessage.value = res
@@ -190,35 +179,15 @@ async function setupApp() {
) )
}) })
useFetch(`https://modrinth.com/news/feed/articles.json`, 'news', true) useFetch(`https://modrinth.com/blog/news.json`, 'news', true).then((res) => {
.then((response) => response.json()) if (res && res.articles) {
.then((res) => { news.value = res.articles
if (res && res.articles) { }
// Format expected by NewsArticleCard component. })
news.value = res.articles
.map((article) => ({
...article,
path: article.link,
thumbnail: article.thumbnail,
title: article.title,
summary: article.summary,
date: article.date,
}))
.slice(0, 4)
}
})
get_opening_command().then(handleCommand) get_opening_command().then(handleCommand)
checkUpdates() checkUpdates()
fetchCredentials() fetchCredentials()
try {
const skins = (await get_available_skins()) ?? []
const capes = (await get_available_capes()) ?? []
generateSkinPreviews(skins, capes)
} catch (error) {
console.warn('Failed to generate skin previews in app setup.', error)
}
} }
const stateFailed = ref(false) const stateFailed = ref(false)
@@ -326,7 +295,6 @@ onMounted(() => {
}) })
const accounts = ref(null) const accounts = ref(null)
provide('accountsCard', accounts)
command_listener(handleCommand) command_listener(handleCommand)
async function handleCommand(e) { async function handleCommand(e) {
@@ -422,9 +390,6 @@ function handleAuxClick(e) {
> >
<CompassIcon /> <CompassIcon />
</NavButton> </NavButton>
<NavButton v-tooltip.right="'Skins (Beta)'" to="/skins">
<ChangeSkinIcon />
</NavButton>
<NavButton <NavButton
v-tooltip.right="'Library'" v-tooltip.right="'Library'"
to="/library" to="/library"
@@ -605,20 +570,34 @@ function handleAuxClick(e) {
<FriendsList :credentials="credentials" :sign-in="() => signIn()" /> <FriendsList :credentials="credentials" :sign-in="() => signIn()" />
</suspense> </suspense>
</div> </div>
<div v-if="news && news.length > 0" class="pt-4 flex flex-col items-center"> <div v-if="news && news.length > 0" class="pt-4 flex flex-col">
<h3 class="px-4 text-lg m-0 text-left w-full">News</h3> <h3 class="px-4 text-lg m-0">News</h3>
<div class="px-4 pt-2 space-y-4 flex flex-col items-center w-full"> <template v-for="(item, index) in news" :key="`news-${index}`">
<NewsArticleCard <a
v-for="(item, index) in news" :class="`flex flex-col outline-offset-[-4px] hover:bg-[--brand-gradient-border] focus:bg-[--brand-gradient-border] px-4 transition-colors ${index === 0 ? 'pt-2 pb-4' : 'py-4'}`"
:key="`news-${index}`" :href="item.link"
:article="item" target="_blank"
rel="external"
>
<img
:src="item.thumbnail"
alt="News thumbnail"
aria-hidden="true"
class="w-full aspect-[3/1] object-cover rounded-2xl border-[1px] border-solid border-[--brand-gradient-border]"
/>
<h4 class="mt-2 mb-0 text-sm leading-none text-contrast font-semibold">
{{ item.title }}
</h4>
<p class="my-1 text-sm text-secondary leading-tight">{{ item.summary }}</p>
<p class="text-right text-sm text-secondary opacity-60 leading-tight m-0">
{{ dayjs(item.date).fromNow() }}
</p>
</a>
<hr
v-if="index !== news.length - 1"
class="h-px my-[-2px] mx-4 border-0 m-0 bg-[--brand-gradient-border]"
/> />
<ButtonStyled color="brand" size="large"> </template>
<a href="https://modrinth.com/news" target="_blank" class="my-4">
<NewspaperIcon /> View all news
</a>
</ButtonStyled>
</div>
</div> </div>
</div> </div>
</div> </div>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

After

Width:  |  Height:  |  Size: 937 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -7,7 +7,7 @@
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
font-weight: 400; font-weight: 400;
src: url('https://cdn-raw.modrinth.com/fonts/minecraft/regular.otf') format('opentype'); src: url('https://cdn.modrinth.com/fonts/minecraft/regular.otf') format('opentype');
} }
@font-face { @font-face {
@@ -15,7 +15,7 @@
font-style: italic; font-style: italic;
font-display: swap; font-display: swap;
font-weight: 400; font-weight: 400;
src: url('https://cdn-raw.modrinth.com/fonts/minecraft/italic.otf') format('opentype'); src: url('https://cdn.modrinth.com/fonts/minecraft/italic.otf') format('opentype');
} }
@font-face { @font-face {
@@ -23,7 +23,7 @@
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
font-weight: 600; font-weight: 600;
src: url('https://cdn-raw.modrinth.com/fonts/minecraft/bold.otf') format('opentype'); src: url('https://cdn.modrinth.com/fonts/minecraft/bold.otf') format('opentype');
} }
@font-face { @font-face {
@@ -31,7 +31,7 @@
font-style: italic; font-style: italic;
font-display: swap; font-display: swap;
font-weight: 600; font-weight: 600;
src: url('https://cdn-raw.modrinth.com/fonts/minecraft/bold-italic.otf') format('opentype'); src: url('https://cdn.modrinth.com/fonts/minecraft/bold-italic.otf') format('opentype');
} }
.font-minecraft { .font-minecraft {

View File

@@ -9,11 +9,13 @@
<Avatar <Avatar
size="36px" size="36px"
:src=" :src="
selectedAccount ? avatarUrl : 'https://launcher-files.modrinth.com/assets/steve_head.png' selectedAccount
? `https://mc-heads.net/avatar/${selectedAccount.id}/128`
: 'https://launcher-files.modrinth.com/assets/steve_head.png'
" "
/> />
<div class="flex flex-col w-full"> <div class="flex flex-col w-full">
<span>{{ selectedAccount ? selectedAccount.profile.name : 'Select account' }}</span> <span>{{ selectedAccount ? selectedAccount.username : 'Select account' }}</span>
<span class="text-secondary text-xs">Minecraft account</span> <span class="text-secondary text-xs">Minecraft account</span>
</div> </div>
<DropdownIcon class="w-5 h-5 shrink-0" /> <DropdownIcon class="w-5 h-5 shrink-0" />
@@ -26,40 +28,28 @@
:class="{ expanded: mode === 'expanded', isolated: mode === 'isolated' }" :class="{ expanded: mode === 'expanded', isolated: mode === 'isolated' }"
> >
<div v-if="selectedAccount" class="selected account"> <div v-if="selectedAccount" class="selected account">
<Avatar size="xs" :src="avatarUrl" /> <Avatar size="xs" :src="`https://mc-heads.net/avatar/${selectedAccount.id}/128`" />
<div> <div>
<h4>{{ selectedAccount.profile.name }}</h4> <h4>{{ selectedAccount.username }}</h4>
<p>Selected</p> <p>Selected</p>
</div> </div>
<Button <Button v-tooltip="'Log out'" icon-only color="raised" @click="logout(selectedAccount.id)">
v-tooltip="'Log out'"
icon-only
color="raised"
@click="logout(selectedAccount.profile.id)"
>
<TrashIcon /> <TrashIcon />
</Button> </Button>
</div> </div>
<div v-else class="logged-out account"> <div v-else class="logged-out account">
<h4>Not signed in</h4> <h4>Not signed in</h4>
<Button <Button v-tooltip="'Log in'" icon-only color="primary" @click="login()">
v-tooltip="'Log in'" <LogInIcon />
:disabled="loginDisabled"
icon-only
color="primary"
@click="login()"
>
<LogInIcon v-if="!loginDisabled" />
<SpinnerIcon v-else class="animate-spin" />
</Button> </Button>
</div> </div>
<div v-if="displayAccounts.length > 0" class="account-group"> <div v-if="displayAccounts.length > 0" class="account-group">
<div v-for="account in displayAccounts" :key="account.profile.id" class="account-row"> <div v-for="account in displayAccounts" :key="account.id" class="account-row">
<Button class="option account" @click="setAccount(account)"> <Button class="option account" @click="setAccount(account)">
<Avatar :src="getAccountAvatarUrl(account)" class="icon" /> <Avatar :src="`https://mc-heads.net/avatar/${account.id}/128`" class="icon" />
<p>{{ account.profile.name }}</p> <p>{{ account.username }}</p>
</Button> </Button>
<Button v-tooltip="'Log out'" icon-only @click="logout(account.profile.id)"> <Button v-tooltip="'Log out'" icon-only @click="logout(account.id)">
<TrashIcon /> <TrashIcon />
</Button> </Button>
</div> </div>
@@ -73,7 +63,7 @@
</template> </template>
<script setup> <script setup>
import { DropdownIcon, PlusIcon, TrashIcon, LogInIcon, SpinnerIcon } from '@modrinth/assets' import { DropdownIcon, PlusIcon, TrashIcon, LogInIcon } from '@modrinth/assets'
import { Avatar, Button, Card } from '@modrinth/ui' import { Avatar, Button, Card } from '@modrinth/ui'
import { ref, computed, onMounted, onBeforeUnmount, onUnmounted } from 'vue' import { ref, computed, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
import { import {
@@ -87,8 +77,6 @@ import { handleError } from '@/store/state.js'
import { trackEvent } from '@/helpers/analytics' import { trackEvent } from '@/helpers/analytics'
import { process_listener } from '@/helpers/events' import { process_listener } from '@/helpers/events'
import { handleSevereError } from '@/store/error.js' import { handleSevereError } from '@/store/error.js'
import { get_available_skins } from '@/helpers/skins'
import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
defineProps({ defineProps({
mode: { mode: {
@@ -101,86 +89,32 @@ defineProps({
const emit = defineEmits(['change']) const emit = defineEmits(['change'])
const accounts = ref({}) const accounts = ref({})
const loginDisabled = ref(false)
const defaultUser = ref() const defaultUser = ref()
const equippedSkin = ref(null)
const headUrlCache = ref(new Map())
async function refreshValues() { async function refreshValues() {
defaultUser.value = await get_default_user().catch(handleError) defaultUser.value = await get_default_user().catch(handleError)
accounts.value = await users().catch(handleError) accounts.value = await users().catch(handleError)
try {
const skins = await get_available_skins()
equippedSkin.value = skins.find((skin) => skin.is_equipped)
if (equippedSkin.value) {
try {
const headUrl = await getPlayerHeadUrl(equippedSkin.value)
headUrlCache.value.set(equippedSkin.value.texture_key, headUrl)
} catch (error) {
console.warn('Failed to get head render for equipped skin:', error)
}
}
} catch {
equippedSkin.value = null
}
} }
function setLoginDisabled(value) {
loginDisabled.value = value
}
defineExpose({ defineExpose({
refreshValues, refreshValues,
setLoginDisabled,
loginDisabled,
}) })
await refreshValues() await refreshValues()
const displayAccounts = computed(() => const displayAccounts = computed(() =>
accounts.value.filter((account) => defaultUser.value !== account.profile.id), accounts.value.filter((account) => defaultUser.value !== account.id),
) )
const avatarUrl = computed(() => {
if (equippedSkin.value?.texture_key) {
const cachedUrl = headUrlCache.value.get(equippedSkin.value.texture_key)
if (cachedUrl) {
return cachedUrl
}
return `https://mc-heads.net/avatar/${equippedSkin.value.texture_key}/128`
}
if (selectedAccount.value?.profile?.id) {
return `https://mc-heads.net/avatar/${selectedAccount.value.profile.id}/128`
}
return 'https://launcher-files.modrinth.com/assets/steve_head.png'
})
function getAccountAvatarUrl(account) {
if (
account.profile.id === selectedAccount.value?.profile?.id &&
equippedSkin.value?.texture_key
) {
const cachedUrl = headUrlCache.value.get(equippedSkin.value.texture_key)
if (cachedUrl) {
return cachedUrl
}
}
return `https://mc-heads.net/avatar/${account.profile.id}/128`
}
const selectedAccount = computed(() => const selectedAccount = computed(() =>
accounts.value.find((account) => account.profile.id === defaultUser.value), accounts.value.find((account) => account.id === defaultUser.value),
) )
async function setAccount(account) { async function setAccount(account) {
defaultUser.value = account.profile.id defaultUser.value = account.id
await set_default_user(account.profile.id).catch(handleError) await set_default_user(account.id).catch(handleError)
emit('change') emit('change')
} }
async function login() { async function login() {
loginDisabled.value = true
const loggedIn = await login_flow().catch(handleSevereError) const loggedIn = await login_flow().catch(handleSevereError)
if (loggedIn) { if (loggedIn) {
@@ -189,7 +123,6 @@ async function login() {
} }
trackEvent('AccountLogIn') trackEvent('AccountLogIn')
loginDisabled.value = false
} }
const logout = async (id) => { const logout = async (id) => {

View File

@@ -14,7 +14,7 @@ import { ref, computed } from 'vue'
import { login as login_flow, set_default_user } from '@/helpers/auth.js' import { login as login_flow, set_default_user } from '@/helpers/auth.js'
import { handleError } from '@/store/notifications.js' import { handleError } from '@/store/notifications.js'
import { handleSevereError } from '@/store/error.js' import { handleSevereError } from '@/store/error.js'
import { cancel_directory_change } from '@/helpers/settings.ts' import { cancel_directory_change } from '@/helpers/settings.js'
import { install } from '@/helpers/profile.js' import { install } from '@/helpers/profile.js'
import { trackEvent } from '@/helpers/analytics' import { trackEvent } from '@/helpers/analytics'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
@@ -92,7 +92,7 @@ async function loginMinecraft() {
const loggedIn = await login_flow() const loggedIn = await login_flow()
if (loggedIn) { if (loggedIn) {
await set_default_user(loggedIn.profile.id).catch(handleError) await set_default_user(loggedIn.id).catch(handleError)
} }
await trackEvent('AccountLogIn', { source: 'ErrorModal' }) await trackEvent('AccountLogIn', { source: 'ErrorModal' })
@@ -219,8 +219,8 @@ async function copyToClipboard(text) {
<template v-else-if="metadata.notEnoughSpace"> <template v-else-if="metadata.notEnoughSpace">
<h3>Not enough space</h3> <h3>Not enough space</h3>
<p> <p>
It looks like there is not enough space on the disk containing the directory you It looks like there is not enough space on the disk containing the dirctory you
selected. Please free up some space and try again or cancel the directory change. selected Please free up some space and try again or cancel the directory change.
</p> </p>
</template> </template>
<template v-else> <template v-else>

View File

@@ -9,7 +9,7 @@ import {
StopCircleIcon, StopCircleIcon,
TimerIcon, TimerIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { Avatar, ButtonStyled, useRelativeTime } from '@modrinth/ui' import { Avatar, ButtonStyled } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core' import { convertFileSrc } from '@tauri-apps/api/core'
import { finish_install, kill, run } from '@/helpers/profile' import { finish_install, kill, run } from '@/helpers/profile'
import { get_by_profile_path } from '@/helpers/process' import { get_by_profile_path } from '@/helpers/process'
@@ -19,8 +19,10 @@ import { showProfileInFolder } from '@/helpers/utils.js'
import { handleSevereError } from '@/store/error.js' import { handleSevereError } from '@/store/error.js'
import { trackEvent } from '@/helpers/analytics' import { trackEvent } from '@/helpers/analytics'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { formatCategory } from '@modrinth/utils'
const formatRelativeTime = useRelativeTime() dayjs.extend(relativeTime)
const props = defineProps({ const props = defineProps({
instance: { instance: {
@@ -171,12 +173,7 @@ onUnmounted(() => unlisten())
</div> </div>
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold"> <div class="flex items-center col-span-3 gap-1 text-secondary font-semibold">
<TimerIcon /> <TimerIcon />
<span class="text-sm"> <span class="text-sm"> Played {{ dayjs(instance.last_played).fromNow() }} </span>
<template v-if="instance.last_played">
Played {{ formatRelativeTime(dayjs(instance.last_played).toISOString()) }}
</template>
<template v-else> Never played </template>
</span>
</div> </div>
</div> </div>
</template> </template>
@@ -239,8 +236,8 @@ onUnmounted(() => unlisten())
</p> </p>
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold mt-auto"> <div class="flex items-center col-span-3 gap-1 text-secondary font-semibold mt-auto">
<GameIcon class="shrink-0" /> <GameIcon class="shrink-0" />
<span class="text-sm capitalize"> <span class="text-sm">
{{ instance.loader }} {{ instance.game_version }} {{ formatCategory(instance.loader) }} {{ instance.game_version }}
</span> </span>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,5 @@
<template> <template>
<ModalWrapper ref="modal" header="Creating an instance"> <ModalWrapper ref="modal" header="Create instance">
<div class="modal-header"> <div class="modal-header">
<Chips v-model="creationType" :items="['custom', 'from file', 'import from launcher']" /> <Chips v-model="creationType" :items="['custom', 'from file', 'import from launcher']" />
</div> </div>

View File

@@ -108,6 +108,7 @@ async function testJava() {
testingJava.value = true testingJava.value = true
testingJavaSuccess.value = await test_jre( testingJavaSuccess.value = await test_jre(
props.modelValue ? props.modelValue.path : '', props.modelValue ? props.modelValue.path : '',
1,
props.version, props.version,
) )
testingJava.value = false testingJava.value = false
@@ -126,7 +127,7 @@ async function handleJavaFileInput() {
const filePath = await open() const filePath = await open()
if (filePath) { if (filePath) {
let result = await get_jre(filePath.path ?? filePath).catch(handleError) let result = await get_jre(filePath.path ?? filePath)
if (!result) { if (!result) {
result = { result = {
path: filePath.path ?? filePath, path: filePath.path ?? filePath,

View File

@@ -32,33 +32,8 @@ function updateAdPosition() {
<template> <template>
<div ref="adsWrapper" class="ad-parent relative flex w-full justify-center cursor-pointer bg-bg"> <div ref="adsWrapper" class="ad-parent relative flex w-full justify-center cursor-pointer bg-bg">
<a <div class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 p-6">
href="https://modrinth.gg?from=app-placeholder" <p class="m-0 text-2xl font-bold text-contrast">75% of ad revenue goes to creators</p>
target="_blank" </div>
class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 rounded-[inherit]"
>
<img
src="https://cdn-raw.modrinth.com/modrinth-servers-placeholder-light.webp"
alt="Host your next server with Modrinth Servers"
class="hidden light-image rounded-[inherit]"
/>
<img
src="https://cdn-raw.modrinth.com/modrinth-servers-placeholder-dark.webp"
alt="Host your next server with Modrinth Servers"
class="dark-image rounded-[inherit]"
/>
</a>
</div> </div>
</template> </template>
<style lang="scss" scoped>
.light,
.light-mode {
.dark-image {
display: none;
}
.light-image {
display: block;
}
}
</style>

View File

@@ -30,7 +30,7 @@ const getInstances = async () => {
return dateB - dateA return dateB - dateA
}) })
.slice(0, 3) .slice(0, 4)
} }
await getInstances() await getInstances()

View File

@@ -124,11 +124,8 @@ import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime' import relativeTime from 'dayjs/plugin/relativeTime'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { install as installVersion } from '@/store/install.js' import { install as installVersion } from '@/store/install.js'
import { useRouter } from 'vue-router'
dayjs.extend(relativeTime) dayjs.extend(relativeTime)
const router = useRouter()
const props = defineProps({ const props = defineProps({
backgroundImage: { backgroundImage: {
type: String, type: String,
@@ -171,9 +168,6 @@ async function install() {
installing.value = false installing.value = false
emit('install', props.project.project_id ?? props.project.id) emit('install', props.project.project_id ?? props.project.id)
}, },
(profile) => {
router.push(`/instance/${profile}`)
},
) )
} }

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { Avatar, ButtonStyled, OverflowMenu, useRelativeTime } from '@modrinth/ui' import { Avatar, ButtonStyled, OverflowMenu } from '@modrinth/ui'
import { import {
UserPlusIcon, UserPlusIcon,
MoreVerticalIcon, MoreVerticalIcon,
@@ -18,8 +18,6 @@ import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
const formatRelativeTime = useRelativeTime()
const props = defineProps<{ const props = defineProps<{
credentials: unknown | null credentials: unknown | null
signIn: () => void signIn: () => void
@@ -207,9 +205,7 @@ onUnmounted(() => {
You sent <span class="font-bold">{{ friend.username }}</span> a friend request You sent <span class="font-bold">{{ friend.username }}</span> a friend request
</template> </template>
</p> </p>
<p class="m-0 text-sm text-secondary"> <p class="m-0 text-sm text-secondary">{{ friend.created.fromNow() }}</p>
{{ formatRelativeTime(friend.created.toISOString()) }}
</p>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<template v-if="friend.id === userCredentials.user_id"> <template v-if="friend.id === userCredentials.user_id">

View File

@@ -13,17 +13,15 @@ const confirmModal = ref(null)
const installing = ref(false) const installing = ref(false)
const onInstall = ref(() => {}) const onInstall = ref(() => {})
const onCreateInstance = ref(() => {})
defineExpose({ defineExpose({
show: (projectVal, versionIdVal, callback, createInstanceCallback) => { show: (projectVal, versionIdVal, callback) => {
project.value = projectVal project.value = projectVal
versionId.value = versionIdVal versionId.value = versionIdVal
installing.value = false installing.value = false
confirmModal.value.show() confirmModal.value.show()
onInstall.value = callback onInstall.value = callback
onCreateInstance.value = createInstanceCallback
trackEvent('PackInstallStart') trackEvent('PackInstallStart')
}, },
@@ -38,7 +36,6 @@ async function install() {
versionId.value, versionId.value,
project.value.title, project.value.title,
project.value.icon_url, project.value.icon_url,
onCreateInstance.value,
).catch(handleError) ).catch(handleError)
trackEvent('PackInstall', { trackEvent('PackInstall', {
id: project.value.id, id: project.value.id,

View File

@@ -3,7 +3,7 @@ import { Checkbox } from '@modrinth/ui'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { handleError } from '@/store/notifications' import { handleError } from '@/store/notifications'
import { defineMessages, useVIntl } from '@vintl/vintl' import { defineMessages, useVIntl } from '@vintl/vintl'
import { get } from '@/helpers/settings.ts' import { get } from '@/helpers/settings'
import { edit } from '@/helpers/profile' import { edit } from '@/helpers/profile'
import type { InstanceSettingsTabProps, AppSettings, Hooks } from '../../../helpers/types' import type { InstanceSettingsTabProps, AppSettings, Hooks } from '../../../helpers/types'
@@ -25,8 +25,9 @@ const editProfileObject = computed(() => {
hooks?: Hooks hooks?: Hooks
} = {} } = {}
// When hooks are not overridden per-instance, we want to clear them if (overrideHooks.value) {
editProfile.hooks = overrideHooks.value ? hooks.value : {} editProfile.hooks = hooks.value
}
return editProfile return editProfile
}) })

View File

@@ -7,7 +7,7 @@ import { handleError } from '@/store/notifications'
import { defineMessages, useVIntl } from '@vintl/vintl' import { defineMessages, useVIntl } from '@vintl/vintl'
import JavaSelector from '@/components/ui/JavaSelector.vue' import JavaSelector from '@/components/ui/JavaSelector.vue'
import { get_max_memory } from '@/helpers/jre' import { get_max_memory } from '@/helpers/jre'
import { get } from '@/helpers/settings.ts' import { get } from '@/helpers/settings'
import type { InstanceSettingsTabProps, AppSettings, MemorySettings } from '../../../helpers/types' import type { InstanceSettingsTabProps, AppSettings, MemorySettings } from '../../../helpers/types'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()

View File

@@ -3,7 +3,7 @@ import { Checkbox, Toggle } from '@modrinth/ui'
import { computed, ref, type Ref, watch } from 'vue' import { computed, ref, type Ref, watch } from 'vue'
import { handleError } from '@/store/notifications' import { handleError } from '@/store/notifications'
import { defineMessages, useVIntl } from '@vintl/vintl' import { defineMessages, useVIntl } from '@vintl/vintl'
import { get } from '@/helpers/settings.ts' import { get } from '@/helpers/settings'
import { edit } from '@/helpers/profile' import { edit } from '@/helpers/profile'
import type { AppSettings, InstanceSettingsTabProps } from '../../../helpers/types' import type { AppSettings, InstanceSettingsTabProps } from '../../../helpers/types'

View File

@@ -5,7 +5,7 @@ import {
ShieldIcon, ShieldIcon,
SettingsIcon, SettingsIcon,
GaugeIcon, GaugeIcon,
PaintbrushIcon, PaintBrushIcon,
GameIcon, GameIcon,
CoffeeIcon, CoffeeIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
@@ -22,7 +22,7 @@ import { version as getOsVersion, platform as getOsPlatform } from '@tauri-apps/
import { useTheming } from '@/store/state' import { useTheming } from '@/store/state'
import FeatureFlagSettings from '@/components/ui/settings/FeatureFlagSettings.vue' import FeatureFlagSettings from '@/components/ui/settings/FeatureFlagSettings.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { get, set } from '@/helpers/settings.ts' import { get, set } from '@/helpers/settings'
const themeStore = useTheming() const themeStore = useTheming()
@@ -41,7 +41,7 @@ const tabs = [
id: 'app.settings.tabs.appearance', id: 'app.settings.tabs.appearance',
defaultMessage: 'Appearance', defaultMessage: 'Appearance',
}), }),
icon: PaintbrushIcon, icon: PaintBrushIcon,
content: AppearanceSettings, content: AppearanceSettings,
}, },
{ {

View File

@@ -2,7 +2,7 @@
import { ref } from 'vue' import { ref } from 'vue'
import { ConfirmModal } from '@modrinth/ui' import { ConfirmModal } from '@modrinth/ui'
import { show_ads_window, hide_ads_window } from '@/helpers/ads.js' import { show_ads_window, hide_ads_window } from '@/helpers/ads.js'
import { useTheming } from '@/store/theme.ts' import { useTheming } from '@/store/theme.js'
const themeStore = useTheming() const themeStore = useTheming()

View File

@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { useTemplateRef } from 'vue' import { ref } from 'vue'
import { NewModal as Modal } from '@modrinth/ui' import { NewModal as Modal } from '@modrinth/ui'
import { show_ads_window, hide_ads_window } from '@/helpers/ads.js' import { show_ads_window, hide_ads_window } from '@/helpers/ads.js'
import { useTheming } from '@/store/theme.ts' import { useTheming } from '@/store/theme.js'
const themeStore = useTheming() const themeStore = useTheming()
@@ -26,16 +26,16 @@ const props = defineProps({
default: true, default: true,
}, },
}) })
const modal = useTemplateRef('modal') const modal = ref(null)
defineExpose({ defineExpose({
show: (e: MouseEvent) => { show: () => {
hide_ads_window() hide_ads_window()
modal.value?.show(e) modal.value.show()
}, },
hide: () => { hide: () => {
onModalHide() onModalHide()
modal.value?.hide() modal.value.hide()
}, },
}) })

View File

@@ -2,7 +2,7 @@
import { ref } from 'vue' import { ref } from 'vue'
import { ShareModal } from '@modrinth/ui' import { ShareModal } from '@modrinth/ui'
import { show_ads_window, hide_ads_window } from '@/helpers/ads.js' import { show_ads_window, hide_ads_window } from '@/helpers/ads.js'
import { useTheming } from '@/store/theme.ts' import { useTheming } from '@/store/theme.js'
const themeStore = useTheming() const themeStore = useTheming()

View File

@@ -1,10 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { TeleportDropdownMenu, ThemeSelector, Toggle } from '@modrinth/ui' import { TeleportDropdownMenu, ThemeSelector, Toggle } from '@modrinth/ui'
import { useTheming } from '@/store/state' import { useTheming } from '@/store/state'
import { get, set } from '@/helpers/settings.ts' import { get, set } from '@/helpers/settings'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { getOS } from '@/helpers/utils' import { getOS } from '@/helpers/utils'
import type { ColorTheme } from '@/store/theme.ts'
const themeStore = useTheming() const themeStore = useTheming()
@@ -25,13 +24,13 @@ watch(
<ThemeSelector <ThemeSelector
:update-color-theme=" :update-color-theme="
(theme: ColorTheme) => { (theme) => {
themeStore.setThemeState(theme) themeStore.setThemeState(theme)
settings.theme = theme settings.theme = theme
} }
" "
:current-theme="settings.theme" :current-theme="settings.theme"
:theme-options="themeStore.getThemeOptions()" :theme-options="themeStore.themeOptions"
system-theme-color="system" system-theme-color="system"
/> />
@@ -56,17 +55,9 @@ watch(
/> />
</div> </div>
<div class="mt-4 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Hide nametag</h2>
<p class="m-0 mt-1">Disables the nametag above your player on the skins page.</p>
</div>
<Toggle id="hide-nametag-skins-page" v-model="settings.hide_nametag_skins_page" />
</div>
<div v-if="os !== 'MacOS'" class="mt-4 flex items-center justify-between gap-4"> <div v-if="os !== 'MacOS'" class="mt-4 flex items-center justify-between gap-4">
<div> <div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Native decorations</h2> <h2 class="m-0 text-lg font-extrabold text-contrast">Native Decorations</h2>
<p class="m-0 mt-1">Use system window frame (app restart required).</p> <p class="m-0 mt-1">Use system window frame (app restart required).</p>
</div> </div>
<Toggle id="native-decorations" v-model="settings.native_decorations" /> <Toggle id="native-decorations" v-model="settings.native_decorations" />
@@ -89,28 +80,10 @@ watch(
id="opening-page" id="opening-page"
v-model="settings.default_page" v-model="settings.default_page"
name="Opening page dropdown" name="Opening page dropdown"
class="w-40"
:options="['Home', 'Library']" :options="['Home', 'Library']"
/> />
</div> </div>
<div class="mt-4 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Jump back into worlds</h2>
<p class="m-0 mt-1">Includes recent worlds in the "Jump back in" section on the Home page.</p>
</div>
<Toggle
:model-value="themeStore.getFeatureFlag('worlds_in_home')"
@update:model-value="
() => {
const newValue = !themeStore.getFeatureFlag('worlds_in_home')
themeStore.featureFlags['worlds_in_home'] = newValue
settings.feature_flags['worlds_in_home'] = newValue
}
"
/>
</div>
<div class="mt-4 flex items-center justify-between"> <div class="mt-4 flex items-center justify-between">
<div> <div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Toggle sidebar</h2> <h2 class="m-0 text-lg font-extrabold text-contrast">Toggle sidebar</h2>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { get, set } from '@/helpers/settings.ts' import { get, set } from '@/helpers/settings'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { get_max_memory } from '@/helpers/jre' import { get_max_memory } from '@/helpers/jre'
import { handleError } from '@/store/notifications' import { handleError } from '@/store/notifications'

View File

@@ -2,15 +2,18 @@
import { Toggle } from '@modrinth/ui' import { Toggle } from '@modrinth/ui'
import { useTheming } from '@/store/state' import { useTheming } from '@/store/state'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts' import { get, set } from '@/helpers/settings'
import { DEFAULT_FEATURE_FLAGS, type FeatureFlag } from '@/store/theme.ts'
const themeStore = useTheming() const themeStore = useTheming()
const settings = ref(await getSettings()) const settings = ref(await get())
const options = ref<FeatureFlag[]>(Object.keys(DEFAULT_FEATURE_FLAGS)) const options = ref(['project_background', 'page_path', 'worlds_tab'])
function setFeatureFlag(key: string, value: boolean) { function getStoreValue(key: string) {
return themeStore.featureFlags[key] ?? false
}
function setStoreValue(key: string, value: boolean) {
themeStore.featureFlags[key] = value themeStore.featureFlags[key] = value
settings.value.feature_flags[key] = value settings.value.feature_flags[key] = value
} }
@@ -18,7 +21,7 @@ function setFeatureFlag(key: string, value: boolean) {
watch( watch(
settings, settings,
async () => { async () => {
await setSettings(settings.value) await set(settings.value)
}, },
{ deep: true }, { deep: true },
) )
@@ -33,8 +36,8 @@ watch(
<Toggle <Toggle
id="advanced-rendering" id="advanced-rendering"
:model-value="themeStore.getFeatureFlag(option)" :model-value="getStoreValue(option)"
@update:model-value="() => setFeatureFlag(option, !themeStore.getFeatureFlag(option))" @update:model-value="() => setStoreValue(option, !themeStore.featureFlags[option])"
/> />
</div> </div>
</template> </template>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.ts' import { get, set } from '@/helpers/settings'
import { Toggle } from '@modrinth/ui' import { Toggle } from '@modrinth/ui'
import { optInAnalytics, optOutAnalytics } from '@/helpers/analytics' import { optInAnalytics, optOutAnalytics } from '@/helpers/analytics'

View File

@@ -1,7 +1,7 @@
<script setup> <script setup>
import { Button, Slider } from '@modrinth/ui' import { Button, Slider } from '@modrinth/ui'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.ts' import { get, set } from '@/helpers/settings.js'
import { purge_cache_types } from '@/helpers/cache.js' import { purge_cache_types } from '@/helpers/cache.js'
import { handleError } from '@/store/notifications.js' import { handleError } from '@/store/notifications.js'
import { BoxIcon, FolderSearchIcon, TrashIcon } from '@modrinth/assets' import { BoxIcon, FolderSearchIcon, TrashIcon } from '@modrinth/assets'

View File

@@ -1,412 +0,0 @@
<template>
<UploadSkinModal ref="uploadModal" />
<ModalWrapper ref="modal" @on-hide="resetState">
<template #title>
<span class="text-lg font-extrabold text-contrast">
{{ mode === 'edit' ? 'Editing skin' : 'Adding a skin' }}
</span>
</template>
<div class="flex flex-col md:flex-row gap-6">
<div class="max-h-[25rem] w-[16rem] min-w-[16rem] overflow-hidden relative">
<div class="absolute top-[-4rem] left-0 h-[32rem] w-[16rem] flex-shrink-0">
<SkinPreviewRenderer
:variant="variant"
:texture-src="previewSkin || ''"
:cape-src="selectedCapeTexture"
:scale="1.4"
:fov="50"
:initial-rotation="Math.PI / 8"
class="h-full w-full"
/>
</div>
</div>
<div class="flex flex-col gap-4 w-full min-h-[20rem]">
<section>
<h2 class="text-base font-semibold mb-2">Texture</h2>
<Button @click="openUploadSkinModal"> <UploadIcon /> Replace texture </Button>
</section>
<section>
<h2 class="text-base font-semibold mb-2">Arm style</h2>
<RadioButtons v-model="variant" :items="['CLASSIC', 'SLIM']">
<template #default="{ item }">
{{ item === 'CLASSIC' ? 'Wide' : 'Slim' }}
</template>
</RadioButtons>
</section>
<section>
<h2 class="text-base font-semibold mb-2">Cape</h2>
<div class="flex gap-2">
<CapeButton
v-if="defaultCape"
:id="defaultCape.id"
:texture="defaultCape.texture"
:name="undefined"
:selected="!selectedCape"
faded
@select="selectCape(undefined)"
>
<span>Use default cape</span>
</CapeButton>
<CapeLikeTextButton v-else :highlighted="!selectedCape" @click="selectCape(undefined)">
<span>Use default cape</span>
</CapeLikeTextButton>
<CapeButton
v-for="cape in visibleCapeList"
:id="cape.id"
:key="cape.id"
:texture="cape.texture"
:name="cape.name || 'Cape'"
:selected="selectedCape?.id === cape.id"
@select="selectCape(cape)"
/>
<CapeLikeTextButton
v-if="(capes?.length ?? 0) > 2"
tooltip="View more capes"
@mouseup="openSelectCapeModal"
>
<template #icon><ChevronRightIcon /></template>
<span>More</span>
</CapeLikeTextButton>
</div>
</section>
</div>
</div>
<div class="flex gap-2 mt-12">
<ButtonStyled color="brand" :disabled="disableSave || isSaving">
<button v-tooltip="saveTooltip" :disabled="disableSave || isSaving" @click="save">
<SpinnerIcon v-if="isSaving" class="animate-spin" />
<CheckIcon v-else-if="mode === 'new'" />
<SaveIcon v-else />
{{ mode === 'new' ? 'Add skin' : 'Save skin' }}
</button>
</ButtonStyled>
<Button :disabled="isSaving" @click="hide"><XIcon />Cancel</Button>
</div>
</ModalWrapper>
<SelectCapeModal
ref="selectCapeModal"
:capes="capes || []"
@select="handleCapeSelected"
@cancel="handleCapeCancel"
/>
</template>
<script setup lang="ts">
import { ref, computed, watch, useTemplateRef } from 'vue'
import SelectCapeModal from '@/components/ui/skin/SelectCapeModal.vue'
import {
SkinPreviewRenderer,
Button,
RadioButtons,
CapeButton,
CapeLikeTextButton,
ButtonStyled,
} from '@modrinth/ui'
import {
add_and_equip_custom_skin,
remove_custom_skin,
unequip_skin,
type Skin,
type Cape,
type SkinModel,
get_normalized_skin_texture,
} from '@/helpers/skins.ts'
import { handleError } from '@/store/notifications'
import {
UploadIcon,
CheckIcon,
SaveIcon,
XIcon,
ChevronRightIcon,
SpinnerIcon,
} from '@modrinth/assets'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import UploadSkinModal from '@/components/ui/skin/UploadSkinModal.vue'
const modal = useTemplateRef('modal')
const selectCapeModal = useTemplateRef('selectCapeModal')
const mode = ref<'new' | 'edit'>('new')
const currentSkin = ref<Skin | null>(null)
const shouldRestoreModal = ref(false)
const isSaving = ref(false)
const uploadedTextureUrl = ref<string | null>(null)
const previewSkin = ref<string>('')
const variant = ref<SkinModel>('CLASSIC')
const selectedCape = ref<Cape | undefined>(undefined)
const props = defineProps<{ capes?: Cape[]; defaultCape?: Cape }>()
const selectedCapeTexture = computed(() => selectedCape.value?.texture)
const visibleCapeList = ref<Cape[]>([])
const sortedCapes = computed(() => {
return [...(props.capes || [])].sort((a, b) => {
const nameA = (a.name || '').toLowerCase()
const nameB = (b.name || '').toLowerCase()
return nameA.localeCompare(nameB)
})
})
function initVisibleCapeList() {
if (!props.capes || props.capes.length === 0) {
visibleCapeList.value = []
return
}
if (visibleCapeList.value.length === 0) {
if (selectedCape.value) {
const otherCape = getSortedCapeExcluding(selectedCape.value.id)
visibleCapeList.value = otherCape ? [selectedCape.value, otherCape] : [selectedCape.value]
} else {
visibleCapeList.value = getSortedCapes(2)
}
}
}
function getSortedCapes(count: number): Cape[] {
if (!sortedCapes.value || sortedCapes.value.length === 0) return []
return sortedCapes.value.slice(0, count)
}
function getSortedCapeExcluding(excludeId: string): Cape | undefined {
if (!sortedCapes.value || sortedCapes.value.length <= 1) return undefined
return sortedCapes.value.find((cape) => cape.id !== excludeId)
}
async function loadPreviewSkin() {
if (uploadedTextureUrl.value) {
previewSkin.value = uploadedTextureUrl.value
} else if (currentSkin.value) {
try {
previewSkin.value = await get_normalized_skin_texture(currentSkin.value)
} catch (error) {
console.error('Failed to load skin texture:', error)
previewSkin.value = '/src/assets/skins/steve.png'
}
} else {
previewSkin.value = '/src/assets/skins/steve.png'
}
}
const hasEdits = computed(() => {
if (mode.value !== 'edit') return true
if (uploadedTextureUrl.value) return true
if (!currentSkin.value) return false
if (variant.value !== currentSkin.value.variant) return true
if ((selectedCape.value?.id || null) !== (currentSkin.value.cape_id || null)) return true
return false
})
const disableSave = computed(
() =>
(mode.value === 'new' && !uploadedTextureUrl.value) ||
(mode.value === 'edit' && !hasEdits.value),
)
const saveTooltip = computed(() => {
if (isSaving.value) return 'Saving...'
if (mode.value === 'new' && !uploadedTextureUrl.value) return 'Upload a skin first!'
if (mode.value === 'edit' && !hasEdits.value) return 'Make an edit to the skin first!'
return undefined
})
function resetState() {
mode.value = 'new'
currentSkin.value = null
uploadedTextureUrl.value = null
previewSkin.value = ''
variant.value = 'CLASSIC'
selectedCape.value = undefined
visibleCapeList.value = []
shouldRestoreModal.value = false
isSaving.value = false
}
async function show(e: MouseEvent, skin?: Skin) {
mode.value = skin ? 'edit' : 'new'
currentSkin.value = skin ?? null
if (skin) {
variant.value = skin.variant
selectedCape.value = props.capes?.find((c) => c.id === skin.cape_id)
} else {
variant.value = 'CLASSIC'
selectedCape.value = undefined
}
visibleCapeList.value = []
initVisibleCapeList()
await loadPreviewSkin()
modal.value?.show(e)
}
async function showNew(e: MouseEvent, skinTextureUrl: string) {
mode.value = 'new'
currentSkin.value = null
uploadedTextureUrl.value = skinTextureUrl
variant.value = 'CLASSIC'
selectedCape.value = undefined
visibleCapeList.value = []
initVisibleCapeList()
await loadPreviewSkin()
modal.value?.show(e)
}
async function restoreWithNewTexture(skinTextureUrl: string) {
uploadedTextureUrl.value = skinTextureUrl
await loadPreviewSkin()
if (shouldRestoreModal.value) {
setTimeout(() => {
modal.value?.show()
shouldRestoreModal.value = false
}, 0)
}
}
function hide() {
modal.value?.hide()
setTimeout(() => resetState(), 250)
}
function selectCape(cape: Cape | undefined) {
if (cape && selectedCape.value?.id !== cape.id) {
const isInVisibleList = visibleCapeList.value.some((c) => c.id === cape.id)
if (!isInVisibleList && visibleCapeList.value.length > 0) {
visibleCapeList.value.splice(0, 1, cape)
if (visibleCapeList.value.length > 1 && visibleCapeList.value[1].id === cape.id) {
const otherCape = getSortedCapeExcluding(cape.id)
if (otherCape) {
visibleCapeList.value.splice(1, 1, otherCape)
}
}
}
}
selectedCape.value = cape
}
function handleCapeSelected(cape: Cape | undefined) {
selectCape(cape)
if (shouldRestoreModal.value) {
setTimeout(() => {
modal.value?.show()
shouldRestoreModal.value = false
}, 0)
}
}
function handleCapeCancel() {
if (shouldRestoreModal.value) {
setTimeout(() => {
modal.value?.show()
shouldRestoreModal.value = false
}, 0)
}
}
function openSelectCapeModal(e: MouseEvent) {
if (!selectCapeModal.value) return
shouldRestoreModal.value = true
modal.value?.hide()
setTimeout(() => {
selectCapeModal.value?.show(
e,
currentSkin.value?.texture_key,
selectedCape.value,
previewSkin.value,
variant.value,
)
}, 0)
}
function openUploadSkinModal(e: MouseEvent) {
shouldRestoreModal.value = true
modal.value?.hide()
emit('open-upload-modal', e)
}
function restoreModal() {
if (shouldRestoreModal.value) {
setTimeout(() => {
const fakeEvent = new MouseEvent('click')
modal.value?.show(fakeEvent)
shouldRestoreModal.value = false
}, 500)
}
}
async function save() {
isSaving.value = true
try {
let textureUrl: string
if (uploadedTextureUrl.value) {
textureUrl = uploadedTextureUrl.value
} else {
textureUrl = currentSkin.value!.texture
}
await unequip_skin()
const bytes: Uint8Array = new Uint8Array(await (await fetch(textureUrl)).arrayBuffer())
if (mode.value === 'new') {
await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value)
emit('saved')
} else {
await add_and_equip_custom_skin(bytes, variant.value, selectedCape.value)
await remove_custom_skin(currentSkin.value!)
emit('saved')
}
hide()
} catch (err) {
handleError(err)
} finally {
isSaving.value = false
}
}
watch([uploadedTextureUrl, currentSkin], async () => {
await loadPreviewSkin()
})
watch(
() => props.capes,
() => {
initVisibleCapeList()
},
{ immediate: true },
)
const emit = defineEmits<{
(event: 'saved'): void
(event: 'deleted', skin: Skin): void
(event: 'open-upload-modal', mouseEvent: MouseEvent): void
}>()
defineExpose({
show,
showNew,
restoreWithNewTexture,
hide,
shouldRestoreModal,
restoreModal,
})
</script>

View File

@@ -1,140 +0,0 @@
<script setup lang="ts">
import { useTemplateRef, ref, computed } from 'vue'
import type { Cape, SkinModel } from '@/helpers/skins.ts'
import {
ButtonStyled,
ScrollablePanel,
CapeButton,
CapeLikeTextButton,
SkinPreviewRenderer,
} from '@modrinth/ui'
import { CheckIcon, XIcon } from '@modrinth/assets'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
const modal = useTemplateRef('modal')
const emit = defineEmits<{
(e: 'select', cape: Cape | undefined): void
(e: 'cancel'): void
}>()
const props = defineProps<{
capes: Cape[]
}>()
const sortedCapes = computed(() => {
return [...props.capes].sort((a, b) => {
const nameA = (a.name || '').toLowerCase()
const nameB = (b.name || '').toLowerCase()
return nameA.localeCompare(nameB)
})
})
const currentSkinId = ref<string | undefined>()
const currentSkinTexture = ref<string | undefined>()
const currentSkinVariant = ref<SkinModel>('CLASSIC')
const currentCapeTexture = computed<string | undefined>(() => currentCape.value?.texture)
const currentCape = ref<Cape | undefined>()
function show(
e: MouseEvent,
skinId?: string,
selected?: Cape,
skinTexture?: string,
variant?: SkinModel,
) {
currentSkinId.value = skinId
currentSkinTexture.value = skinTexture
currentSkinVariant.value = variant || 'CLASSIC'
currentCape.value = selected
modal.value?.show(e)
}
function select() {
emit('select', currentCape.value)
hide()
}
function hide() {
modal.value?.hide()
emit('cancel')
}
function updateSelectedCape(cape: Cape | undefined) {
currentCape.value = cape
}
function onModalHide() {
emit('cancel')
}
defineExpose({
show,
hide,
})
</script>
<template>
<ModalWrapper ref="modal" @on-hide="onModalHide">
<template #title>
<div class="flex flex-col">
<span class="text-lg font-extrabold text-heading">Change cape</span>
</div>
</template>
<div class="flex flex-col md:flex-row gap-6">
<div class="max-h-[25rem] h-[25rem] w-[16rem] min-w-[16rem] overflow-hidden relative">
<div class="absolute top-[-4rem] left-0 h-[32rem] w-[16rem] flex-shrink-0">
<SkinPreviewRenderer
v-if="currentSkinTexture"
:cape-src="currentCapeTexture"
:texture-src="currentSkinTexture"
:variant="currentSkinVariant"
:scale="1.4"
:fov="50"
:initial-rotation="Math.PI + Math.PI / 8"
class="h-full w-full"
/>
</div>
</div>
<div class="flex flex-col gap-4 w-full my-auto">
<ScrollablePanel class="max-h-[20rem] max-w-[30rem] mb-5 h-full">
<div class="flex flex-wrap gap-2 justify-center content-start overflow-y-auto h-full">
<CapeLikeTextButton
tooltip="No Cape"
:highlighted="!currentCape"
@click="updateSelectedCape(undefined)"
>
<template #icon>
<XIcon />
</template>
<span>None</span>
</CapeLikeTextButton>
<CapeButton
v-for="cape in sortedCapes"
:id="cape.id"
:key="cape.id"
:name="cape.name"
:texture="cape.texture"
:selected="currentCape?.id === cape.id"
@select="updateSelectedCape(cape)"
/>
</div>
</ScrollablePanel>
</div>
</div>
<div class="flex gap-2 items-center">
<ButtonStyled color="brand">
<button @click="select">
<CheckIcon />
Select
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="hide">
<XIcon />
Cancel
</button>
</ButtonStyled>
</div>
</ModalWrapper>
</template>

View File

@@ -1,140 +0,0 @@
<template>
<ModalWrapper ref="modal" @on-hide="hide(true)">
<template #title>
<span class="text-lg font-extrabold text-contrast"> Upload skin texture </span>
</template>
<div class="relative">
<div
class="border-2 border-dashed border-highlight-gray rounded-xl h-[173px] flex flex-col items-center justify-center p-8 cursor-pointer bg-button-bg hover:bg-button-hover transition-colors relative"
@click="triggerFileInput"
>
<p class="mx-auto mb-0 text-primary font-bold text-lg text-center flex items-center gap-2">
<UploadIcon /> Select skin texture file
</p>
<p class="mx-auto mt-0 text-secondary text-sm text-center">
Drag and drop or click here to browse
</p>
<input
ref="fileInput"
type="file"
accept="image/png"
class="hidden"
@change="handleInputFileChange"
/>
</div>
</div>
</ModalWrapper>
</template>
<script setup lang="ts">
import { ref, onBeforeUnmount, watch } from 'vue'
import { UploadIcon } from '@modrinth/assets'
import { useNotifications } from '@/store/state'
import { getCurrentWebview } from '@tauri-apps/api/webview'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { get_dragged_skin_data } from '@/helpers/skins'
const notifications = useNotifications()
const modal = ref()
const fileInput = ref<HTMLInputElement>()
const unlisten = ref<() => void>()
const modalVisible = ref(false)
const emit = defineEmits<{
(e: 'uploaded', data: ArrayBuffer): void
(e: 'canceled'): void
}>()
function show(e?: MouseEvent) {
modal.value?.show(e)
modalVisible.value = true
setupDragDropListener()
}
function hide(emitCanceled = false) {
modal.value?.hide()
modalVisible.value = false
cleanupDragDropListener()
resetState()
if (emitCanceled) {
emit('canceled')
}
}
function resetState() {
if (fileInput.value) fileInput.value.value = ''
}
function triggerFileInput() {
fileInput.value?.click()
}
async function handleInputFileChange(e: Event) {
const files = (e.target as HTMLInputElement).files
if (!files || files.length === 0) {
return
}
const file = files[0]
const buffer = await file.arrayBuffer()
await processData(buffer)
}
async function setupDragDropListener() {
try {
if (modalVisible.value) {
await cleanupDragDropListener()
unlisten.value = await getCurrentWebview().onDragDropEvent(async (event) => {
if (event.payload.type !== 'drop') {
return
}
if (!event.payload.paths || event.payload.paths.length === 0) {
return
}
const filePath = event.payload.paths[0]
try {
const data = await get_dragged_skin_data(filePath)
await processData(data.buffer)
} catch (error) {
notifications.addNotification({
title: 'Error processing file',
text: error instanceof Error ? error.message : 'Failed to read the dropped file.',
type: 'error',
})
}
})
}
} catch (error) {
console.error('Failed to set up drag and drop listener:', error)
}
}
async function cleanupDragDropListener() {
if (unlisten.value) {
unlisten.value()
unlisten.value = undefined
}
}
async function processData(buffer: ArrayBuffer) {
emit('uploaded', buffer)
hide()
}
watch(modalVisible, (isVisible) => {
if (isVisible) {
setupDragDropListener()
} else {
cleanupDragDropListener()
}
})
onBeforeUnmount(() => {
cleanupDragDropListener()
})
defineExpose({ show, hide })
</script>

View File

@@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { import {
EyeIcon, EyeIcon,
@@ -9,14 +8,7 @@ import {
SpinnerIcon, SpinnerIcon,
StopCircleIcon, StopCircleIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { import { Avatar, ButtonStyled, commonMessages, OverflowMenu, SmartClickable } from '@modrinth/ui'
Avatar,
ButtonStyled,
commonMessages,
OverflowMenu,
SmartClickable,
useRelativeTime,
} from '@modrinth/ui'
import { useVIntl } from '@vintl/vintl' import { useVIntl } from '@vintl/vintl'
import { computed, nextTick, ref, onMounted, onUnmounted } from 'vue' import { computed, nextTick, ref, onMounted, onUnmounted } from 'vue'
import { showProfileInFolder } from '@/helpers/utils' import { showProfileInFolder } from '@/helpers/utils'
@@ -33,7 +25,6 @@ import { handleError } from '@/store/notifications'
import { process_listener } from '@/helpers/events' import { process_listener } from '@/helpers/events'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const router = useRouter() const router = useRouter()
@@ -43,7 +34,6 @@ const emit = defineEmits<{
const props = defineProps<{ const props = defineProps<{
instance: GameInstance instance: GameInstance
last_played: Dayjs
}>() }>()
const loadingModpack = ref(!!props.instance.linked_data) const loadingModpack = ref(!!props.instance.linked_data)
@@ -149,12 +139,12 @@ onUnmounted(() => {
: null : null
" "
class="w-fit shrink-0" class="w-fit shrink-0"
:class="{ 'cursor-help smart-clickable:allow-pointer-events': last_played }" :class="{ 'cursor-help smart-clickable:allow-pointer-events': instance.last_played }"
> >
<template v-if="last_played"> <template v-if="instance.last_played">
{{ {{
formatMessage(commonMessages.playedLabel, { formatMessage(commonMessages.playedLabel, {
time: formatRelativeTime(last_played.toISOString?.()), time: dayjs(instance.last_played).fromNow(),
}) })
}} }}
</template> </template>
@@ -163,7 +153,7 @@ onUnmounted(() => {
<span v-if="modpack" class="flex items-center gap-1 truncate text-secondary"> <span v-if="modpack" class="flex items-center gap-1 truncate text-secondary">
<router-link <router-link
class="inline-flex items-center gap-1 truncate hover:underline text-secondary smart-clickable:allow-pointer-events" class="inline-flex items-center gap-1 truncate hover:underline text-secondary"
:to="`/project/${modpack.id}`" :to="`/project/${modpack.id}`"
> >
<Avatar :src="modpack.icon_url" size="16px" class="shrink-0" /> <Avatar :src="modpack.icon_url" size="16px" class="shrink-0" />

View File

@@ -13,17 +13,16 @@ import {
import { HeadingLink, GAME_MODES } from '@modrinth/ui' import { HeadingLink, GAME_MODES } from '@modrinth/ui'
import WorldItem from '@/components/ui/world/WorldItem.vue' import WorldItem from '@/components/ui/world/WorldItem.vue'
import InstanceItem from '@/components/ui/world/InstanceItem.vue' import InstanceItem from '@/components/ui/world/InstanceItem.vue'
import { watch, onMounted, onUnmounted, ref, computed } from 'vue' import { watch, onMounted, onUnmounted, ref } from 'vue'
import type { Dayjs } from 'dayjs' import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { useTheming } from '@/store/theme.ts' import { useTheming } from '@/store/theme'
import { kill, run } from '@/helpers/profile' import { kill } from '@/helpers/profile'
import { handleError } from '@/store/notifications' import { handleError } from '@/store/notifications'
import { trackEvent } from '@/helpers/analytics' import { trackEvent } from '@/helpers/analytics'
import { process_listener, profile_listener } from '@/helpers/events' import { process_listener, profile_listener } from '@/helpers/events'
import { get_all } from '@/helpers/process' import { get_all } from '@/helpers/process'
import type { GameInstance } from '@/helpers/types' import type { GameInstance } from '@/helpers/types'
import { handleSevereError } from '@/store/error'
const props = defineProps<{ const props = defineProps<{
recentInstances: GameInstance[] recentInstances: GameInstance[]
@@ -55,9 +54,7 @@ type WorldJumpBackInItem = BaseJumpBackInItem & {
type JumpBackInItem = InstanceJumpBackInItem | WorldJumpBackInItem type JumpBackInItem = InstanceJumpBackInItem | WorldJumpBackInItem
const showWorlds = computed(() => theme.getFeatureFlag('worlds_in_home')) watch(props.recentInstances, async () => {
watch([() => props.recentInstances, () => showWorlds.value], async () => {
await populateJumpBackIn().catch(() => { await populateJumpBackIn().catch(() => {
console.error('Failed to populate jump back in') console.error('Failed to populate jump back in')
}) })
@@ -69,85 +66,80 @@ await populateJumpBackIn().catch(() => {
async function populateJumpBackIn() { async function populateJumpBackIn() {
console.info('Repopulating jump back in...') console.info('Repopulating jump back in...')
const worlds = await get_recent_worlds(MAX_JUMP_BACK_IN)
const worldItems: WorldJumpBackInItem[] = [] const worldItems: WorldJumpBackInItem[] = []
worlds.forEach((world) => {
const instance = props.recentInstances.find((instance) => instance.path === world.profile)
if (showWorlds.value) { if (!instance || !world.last_played) {
const worlds = await get_recent_worlds(MAX_JUMP_BACK_IN, ['normal', 'favorite']) return
}
worlds.forEach((world) => { worldItems.push({
const instance = props.recentInstances.find((instance) => instance.path === world.profile) type: 'world',
last_played: dayjs(world.last_played),
if (!instance || !world.last_played) { world: world,
return instance: instance,
}
worldItems.push({
type: 'world',
last_played: dayjs(world.last_played ?? 0),
world: world,
instance: instance,
})
}) })
})
const servers: { const servers: {
instancePath: string instancePath: string
address: string address: string
}[] = worldItems }[] = worldItems
.filter((item) => item.world.type === 'server' && item.instance) .filter((item) => item.world.type === 'server' && item.instance)
.map((item) => ({ .map((item) => ({
instancePath: item.instance.path, instancePath: item.instance.path,
address: (item.world as ServerWorld).address, address: (item.world as ServerWorld).address,
})) }))
// fetch protocol versions for all unique MC versions with server worlds // fetch protocol versions for all unique MC versions with server worlds
const uniqueServerInstances = new Set<string>(servers.map((x) => x.instancePath)) const uniqueServerInstances = new Set<string>(servers.map((x) => x.instancePath))
await Promise.all( await Promise.all(
[...uniqueServerInstances].map((path) => [...uniqueServerInstances].map((path) => {
get_profile_protocol_version(path) get_profile_protocol_version(path)
.then((protoVer) => (protocolVersions.value[path] = protoVer)) .then((protoVer) => (protocolVersions.value[path] = protoVer))
.catch(() => { .catch(() => {
console.error(`Failed to get profile protocol for: ${path} `) console.error(`Failed to get profile protocol for: ${path} `)
}), })
), }),
) )
// initialize server data // initialize server data
servers.forEach(({ address }) => { servers.forEach(({ address }) => {
if (!serverData.value[address]) { if (!serverData.value[address]) {
serverData.value[address] = { serverData.value[address] = {
refreshing: true, refreshing: true,
}
} }
}) }
})
// fetch each server's data // fetch each server's data
Promise.all( await Promise.all(
servers.map(({ instancePath, address }) => servers.map(({ instancePath, address }) =>
refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address), refreshServerData(serverData.value[address], protocolVersions.value[instancePath], address),
), ),
) )
}
const instanceItems: InstanceJumpBackInItem[] = [] const instanceItems: InstanceJumpBackInItem[] = []
for (const instance of props.recentInstances) { props.recentInstances.forEach((instance) => {
const worldItem = worldItems.find((item) => item.instance.path === instance.path) if (worldItems.some((item) => item.instance.path === instance.path) || !instance.last_played) {
if ((worldItem && worldItem.last_played.isAfter(TWO_WEEKS_AGO)) || !instance.last_played) { return
continue
} }
instanceItems.push({ instanceItems.push({
type: 'instance', type: 'instance',
last_played: dayjs(instance.last_played ?? 0), last_played: dayjs(instance.last_played),
instance: instance, instance: instance,
}) })
} })
const items: JumpBackInItem[] = [...worldItems, ...instanceItems] const items: JumpBackInItem[] = [...worldItems, ...instanceItems]
items.sort((a, b) => dayjs(b.last_played ?? 0).diff(dayjs(a.last_played ?? 0))) items.sort((a, b) => dayjs(b.last_played).diff(dayjs(a.last_played)))
jumpBackInItems.value = items jumpBackInItems.value = items.filter(
.filter((item, index) => index < MIN_JUMP_BACK_IN || item.last_played.isAfter(TWO_WEEKS_AGO)) (item, index) => index < MIN_JUMP_BACK_IN || item.last_played.isAfter(TWO_WEEKS_AGO),
.slice(0, MAX_JUMP_BACK_IN) )
} }
async function refreshServer(address: string, instancePath: string) { async function refreshServer(address: string, instancePath: string) {
@@ -163,18 +155,6 @@ async function joinWorld(world: WorldWithProfile) {
} }
} }
async function playInstance(instance: GameInstance) {
await run(instance.path)
.catch((err) => handleSevereError(err, { profilePath: instance.path }))
.finally(() => {
trackEvent('InstancePlay', {
loader: instance.loader,
game_version: instance.game_version,
source: 'WorldItem',
})
})
}
async function stopInstance(path: string) { async function stopInstance(path: string) {
await kill(path).catch(handleError) await kill(path).catch(handleError)
trackEvent('InstanceStop', { trackEvent('InstanceStop', {
@@ -229,7 +209,11 @@ onUnmounted(() => {
<template> <template>
<div v-if="jumpBackInItems.length > 0" class="flex flex-col gap-2"> <div v-if="jumpBackInItems.length > 0" class="flex flex-col gap-2">
<HeadingLink v-if="theme.getFeatureFlag('worlds_tab')" to="/worlds" class="mt-1"> <HeadingLink
v-if="(theme.featureFlags as Record<string, boolean>)['worlds_tab']"
to="/worlds"
class="mt-1"
>
Jump back in Jump back in
</HeadingLink> </HeadingLink>
<span <span
@@ -238,7 +222,7 @@ onUnmounted(() => {
> >
Jump back in Jump back in
</span> </span>
<div class="grid-when-huge flex flex-col w-full gap-2"> <div class="flex flex-col w-full gap-2">
<template <template
v-for="item in jumpBackInItems" v-for="item in jumpBackInItems"
:key="`${item.instance.path}-${item.type === 'world' ? getWorldIdentifier(item.world) : 'instance'}`" :key="`${item.instance.path}-${item.type === 'world' ? getWorldIdentifier(item.world) : 'instance'}`"
@@ -262,7 +246,7 @@ onUnmounted(() => {
:rendered-motd=" :rendered-motd="
item.world.type === 'server' ? serverData[item.world.address].renderedMotd : undefined item.world.type === 'server' ? serverData[item.world.address].renderedMotd : undefined
" "
:current-protocol="protocolVersions[item.instance.path]" :current-protocol="protocolVersions[item.instance.game_version]"
:game-mode=" :game-mode="
item.world.type === 'singleplayer' ? GAME_MODES[item.world.game_mode] : undefined item.world.type === 'singleplayer' ? GAME_MODES[item.world.game_mode] : undefined
" "
@@ -275,7 +259,6 @@ onUnmounted(() => {
? refreshServer(item.world.address, item.instance.path) ? refreshServer(item.world.address, item.instance.path)
: {} : {}
" "
@update="() => populateJumpBackIn()"
@play=" @play="
() => { () => {
currentProfile = item.instance.path currentProfile = item.instance.path
@@ -283,22 +266,10 @@ onUnmounted(() => {
joinWorld(item.world) joinWorld(item.world)
} }
" "
@play-instance="
() => {
currentProfile = item.instance.path
playInstance(item.instance)
}
"
@stop="() => stopInstance(item.instance.path)" @stop="() => stopInstance(item.instance.path)"
/> />
<InstanceItem v-else :instance="item.instance" :last_played="item.last_played" /> <InstanceItem v-else :instance="item.instance" />
</template> </template>
</div> </div>
</div> </div>
</template> </template>
<style scoped lang="scss">
.grid-when-huge {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(670px, 1fr));
}
</style>

View File

@@ -1,16 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import dayjs from 'dayjs' import dayjs from 'dayjs'
import type { ServerStatus, ServerWorld, SingleplayerWorld, World } from '@/helpers/worlds.ts' import type { ServerStatus, ServerWorld, World } from '@/helpers/worlds.ts'
import { set_world_display_status, getWorldIdentifier } from '@/helpers/worlds.ts' import { getWorldIdentifier, showWorldInFolder } from '@/helpers/worlds.ts'
import { formatNumber, getPingLevel } from '@modrinth/utils' import { formatNumber } from '@modrinth/utils'
import {
useRelativeTime,
Avatar,
ButtonStyled,
commonMessages,
OverflowMenu,
SmartClickable,
} from '@modrinth/ui'
import { import {
IssuesIcon, IssuesIcon,
EyeIcon, EyeIcon,
@@ -27,8 +19,8 @@ import {
TrashIcon, TrashIcon,
UpdatedIcon, UpdatedIcon,
UserIcon, UserIcon,
XIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { Avatar, ButtonStyled, commonMessages, OverflowMenu, SmartClickable } from '@modrinth/ui'
import type { MessageDescriptor } from '@vintl/vintl' import type { MessageDescriptor } from '@vintl/vintl'
import { defineMessages, useVIntl } from '@vintl/vintl' import { defineMessages, useVIntl } from '@vintl/vintl'
import type { Component } from 'vue' import type { Component } from 'vue'
@@ -39,13 +31,11 @@ import { useRouter } from 'vue-router'
import { Tooltip } from 'floating-vue' import { Tooltip } from 'floating-vue'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const router = useRouter() const router = useRouter()
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'play' | 'play-instance' | 'update' | 'stop' | 'refresh' | 'edit' | 'delete'): void (e: 'play' | 'stop' | 'refresh' | 'edit' | 'delete'): void
(e: 'open-folder', world: SingleplayerWorld): void
}>() }>()
const props = withDefaults( const props = withDefaults(
@@ -79,7 +69,6 @@ const props = withDefaults(
playingWorld: false, playingWorld: false,
startingInstance: false, startingInstance: false,
supportsQuickPlay: false, supportsQuickPlay: false,
currentProtocol: null,
refreshing: false, refreshing: false,
serverStatus: undefined, serverStatus: undefined,
@@ -105,6 +94,20 @@ const serverIncompatible = computed(
props.serverStatus.version.protocol !== props.currentProtocol, props.serverStatus.version.protocol !== props.currentProtocol,
) )
function getPingLevel(ping: number) {
if (ping < 150) {
return 5
} else if (ping < 300) {
return 4
} else if (ping < 600) {
return 3
} else if (ping < 1000) {
return 2
} else {
return 1
}
}
const locked = computed(() => props.world.type === 'singleplayer' && props.world.locked) const locked = computed(() => props.world.type === 'singleplayer' && props.world.locked)
const messages = defineMessages({ const messages = defineMessages({
@@ -140,18 +143,10 @@ const messages = defineMessages({
id: 'instance.worlds.play_anyway', id: 'instance.worlds.play_anyway',
defaultMessage: 'Play anyway', defaultMessage: 'Play anyway',
}, },
playInstance: {
id: 'instance.worlds.play_instance',
defaultMessage: 'Play instance',
},
worldInUse: { worldInUse: {
id: 'instance.worlds.world_in_use', id: 'instance.worlds.world_in_use',
defaultMessage: 'World is in use', defaultMessage: 'World is in use',
}, },
dontShowOnHome: {
id: 'instance.worlds.dont_show_on_home',
defaultMessage: `Don't show on Home`,
},
}) })
</script> </script>
<template> <template>
@@ -246,7 +241,7 @@ const messages = defineMessages({
<template v-if="world.last_played"> <template v-if="world.last_played">
{{ {{
formatMessage(commonMessages.playedLabel, { formatMessage(commonMessages.playedLabel, {
time: formatRelativeTime(dayjs(world.last_played).toISOString()), time: dayjs(world.last_played).fromNow(),
}) })
}} }}
</template> </template>
@@ -341,12 +336,6 @@ const messages = defineMessages({
<ButtonStyled circular type="transparent"> <ButtonStyled circular type="transparent">
<OverflowMenu <OverflowMenu
:options="[ :options="[
{
id: 'play-instance',
shown: !!instancePath,
disabled: playingInstance,
action: () => emit('play-instance'),
},
{ {
id: 'play-anyway', id: 'play-anyway',
shown: serverIncompatible && !playingInstance && supportsQuickPlay, shown: serverIncompatible && !playingInstance && supportsQuickPlay,
@@ -355,7 +344,7 @@ const messages = defineMessages({
{ {
id: 'open-instance', id: 'open-instance',
shown: !!instancePath, shown: !!instancePath,
action: () => router.push(encodeURI(`/instance/${instancePath}`)), action: () => router.push(encodeURI(`/instance/${instancePath}/worlds`)),
}, },
{ {
id: 'refresh', id: 'refresh',
@@ -377,25 +366,8 @@ const messages = defineMessages({
{ {
id: 'open-folder', id: 'open-folder',
shown: world.type === 'singleplayer', shown: world.type === 'singleplayer',
action: () => (world.type === 'singleplayer' ? emit('open-folder', world) : {}), action: () =>
}, world.type === 'singleplayer' ? showWorldInFolder(instancePath, world.path) : {},
{
divider: true,
shown: !!instancePath,
},
{
id: 'dont-show-on-home',
shown: !!instancePath,
action: () => {
set_world_display_status(
instancePath,
world.type,
getWorldIdentifier(world),
'hidden',
).then(() => {
emit('update')
})
},
}, },
{ {
divider: true, divider: true,
@@ -413,10 +385,6 @@ const messages = defineMessages({
]" ]"
> >
<MoreVerticalIcon aria-hidden="true" /> <MoreVerticalIcon aria-hidden="true" />
<template #play-instance>
<PlayIcon aria-hidden="true" />
{{ formatMessage(messages.playInstance) }}
</template>
<template #play-anyway> <template #play-anyway>
<PlayIcon aria-hidden="true" /> <PlayIcon aria-hidden="true" />
{{ formatMessage(messages.playAnyway) }} {{ formatMessage(messages.playAnyway) }}
@@ -438,10 +406,6 @@ const messages = defineMessages({
<template #refresh> <template #refresh>
<UpdatedIcon aria-hidden="true" /> {{ formatMessage(commonMessages.refreshButton) }} <UpdatedIcon aria-hidden="true" /> {{ formatMessage(commonMessages.refreshButton) }}
</template> </template>
<template #dont-show-on-home>
<XIcon aria-hidden="true" />
{{ formatMessage(messages.dontShowOnHome) }}
</template>
<template #delete> <template #delete>
<TrashIcon aria-hidden="true" /> <TrashIcon aria-hidden="true" />
{{ {{

View File

@@ -1,20 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { SaveIcon, XIcon } from '@modrinth/assets' import { SaveIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, commonMessages } from '@modrinth/ui' import { ButtonStyled, commonMessages } from '@modrinth/ui'
import { computed, ref } from 'vue' import { ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import type { GameInstance } from '@/helpers/types' import type { GameInstance } from '@/helpers/types'
import { import { edit_server_in_profile, type ServerWorld } from '@/helpers/worlds.ts'
type ServerPackStatus,
edit_server_in_profile,
type ServerWorld,
set_world_display_status,
type DisplayStatus,
} from '@/helpers/worlds.ts'
import { defineMessage, useVIntl } from '@vintl/vintl' import { defineMessage, useVIntl } from '@vintl/vintl'
import { handleError } from '@/store/notifications' import { handleError } from '@/store/notifications'
import ServerModalBody from '@/components/ui/world/modal/ServerModalBody.vue' import ServerModalBody from '@/components/ui/world/modal/ServerModalBody.vue'
import HideFromHomeOption from '@/components/ui/world/modal/HideFromHomeOption.vue'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
@@ -28,14 +21,10 @@ const props = defineProps<{
const modal = ref() const modal = ref()
const name = ref<string>('') const name = ref()
const address = ref<string>('') const address = ref()
const resourcePack = ref<ServerPackStatus>('enabled') const resourcePack = ref('enabled')
const index = ref<number>(0) const index = ref()
const displayStatus = ref<DisplayStatus>('normal')
const hideFromHome = ref(false)
const newDisplayStatus = computed(() => (hideFromHome.value ? 'hidden' : 'normal'))
async function saveServer() { async function saveServer() {
const serverName = name.value ? name.value : address.value const serverName = name.value ? name.value : address.value
@@ -47,23 +36,12 @@ async function saveServer() {
address.value, address.value,
resourcePackStatus, resourcePackStatus,
).catch(handleError) ).catch(handleError)
if (newDisplayStatus.value !== displayStatus.value) {
await set_world_display_status(
props.instance.path,
'server',
address.value,
newDisplayStatus.value,
).catch(handleError)
}
emit('submit', { emit('submit', {
name: serverName, name: serverName,
type: 'server', type: 'server',
index: index.value, index: index.value,
address: address.value, address: address.value,
pack_status: resourcePackStatus, pack_status: resourcePackStatus,
display_status: newDisplayStatus.value,
}) })
hide() hide()
} }
@@ -73,8 +51,6 @@ function show(server: ServerWorld) {
address.value = server.address address.value = server.address
resourcePack.value = server.pack_status resourcePack.value = server.pack_status
index.value = server.index index.value = server.index
displayStatus.value = server.display_status
hideFromHome.value = server.display_status === 'hidden'
modal.value.show() modal.value.show()
} }
@@ -99,7 +75,6 @@ const titleMessage = defineMessage({
v-model:address="address" v-model:address="address"
v-model:resource-pack="resourcePack" v-model:resource-pack="resourcePack"
/> />
<HideFromHomeOption v-model="hideFromHome" class="mt-3" />
<div class="flex gap-2 mt-4"> <div class="flex gap-2 mt-4">
<ButtonStyled color="brand"> <ButtonStyled color="brand">
<button :disabled="!address" @click="saveServer"> <button :disabled="!address" @click="saveServer">

View File

@@ -1,19 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { ChevronRightIcon, SaveIcon, XIcon, UndoIcon } from '@modrinth/assets' import { ChevronRightIcon, SaveIcon, XIcon, UndoIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled, commonMessages } from '@modrinth/ui' import { Avatar, ButtonStyled, commonMessages } from '@modrinth/ui'
import { computed, ref } from 'vue' import { ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import type { GameInstance } from '@/helpers/types' import type { GameInstance } from '@/helpers/types'
import type { DisplayStatus, SingleplayerWorld } from '@/helpers/worlds.ts' import type { SingleplayerWorld } from '@/helpers/worlds.ts'
import { set_world_display_status, rename_world, reset_world_icon } from '@/helpers/worlds.ts' import { rename_world, reset_world_icon } from '@/helpers/worlds.ts'
import { defineMessages, useVIntl } from '@vintl/vintl' import { defineMessages, useVIntl } from '@vintl/vintl'
import { handleError } from '@/store/notifications' import { handleError } from '@/store/notifications'
import HideFromHomeOption from '@/components/ui/world/modal/HideFromHomeOption.vue'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const emit = defineEmits<{ const emit = defineEmits<{
submit: [path: string, name: string, removeIcon: boolean, displayStatus: DisplayStatus] submit: [path: string, name: string, removeIcon: boolean]
}>() }>()
const props = defineProps<{ const props = defineProps<{
@@ -26,10 +25,6 @@ const icon = ref()
const name = ref() const name = ref()
const path = ref() const path = ref()
const removeIcon = ref(false) const removeIcon = ref(false)
const displayStatus = ref<DisplayStatus>('normal')
const hideFromHome = ref(false)
const newDisplayStatus = computed(() => (hideFromHome.value ? 'hidden' : 'normal'))
async function saveWorld() { async function saveWorld() {
await rename_world(props.instance.path, path.value, name.value).catch(handleError) await rename_world(props.instance.path, path.value, name.value).catch(handleError)
@@ -37,16 +32,8 @@ async function saveWorld() {
if (removeIcon.value) { if (removeIcon.value) {
await reset_world_icon(props.instance.path, path.value).catch(handleError) await reset_world_icon(props.instance.path, path.value).catch(handleError)
} }
if (newDisplayStatus.value !== displayStatus.value) {
await set_world_display_status(
props.instance.path,
'singleplayer',
path.value,
newDisplayStatus.value,
)
}
emit('submit', path.value, name.value, removeIcon.value, newDisplayStatus.value) emit('submit', path.value, name.value, removeIcon.value)
hide() hide()
} }
@@ -54,9 +41,6 @@ function show(world: SingleplayerWorld) {
name.value = world.name name.value = world.name
path.value = world.path path.value = world.path
icon.value = world.icon icon.value = world.icon
displayStatus.value = world.display_status
hideFromHome.value = world.display_status === 'hidden'
removeIcon.value = false
modal.value.show() modal.value.show()
} }
@@ -103,7 +87,6 @@ const messages = defineMessages({
class="w-full" class="w-full"
autocomplete="off" autocomplete="off"
/> />
<HideFromHomeOption v-model="hideFromHome" class="mt-3" />
</div> </div>
<div class="flex gap-2 mt-4"> <div class="flex gap-2 mt-4">
<ButtonStyled color="brand"> <ButtonStyled color="brand">

View File

@@ -1,18 +0,0 @@
<script setup lang="ts">
import { defineMessage, useVIntl } from '@vintl/vintl'
import { computed } from 'vue'
import { Checkbox } from '@modrinth/ui'
const { formatMessage } = useVIntl()
const value = defineModel<boolean>({ required: true })
const labelMessage = defineMessage({
id: 'instance.edit-world.hide-from-home',
defaultMessage: `Hide from the Home page`,
})
const label = computed(() => formatMessage(labelMessage))
</script>
<template>
<Checkbox v-model="value" :label="label" />
</template>

View File

@@ -1,12 +1,12 @@
import { fetch } from '@tauri-apps/plugin-http' import { ofetch } from 'ofetch'
import { handleError } from '@/store/state.js' import { handleError } from '@/store/state.js'
import { getVersion } from '@tauri-apps/api/app' import { getVersion } from '@tauri-apps/api/app'
export const useFetch = async (url, item, isSilent) => { export const useFetch = async (url, item, isSilent) => {
try { try {
const version = await getVersion() const version = await getVersion()
return await fetch(url, {
method: 'GET', return await ofetch(url, {
headers: { 'User-Agent': `modrinth/theseus/${version} (support@modrinth.com)` }, headers: { 'User-Agent': `modrinth/theseus/${version} (support@modrinth.com)` },
}) })
} catch (err) { } catch (err) {

View File

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

View File

@@ -7,13 +7,7 @@ import { invoke } from '@tauri-apps/api/core'
import { create } from './profile' import { create } from './profile'
// Installs pack from a version ID // Installs pack from a version ID
export async function create_profile_and_install( export async function create_profile_and_install(projectId, versionId, packTitle, iconUrl) {
projectId,
versionId,
packTitle,
iconUrl,
createInstanceCallback = () => {},
) {
const location = { const location = {
type: 'fromVersionId', type: 'fromVersionId',
project_id: projectId, project_id: projectId,
@@ -30,7 +24,6 @@ export async function create_profile_and_install(
null, null,
true, true,
) )
createInstanceCallback(profile)
return await invoke('plugin:pack|pack_install', { location, profile }) return await invoke('plugin:pack|pack_install', { location, profile })
} }

View File

@@ -1,354 +0,0 @@
import * as THREE from 'three'
import type { Skin, Cape } from '../skins'
import { get_normalized_skin_texture, determineModelType } from '../skins'
import { reactive } from 'vue'
import { setupSkinModel, disposeCaches } from '@modrinth/utils'
import { skinPreviewStorage } from '../storage/skin-preview-storage'
import { CapeModel, ClassicPlayerModel, SlimPlayerModel } from '@modrinth/assets'
export interface RenderResult {
forwards: string
backwards: string
}
class BatchSkinRenderer {
private renderer: THREE.WebGLRenderer
private readonly scene: THREE.Scene
private readonly camera: THREE.PerspectiveCamera
private currentModel: THREE.Group | null = null
constructor(width: number = 360, height: number = 504) {
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
this.renderer = new THREE.WebGLRenderer({
canvas: canvas,
antialias: true,
alpha: true,
preserveDrawingBuffer: true,
})
this.renderer.outputColorSpace = THREE.SRGBColorSpace
this.renderer.toneMapping = THREE.NoToneMapping
this.renderer.toneMappingExposure = 10.0
this.renderer.setClearColor(0x000000, 0)
this.renderer.setSize(width, height)
this.scene = new THREE.Scene()
this.camera = new THREE.PerspectiveCamera(20, width / height, 0.4, 1000)
const ambientLight = new THREE.AmbientLight(0xffffff, 2)
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2)
directionalLight.castShadow = true
directionalLight.position.set(2, 4, 3)
this.scene.add(ambientLight)
this.scene.add(directionalLight)
}
public async renderSkin(
textureUrl: string,
modelUrl: string,
capeUrl?: string,
capeModelUrl?: string,
): Promise<RenderResult> {
await this.setupModel(modelUrl, textureUrl, capeModelUrl, capeUrl)
const headPart = this.currentModel!.getObjectByName('Head')
let lookAtTarget: [number, number, number]
if (headPart) {
const headPosition = new THREE.Vector3()
headPart.getWorldPosition(headPosition)
lookAtTarget = [headPosition.x, headPosition.y - 0.3, headPosition.z]
} else {
throw new Error("Failed to find 'Head' object in model.")
}
const frontCameraPos: [number, number, number] = [-1.3, 1, 6.3]
const backCameraPos: [number, number, number] = [-1.3, 1, -2.5]
const forwards = await this.renderView(frontCameraPos, lookAtTarget)
const backwards = await this.renderView(backCameraPos, lookAtTarget)
return { forwards, backwards }
}
private async renderView(
cameraPosition: [number, number, number],
lookAtPosition: [number, number, number],
): Promise<string> {
this.camera.position.set(...cameraPosition)
this.camera.lookAt(...lookAtPosition)
this.renderer.render(this.scene, this.camera)
return new Promise<string>((resolve, reject) => {
this.renderer.domElement.toBlob((blob) => {
if (blob) {
const url = URL.createObjectURL(blob)
resolve(url)
} else {
reject(new Error('Failed to create blob from canvas'))
}
}, 'image/png')
})
}
private async setupModel(
modelUrl: string,
textureUrl: string,
capeModelUrl?: string,
capeUrl?: string,
): Promise<void> {
if (this.currentModel) {
this.scene.remove(this.currentModel)
}
const { model } = await setupSkinModel(modelUrl, textureUrl, capeModelUrl, capeUrl)
const group = new THREE.Group()
group.add(model)
group.position.set(0, 0.3, 1.95)
group.scale.set(0.8, 0.8, 0.8)
this.scene.add(group)
this.currentModel = group
}
public dispose(): void {
this.renderer.dispose()
disposeCaches()
}
}
function getModelUrlForVariant(variant: string): string {
switch (variant) {
case 'SLIM':
return SlimPlayerModel
case 'CLASSIC':
case 'UNKNOWN':
default:
return ClassicPlayerModel
}
}
export const map = reactive(new Map<string, RenderResult>())
export const headMap = reactive(new Map<string, string>())
const DEBUG_MODE = false
export async function cleanupUnusedPreviews(skins: Skin[]): Promise<void> {
const validKeys = new Set<string>()
const validHeadKeys = new Set<string>()
for (const skin of skins) {
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
const headKey = `${skin.texture_key}-head`
validKeys.add(key)
validHeadKeys.add(headKey)
}
try {
await skinPreviewStorage.cleanupInvalidKeys(validKeys)
await skinPreviewStorage.cleanupInvalidKeys(validHeadKeys)
} catch (error) {
console.warn('Failed to cleanup unused skin previews:', error)
}
}
export async function generatePlayerHeadBlob(skinUrl: string, size: number = 64): Promise<Blob> {
return new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
try {
const sourceCanvas = document.createElement('canvas')
const sourceCtx = sourceCanvas.getContext('2d')
if (!sourceCtx) {
throw new Error('Could not get 2D context from source canvas')
}
sourceCanvas.width = img.width
sourceCanvas.height = img.height
sourceCtx.drawImage(img, 0, 0)
const outputCanvas = document.createElement('canvas')
const outputCtx = outputCanvas.getContext('2d')
if (!outputCtx) {
throw new Error('Could not get 2D context from output canvas')
}
outputCanvas.width = size
outputCanvas.height = size
outputCtx.imageSmoothingEnabled = false
const headImageData = sourceCtx.getImageData(8, 8, 8, 8)
const headCanvas = document.createElement('canvas')
const headCtx = headCanvas.getContext('2d')
if (!headCtx) {
throw new Error('Could not get 2D context from head canvas')
}
headCanvas.width = 8
headCanvas.height = 8
headCtx.putImageData(headImageData, 0, 0)
outputCtx.drawImage(headCanvas, 0, 0, 8, 8, 0, 0, size, size)
const hatImageData = sourceCtx.getImageData(40, 8, 8, 8)
const hatCanvas = document.createElement('canvas')
const hatCtx = hatCanvas.getContext('2d')
if (!hatCtx) {
throw new Error('Could not get 2D context from hat canvas')
}
hatCanvas.width = 8
hatCanvas.height = 8
hatCtx.putImageData(hatImageData, 0, 0)
const hatPixels = hatImageData.data
let hasHat = false
for (let i = 3; i < hatPixels.length; i += 4) {
if (hatPixels[i] > 0) {
hasHat = true
break
}
}
if (hasHat) {
outputCtx.drawImage(hatCanvas, 0, 0, 8, 8, 0, 0, size, size)
}
outputCanvas.toBlob((blob) => {
if (blob) {
resolve(blob)
} else {
reject(new Error('Failed to create blob from canvas'))
}
}, 'image/png')
} catch (error) {
reject(error)
}
}
img.onerror = () => {
reject(new Error('Failed to load skin texture image'))
}
img.src = skinUrl
})
}
async function generateHeadRender(skin: Skin): Promise<string> {
const headKey = `${skin.texture_key}-head`
if (headMap.has(headKey)) {
if (DEBUG_MODE) {
const url = headMap.get(headKey)!
URL.revokeObjectURL(url)
headMap.delete(headKey)
} else {
return headMap.get(headKey)!
}
}
try {
const cached = await skinPreviewStorage.retrieve(headKey)
if (cached && typeof cached === 'string') {
headMap.set(headKey, cached)
return cached
}
} catch (error) {
console.warn('Failed to retrieve cached head render:', error)
}
const skinUrl = await get_normalized_skin_texture(skin)
const headBlob = await generatePlayerHeadBlob(skinUrl, 64)
const headUrl = URL.createObjectURL(headBlob)
headMap.set(headKey, headUrl)
try {
// @ts-expect-error - skinPreviewStorage.store expects a RenderResult, but we are storing a string url.
await skinPreviewStorage.store(headKey, headUrl)
} catch (error) {
console.warn('Failed to store head render in persistent storage:', error)
}
return headUrl
}
export async function getPlayerHeadUrl(skin: Skin): Promise<string> {
return await generateHeadRender(skin)
}
export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promise<void> {
const renderer = new BatchSkinRenderer()
try {
for (const skin of skins) {
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
if (map.has(key)) {
if (DEBUG_MODE) {
const result = map.get(key)!
URL.revokeObjectURL(result.forwards)
URL.revokeObjectURL(result.backwards)
map.delete(key)
} else continue
}
try {
const cached = await skinPreviewStorage.retrieve(key)
if (cached) {
map.set(key, cached)
continue
}
} catch (error) {
console.warn('Failed to retrieve cached skin preview:', error)
}
let variant = skin.variant
if (variant === 'UNKNOWN') {
try {
variant = await determineModelType(skin.texture)
} catch (error) {
console.error(`Failed to determine model type for skin ${key}:`, error)
variant = 'CLASSIC'
}
}
const modelUrl = getModelUrlForVariant(variant)
const cape: Cape | undefined = capes.find((_cape) => _cape.id === skin.cape_id)
const renderResult = await renderer.renderSkin(
await get_normalized_skin_texture(skin),
modelUrl,
cape?.texture,
CapeModel,
)
map.set(key, renderResult)
try {
await skinPreviewStorage.store(key, renderResult)
} catch (error) {
console.warn('Failed to store skin preview in persistent storage:', error)
}
await generateHeadRender(skin)
}
} finally {
renderer.dispose()
await cleanupUnusedPreviews(skins)
}
}

View File

@@ -0,0 +1,43 @@
/**
* All theseus API calls return serialized values (both return values and errors);
* So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
* and deserialized into a usable JS object.
*/
import { invoke } from '@tauri-apps/api/core'
// Settings object
/*
Settings {
"memory": MemorySettings,
"game_resolution": [int int],
"custom_java_args": [String ...],
"custom_env_args" : [(string, string) ... ]>,
"java_globals": Hash of (string, Path),
"default_user": Uuid string (can be null),
"hooks": Hooks,
"max_concurrent_downloads": uint,
"version": u32,
"collapsed_navigation": bool,
}
Memorysettings {
"min": u32, can be null,
"max": u32,
}
*/
// Get full settings object
export async function get() {
return await invoke('plugin:settings|settings_get')
}
// Set full settings object
export async function set(settings) {
return await invoke('plugin:settings|settings_set', { settings })
}
export async function cancel_directory_change() {
return await invoke('plugin:settings|cancel_directory_change')
}

View File

@@ -1,79 +0,0 @@
/**
* All theseus API calls return serialized values (both return values and errors);
* So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
* and deserialized into a usable JS object.
*/
import { invoke } from '@tauri-apps/api/core'
import type { ColorTheme, FeatureFlag } from '@/store/theme.ts'
import type { Hooks, MemorySettings, WindowSize } from '@/helpers/types'
// Settings object
/*
Settings {
"memory": MemorySettings,
"game_resolution": [int int],
"custom_java_args": [String ...],
"custom_env_args" : [(string, string) ... ]>,
"java_globals": Hash of (string, Path),
"default_user": Uuid string (can be null),
"hooks": Hooks,
"max_concurrent_downloads": uint,
"version": u32,
"collapsed_navigation": bool,
}
Memorysettings {
"min": u32, can be null,
"max": u32,
}
*/
export type AppSettings = {
max_concurrent_downloads: number
max_concurrent_writes: number
theme: ColorTheme
default_page: 'home' | 'library'
collapsed_navigation: boolean
hide_nametag_skins_page: boolean
advanced_rendering: boolean
native_decorations: boolean
toggle_sidebar: boolean
telemetry: boolean
discord_rpc: boolean
personalized_ads: boolean
onboarded: boolean
extra_launch_args: string[]
custom_env_vars: [string, string][]
memory: MemorySettings
force_fullscreen: boolean
game_resolution: WindowSize
hide_on_process_start: boolean
hooks: Hooks
custom_dir?: string | null
prev_custom_dir?: string | null
migrated: boolean
developer_mode: boolean
feature_flags: Record<FeatureFlag, boolean>
}
// Get full settings object
export async function get() {
return (await invoke('plugin:settings|settings_get')) as AppSettings
}
// Set full settings object
export async function set(settings: AppSettings) {
return await invoke('plugin:settings|settings_set', { settings })
}
export async function cancel_directory_change(): Promise<void> {
return await invoke('plugin:settings|cancel_directory_change')
}

View File

@@ -1,167 +0,0 @@
import { invoke } from '@tauri-apps/api/core'
import { handleError } from '@/store/notifications'
import { arrayBufferToBase64 } from '@modrinth/utils'
export interface Cape {
id: string
name: string
texture: string
is_default: boolean
is_equipped: boolean
}
export type SkinModel = 'CLASSIC' | 'SLIM' | 'UNKNOWN'
export type SkinSource = 'default' | 'custom_external' | 'custom'
export interface Skin {
texture_key: string
name?: string
variant: SkinModel
cape_id?: string
texture: string
source: SkinSource
is_equipped: boolean
}
export const DEFAULT_MODEL_SORTING = ['Steve', 'Alex'] as string[]
export const DEFAULT_MODELS: Record<string, SkinModel> = {
Steve: 'CLASSIC',
Alex: 'SLIM',
Zuri: 'CLASSIC',
Sunny: 'CLASSIC',
Noor: 'SLIM',
Makena: 'SLIM',
Kai: 'CLASSIC',
Efe: 'SLIM',
Ari: 'CLASSIC',
}
export function filterSavedSkins(list: Skin[]) {
const customSkins = list.filter((s) => s.source !== 'default')
fixUnknownSkins(customSkins).catch(handleError)
return customSkins
}
export async function determineModelType(texture: string): Promise<'SLIM' | 'CLASSIC'> {
return new Promise((resolve, reject) => {
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')
if (!context) {
return reject(new Error('Failed to create canvas rendering context.'))
}
const image = new Image()
image.crossOrigin = 'anonymous'
image.src = texture
image.onload = () => {
canvas.width = image.width
canvas.height = image.height
context.drawImage(image, 0, 0)
const armX = 44
const armY = 16
const armWidth = 4
const armHeight = 12
const imageData = context.getImageData(armX, armY, armWidth, armHeight).data
for (let y = 0; y < armHeight; y++) {
const alphaIndex = (3 + y * armWidth) * 4 + 3
if (imageData[alphaIndex] !== 0) {
resolve('CLASSIC')
return
}
}
canvas.remove()
resolve('SLIM')
}
image.onerror = () => {
canvas.remove()
reject(new Error('Failed to load the image.'))
}
})
}
export async function fixUnknownSkins(list: Skin[]) {
const unknownSkins = list.filter((s) => s.variant === 'UNKNOWN')
for (const unknownSkin of unknownSkins) {
unknownSkin.variant = await determineModelType(unknownSkin.texture)
}
}
export function filterDefaultSkins(list: Skin[]) {
return list
.filter(
(s) =>
s.source === 'default' &&
(!s.name || !(s.name in DEFAULT_MODELS) || s.variant === DEFAULT_MODELS[s.name]),
)
.sort((a, b) => {
const aIndex = a.name ? DEFAULT_MODEL_SORTING.indexOf(a.name) : -1
const bIndex = b.name ? DEFAULT_MODEL_SORTING.indexOf(b.name) : -1
return (aIndex === -1 ? Infinity : aIndex) - (bIndex === -1 ? Infinity : bIndex)
})
}
export async function get_available_capes(): Promise<Cape[]> {
return invoke('plugin:minecraft-skins|get_available_capes', {})
}
export async function get_available_skins(): Promise<Skin[]> {
return invoke('plugin:minecraft-skins|get_available_skins', {})
}
export async function add_and_equip_custom_skin(
textureBlob: Uint8Array,
variant: SkinModel,
capeOverride?: Cape,
): Promise<void> {
await invoke('plugin:minecraft-skins|add_and_equip_custom_skin', {
textureBlob,
variant,
capeOverride,
})
}
export async function set_default_cape(cape?: Cape): Promise<void> {
await invoke('plugin:minecraft-skins|set_default_cape', {
cape,
})
}
export async function equip_skin(skin: Skin): Promise<void> {
await invoke('plugin:minecraft-skins|equip_skin', {
skin,
})
}
export async function remove_custom_skin(skin: Skin): Promise<void> {
await invoke('plugin:minecraft-skins|remove_custom_skin', {
skin,
})
}
export async function get_normalized_skin_texture(skin: Skin): Promise<string> {
const data = await normalize_skin_texture(skin.texture)
const base64 = arrayBufferToBase64(data)
return `data:image/png;base64,${base64}`
}
export async function normalize_skin_texture(texture: Uint8Array | string): Promise<Uint8Array> {
return await invoke('plugin:minecraft-skins|normalize_skin_texture', { texture })
}
export async function unequip_skin(): Promise<void> {
await invoke('plugin:minecraft-skins|unequip_skin')
}
export async function get_dragged_skin_data(path: string): Promise<Uint8Array> {
const data = await invoke('plugin:minecraft-skins|get_dragged_skin_data', { path })
return new Uint8Array(data)
}

View File

@@ -1,118 +0,0 @@
import type { RenderResult } from '../rendering/batch-skin-renderer'
interface StoredPreview {
forwards: Blob
backwards: Blob
timestamp: number
}
export class SkinPreviewStorage {
private dbName = 'skin-previews'
private version = 1
private db: IDBDatabase | null = null
async init(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version)
request.onerror = () => reject(request.error)
request.onsuccess = () => {
this.db = request.result
resolve()
}
request.onupgradeneeded = () => {
const db = request.result
if (!db.objectStoreNames.contains('previews')) {
db.createObjectStore('previews')
}
}
})
}
async store(key: string, result: RenderResult): Promise<void> {
if (!this.db) await this.init()
const forwardsBlob = await fetch(result.forwards).then((r) => r.blob())
const backwardsBlob = await fetch(result.backwards).then((r) => r.blob())
const transaction = this.db!.transaction(['previews'], 'readwrite')
const store = transaction.objectStore('previews')
const storedPreview: StoredPreview = {
forwards: forwardsBlob,
backwards: backwardsBlob,
timestamp: Date.now(),
}
return new Promise((resolve, reject) => {
const request = store.put(storedPreview, key)
request.onsuccess = () => resolve()
request.onerror = () => reject(request.error)
})
}
async retrieve(key: string): Promise<RenderResult | null> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['previews'], 'readonly')
const store = transaction.objectStore('previews')
return new Promise((resolve, reject) => {
const request = store.get(key)
request.onsuccess = () => {
const result = request.result as StoredPreview | undefined
if (!result) {
resolve(null)
return
}
const forwards = URL.createObjectURL(result.forwards)
const backwards = URL.createObjectURL(result.backwards)
resolve({ forwards, backwards })
}
request.onerror = () => reject(request.error)
})
}
async cleanupInvalidKeys(validKeys: Set<string>): Promise<number> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['previews'], 'readwrite')
const store = transaction.objectStore('previews')
let deletedCount = 0
return new Promise((resolve, reject) => {
const request = store.openCursor()
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result
if (cursor) {
const key = cursor.primaryKey as string
if (!validKeys.has(key)) {
const deleteRequest = cursor.delete()
deleteRequest.onsuccess = () => {
deletedCount++
}
deleteRequest.onerror = () => {
console.warn('Failed to delete invalid entry:', key)
}
}
cursor.continue()
} else {
resolve(deletedCount)
}
}
request.onerror = () => reject(request.error)
})
}
}
export const skinPreviewStorage = new SkinPreviewStorage()

View File

@@ -48,32 +48,6 @@ type LinkedData = {
type InstanceLoader = 'vanilla' | 'forge' | 'fabric' | 'quilt' | 'neoforge' type InstanceLoader = 'vanilla' | 'forge' | 'fabric' | 'quilt' | 'neoforge'
type ContentFile = {
hash: string
file_name: string
size: number
metadata?: FileMetadata
update_version_id?: string
project_type: ContentFileProjectType
}
type FileMetadata = {
project_id: string
version_id: string
}
type ContentFileProjectType = 'mod' | 'datapack' | 'resourcepack' | 'shaderpack'
type CacheBehaviour =
// Serve expired data. If fetch fails / launcher is offline, errors are ignored
| 'stale_while_revalidate_skip_offline'
// Serve expired data, revalidate in background
| 'stale_while_revalidate'
// Must revalidate if data is expired
| 'must_revalidate'
// Ignore cache- always fetch updated data from origin
| 'bypass'
type MemorySettings = { type MemorySettings = {
maximum: number maximum: number
} }
@@ -114,7 +88,6 @@ type AppSettings = {
collapsed_navigation: boolean collapsed_navigation: boolean
advanced_rendering: boolean advanced_rendering: boolean
native_decorations: boolean native_decorations: boolean
worlds_in_home: boolean
telemetry: boolean telemetry: boolean
discord_rpc: boolean discord_rpc: boolean

View File

@@ -9,13 +9,8 @@ type BaseWorld = {
name: string name: string
last_played?: string last_played?: string
icon?: string icon?: string
display_status: DisplayStatus
type: WorldType
} }
export type WorldType = 'singleplayer' | 'server'
export type DisplayStatus = 'normal' | 'hidden' | 'favorite'
export type SingleplayerWorld = BaseWorld & { export type SingleplayerWorld = BaseWorld & {
type: 'singleplayer' type: 'singleplayer'
path: string path: string
@@ -75,11 +70,8 @@ export type ServerData = {
renderedMotd?: string renderedMotd?: string
} }
export async function get_recent_worlds( export async function get_recent_worlds(limit: number): Promise<WorldWithProfile[]> {
limit: number, return await invoke('plugin:worlds|get_recent_worlds', { limit })
displayStatuses?: DisplayStatus[],
): Promise<WorldWithProfile[]> {
return await invoke('plugin:worlds|get_recent_worlds', { limit, displayStatuses })
} }
export async function get_profile_worlds(path: string): Promise<World[]> { export async function get_profile_worlds(path: string): Promise<World[]> {
@@ -93,20 +85,6 @@ export async function get_singleplayer_world(
return await invoke('plugin:worlds|get_singleplayer_world', { instance, world }) return await invoke('plugin:worlds|get_singleplayer_world', { instance, world })
} }
export async function set_world_display_status(
instance: string,
worldType: WorldType,
worldId: string,
displayStatus: DisplayStatus,
): Promise<void> {
return await invoke('plugin:worlds|set_world_display_status', {
instance,
worldType,
worldId,
displayStatus,
})
}
export async function rename_world( export async function rename_world(
instance: string, instance: string,
world: string, world: string,
@@ -252,14 +230,12 @@ export async function refreshServers(
export async function refreshWorld(worlds: World[], instancePath: string, worldPath: string) { export async function refreshWorld(worlds: World[], instancePath: string, worldPath: string) {
const index = worlds.findIndex((w) => w.type === 'singleplayer' && w.path === worldPath) const index = worlds.findIndex((w) => w.type === 'singleplayer' && w.path === worldPath)
const newWorld = await get_singleplayer_world(instancePath, worldPath)
if (index !== -1) { if (index !== -1) {
worlds[index] = newWorld worlds[index] = await get_singleplayer_world(instancePath, worldPath)
sortWorlds(worlds)
} else { } else {
console.info(`Adding new world at path: ${worldPath}.`) console.error(`Error refreshing world, could not find world at path ${worldPath}.`)
worlds.push(newWorld)
} }
sortWorlds(worlds)
} }
export async function handleDefaultProfileUpdateEvent( export async function handleDefaultProfileUpdateEvent(

View File

@@ -41,9 +41,6 @@
"instance.edit-server.title": { "instance.edit-server.title": {
"message": "Edit server" "message": "Edit server"
}, },
"instance.edit-world.hide-from-home": {
"message": "Hide from the Home page"
},
"instance.edit-world.name": { "instance.edit-world.name": {
"message": "Name" "message": "Name"
}, },
@@ -365,9 +362,6 @@
"instance.worlds.copy_address": { "instance.worlds.copy_address": {
"message": "Copy address" "message": "Copy address"
}, },
"instance.worlds.dont_show_on_home": {
"message": "Don't show on Home"
},
"instance.worlds.filter.available": { "instance.worlds.filter.available": {
"message": "Available" "message": "Available"
}, },
@@ -383,9 +377,6 @@
"instance.worlds.play_anyway": { "instance.worlds.play_anyway": {
"message": "Play anyway" "message": "Play anyway"
}, },
"instance.worlds.play_instance": {
"message": "Play instance"
},
"instance.worlds.type.server": { "instance.worlds.type.server": {
"message": "Server" "message": "Server"
}, },

View File

@@ -220,7 +220,6 @@ async function refreshSearch() {
} }
} }
results.value = rawResults.result results.value = rawResults.result
currentPage.value = Math.max(1, Math.min(pageCount.value, currentPage.value))
const persistentParams: LocationQuery = {} const persistentParams: LocationQuery = {}

View File

@@ -10,7 +10,6 @@ import dayjs from 'dayjs'
import { get_search_results } from '@/helpers/cache.js' import { get_search_results } from '@/helpers/cache.js'
import type { SearchResult } from '@modrinth/utils' import type { SearchResult } from '@modrinth/utils'
import RecentWorldsList from '@/components/ui/world/RecentWorldsList.vue' import RecentWorldsList from '@/components/ui/world/RecentWorldsList.vue'
import type { GameInstance } from '@/helpers/types'
const route = useRoute() const route = useRoute()
const breadcrumbs = useBreadcrumbs() const breadcrumbs = useBreadcrumbs()
@@ -83,15 +82,13 @@ async function refreshFeaturedProjects() {
await fetchInstances() await fetchInstances()
await refreshFeaturedProjects() await refreshFeaturedProjects()
const unlistenProfile = await profile_listener( const unlistenProfile = await profile_listener(async (e) => {
async (e: { event: string; profile_path_id: string }) => { await fetchInstances()
await fetchInstances()
if (e.event === 'added' || e.event === 'created' || e.event === 'removed') { if (e.event === 'added' || e.event === 'created' || e.event === 'removed') {
await refreshFeaturedProjects() await refreshFeaturedProjects()
} }
}, })
)
onUnmounted(() => { onUnmounted(() => {
unlistenProfile() unlistenProfile()
@@ -100,8 +97,8 @@ onUnmounted(() => {
<template> <template>
<div class="p-6 flex flex-col gap-2"> <div class="p-6 flex flex-col gap-2">
<h1 v-if="recentInstances?.length > 0" class="m-0 text-2xl font-extrabold">Welcome back!</h1> <h1 v-if="recentInstances" class="m-0 text-2xl">Welcome back!</h1>
<h1 v-else class="m-0 text-2xl font-extrabold">Welcome to Modrinth App!</h1> <h1 v-else class="m-0 text-2xl">Welcome to Modrinth App!</h1>
<RecentWorldsList :recent-instances="recentInstances" /> <RecentWorldsList :recent-instances="recentInstances" />
<RowDisplay <RowDisplay
v-if="hasFeaturedProjects" v-if="hasFeaturedProjects"

View File

@@ -1,521 +0,0 @@
<script setup lang="ts">
import {
EditIcon,
ExcitedRinthbot,
LogInIcon,
PlusIcon,
SpinnerIcon,
TrashIcon,
UpdatedIcon,
} from '@modrinth/assets'
import {
Button,
ButtonStyled,
ConfirmModal,
SkinButton,
SkinLikeTextButton,
SkinPreviewRenderer,
} from '@modrinth/ui'
import { computedAsync } from '@vueuse/core'
import type { Ref } from 'vue'
import { computed, inject, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'
import EditSkinModal from '@/components/ui/skin/EditSkinModal.vue'
import SelectCapeModal from '@/components/ui/skin/SelectCapeModal.vue'
import UploadSkinModal from '@/components/ui/skin/UploadSkinModal.vue'
import { handleError, useNotifications } from '@/store/notifications'
import type { Cape, Skin } from '@/helpers/skins.ts'
import {
normalize_skin_texture,
equip_skin,
filterDefaultSkins,
filterSavedSkins,
get_available_capes,
get_available_skins,
get_normalized_skin_texture,
remove_custom_skin,
set_default_cape,
} from '@/helpers/skins.ts'
import { get as getSettings } from '@/helpers/settings.ts'
import { get_default_user, login as login_flow, users } from '@/helpers/auth'
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
import { generateSkinPreviews, map } from '@/helpers/rendering/batch-skin-renderer.ts'
import { handleSevereError } from '@/store/error'
import { trackEvent } from '@/helpers/analytics'
import type AccountsCard from '@/components/ui/AccountsCard.vue'
import { arrayBufferToBase64 } from '@modrinth/utils'
const editSkinModal = useTemplateRef('editSkinModal')
const selectCapeModal = useTemplateRef('selectCapeModal')
const uploadSkinModal = useTemplateRef('uploadSkinModal')
const notifications = useNotifications()
const settings = ref(await getSettings())
const skins = ref<Skin[]>([])
const capes = ref<Cape[]>([])
const accountsCard = inject('accountsCard') as Ref<typeof AccountsCard>
const currentUser = ref(undefined)
const currentUserId = ref<string | undefined>(undefined)
const username = computed(() => currentUser.value?.profile?.name ?? undefined)
const selectedSkin = ref<Skin | null>(null)
const defaultCape = ref<Cape>()
const originalSelectedSkin = ref<Skin | null>(null)
const originalDefaultCape = ref<Cape>()
const savedSkins = computed(() => filterSavedSkins(skins.value))
const defaultSkins = computed(() => filterDefaultSkins(skins.value))
const currentCape = computed(() => {
if (selectedSkin.value?.cape_id) {
const overrideCape = capes.value.find((c) => c.id === selectedSkin.value?.cape_id)
if (overrideCape) {
return overrideCape
}
}
return defaultCape.value
})
const skinTexture = computedAsync(async () => {
if (selectedSkin.value?.texture) {
return await get_normalized_skin_texture(selectedSkin.value)
} else {
return ''
}
})
const capeTexture = computed(() => currentCape.value?.texture)
const skinVariant = computed(() => selectedSkin.value?.variant)
const skinNametag = computed(() =>
settings.value.hide_nametag_skins_page ? undefined : username.value,
)
let userCheckInterval: number | null = null
const deleteSkinModal = ref()
const skinToDelete = ref<Skin | null>(null)
function confirmDeleteSkin(skin: Skin) {
skinToDelete.value = skin
deleteSkinModal.value?.show()
}
async function deleteSkin() {
if (!skinToDelete.value) return
await remove_custom_skin(skinToDelete.value).catch(handleError)
await loadSkins()
skinToDelete.value = null
}
async function loadCapes() {
try {
capes.value = (await get_available_capes()) ?? []
defaultCape.value = capes.value.find((c) => c.is_equipped)
originalDefaultCape.value = defaultCape.value
} catch (error) {
if (currentUser.value) {
handleError(error)
}
}
}
async function loadSkins() {
try {
skins.value = (await get_available_skins()) ?? []
generateSkinPreviews(skins.value, capes.value)
selectedSkin.value = skins.value.find((s) => s.is_equipped) ?? null
originalSelectedSkin.value = selectedSkin.value
} catch (error) {
if (currentUser.value) {
handleError(error)
}
}
}
async function changeSkin(newSkin: Skin) {
const previousSkin = selectedSkin.value
const previousSkinsList = [...skins.value]
skins.value = skins.value.map((skin) => {
return {
...skin,
is_equipped: skin.texture_key === newSkin.texture_key,
}
})
selectedSkin.value = skins.value.find((s) => s.texture_key === newSkin.texture_key) || null
try {
await equip_skin(newSkin)
if (accountsCard.value) {
await accountsCard.value.refreshValues()
}
} catch (error) {
selectedSkin.value = previousSkin
skins.value = previousSkinsList
if ((error as { message?: string })?.message?.includes('429 Too Many Requests')) {
notifications.addNotification({
type: 'error',
title: 'Slow down!',
text: "You're changing your skin too frequently. Mojang's servers have temporarily blocked further requests. Please wait a moment before trying again.",
})
} else {
handleError(error)
}
}
}
async function handleCapeSelected(cape: Cape | undefined) {
const previousDefaultCape = defaultCape.value
const previousCapesList = [...capes.value]
capes.value = capes.value.map((c) => ({
...c,
is_equipped: cape ? c.id === cape.id : false,
}))
defaultCape.value = cape ? capes.value.find((c) => c.id === cape.id) : undefined
try {
await set_default_cape(cape)
} catch (error) {
defaultCape.value = previousDefaultCape
capes.value = previousCapesList
if ((error as { message?: string })?.message?.includes('429 Too Many Requests')) {
notifications.addNotification({
type: 'error',
title: 'Slow down!',
text: "You're changing your cape too frequently. Mojang's servers have temporarily blocked further requests. Please wait a moment before trying again.",
})
} else {
handleError(error)
}
}
}
async function onSkinSaved() {
await Promise.all([loadCapes(), loadSkins()])
}
async function loadCurrentUser() {
try {
const defaultId = await get_default_user()
currentUserId.value = defaultId
const allAccounts = await users()
currentUser.value = allAccounts.find((acc) => acc.profile.id === defaultId)
} catch (e) {
handleError(e)
currentUser.value = undefined
currentUserId.value = undefined
}
}
function getBakedSkinTextures(skin: Skin): RenderResult | undefined {
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
return map.get(key)
}
async function login() {
accountsCard.value.setLoginDisabled(true)
const loggedIn = await login_flow().catch(handleSevereError)
if (loggedIn && accountsCard) {
await accountsCard.value.refreshValues()
}
trackEvent('AccountLogIn')
accountsCard.value.setLoginDisabled(false)
}
function openUploadSkinModal(e: MouseEvent) {
uploadSkinModal.value?.show(e)
}
function onSkinFileUploaded(buffer: ArrayBuffer) {
const fakeEvent = new MouseEvent('click')
normalize_skin_texture(`data:image/png;base64,` + arrayBufferToBase64(buffer)).then(
(skinTextureNormalized: Uint8Array) => {
const skinTexUrl = `data:image/png;base64,` + arrayBufferToBase64(skinTextureNormalized)
if (editSkinModal.value && editSkinModal.value.shouldRestoreModal) {
editSkinModal.value.restoreWithNewTexture(skinTexUrl)
} else {
editSkinModal.value?.showNew(fakeEvent, skinTexUrl)
}
},
)
}
function onUploadCanceled() {
editSkinModal.value?.restoreModal()
}
watch(
() => selectedSkin.value?.cape_id,
() => {},
)
onMounted(() => {
userCheckInterval = window.setInterval(checkUserChanges, 250)
})
onUnmounted(() => {
if (userCheckInterval !== null) {
window.clearInterval(userCheckInterval)
}
})
async function checkUserChanges() {
try {
const defaultId = await get_default_user()
if (defaultId !== currentUserId.value) {
await loadCurrentUser()
await loadCapes()
await loadSkins()
}
} catch (error) {
if (currentUser.value) {
handleError(error)
}
}
}
await Promise.all([loadCapes(), loadSkins(), loadCurrentUser()])
</script>
<template>
<EditSkinModal
ref="editSkinModal"
:capes="capes"
:default-cape="defaultCape"
@saved="onSkinSaved"
@deleted="() => loadSkins()"
@open-upload-modal="openUploadSkinModal"
/>
<SelectCapeModal ref="selectCapeModal" :capes="capes" @select="handleCapeSelected" />
<UploadSkinModal
ref="uploadSkinModal"
@uploaded="onSkinFileUploaded"
@canceled="onUploadCanceled"
/>
<ConfirmModal
ref="deleteSkinModal"
title="Are you sure you want to delete this skin?"
description="This will permanently delete the selected skin. This action cannot be undone."
proceed-label="Delete"
@proceed="deleteSkin"
/>
<div v-if="currentUser" class="p-4 skin-layout">
<div class="preview-panel">
<h1 class="m-0 text-2xl font-bold flex items-center gap-2">
Skins
<span class="text-sm font-bold px-2 bg-brand-highlight text-brand rounded-full">Beta</span>
</h1>
<div class="preview-container">
<SkinPreviewRenderer
:cape-src="capeTexture"
:texture-src="skinTexture || ''"
:variant="skinVariant"
:nametag="skinNametag"
:initial-rotation="Math.PI / 8"
>
<template #subtitle>
<ButtonStyled :disabled="!!selectedSkin?.cape_id">
<button
v-tooltip="
selectedSkin?.cape_id
? 'The equipped skin is overriding the default cape.'
: undefined
"
:disabled="!!selectedSkin?.cape_id"
@click="
(e: MouseEvent) =>
selectCapeModal?.show(
e,
selectedSkin?.texture_key,
currentCape,
skinTexture,
skinVariant,
)
"
>
<UpdatedIcon />
Change cape
</button>
</ButtonStyled>
</template>
</SkinPreviewRenderer>
</div>
</div>
<div class="skins-container">
<section class="flex flex-col gap-2 mt-1">
<h2 class="text-lg font-bold m-0 text-primary">Saved skins</h2>
<div class="skin-card-grid">
<SkinLikeTextButton class="skin-card" @click="openUploadSkinModal">
<template #icon>
<PlusIcon class="size-8" />
</template>
<span>Add a skin</span>
</SkinLikeTextButton>
<SkinButton
v-for="skin in savedSkins"
:key="`saved-skin-${skin.texture_key}`"
class="skin-card"
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
:backward-image-src="getBakedSkinTextures(skin)?.backwards"
:selected="selectedSkin === skin"
@select="changeSkin(skin)"
>
<template #overlay-buttons>
<Button
color="green"
aria-label="Edit skin"
class="pointer-events-auto"
@click.stop="(e) => editSkinModal?.show(e, skin)"
>
<EditIcon /> Edit
</Button>
<Button
v-show="!skin.is_equipped"
v-tooltip="'Delete skin'"
aria-label="Delete skin"
color="red"
class="!rounded-[100%] pointer-events-auto"
icon-only
@click.stop="() => confirmDeleteSkin(skin)"
>
<TrashIcon />
</Button>
</template>
</SkinButton>
</div>
</section>
<section class="flex flex-col gap-2 mt-6">
<h2 class="text-lg font-bold m-0 text-primary">Default skins</h2>
<div class="skin-card-grid">
<SkinButton
v-for="skin in defaultSkins"
:key="`default-skin-${skin.texture_key}`"
class="skin-card"
:forward-image-src="getBakedSkinTextures(skin)?.forwards"
:backward-image-src="getBakedSkinTextures(skin)?.backwards"
:selected="selectedSkin === skin"
:tooltip="skin.name"
@select="changeSkin(skin)"
/>
</div>
</section>
</div>
</div>
<div v-else class="flex items-center justify-center min-h-[50vh] pt-[25%]">
<div
class="bg-bg-raised rounded-lg p-7 flex flex-col gap-5 shadow-md relative max-w-xl w-full mx-auto"
>
<img
:src="ExcitedRinthbot"
alt="Excited Modrinth Bot"
class="absolute -top-28 right-8 md:right-20 h-28 w-auto"
/>
<div
class="absolute top-0 left-0 w-full h-[1px] opacity-40 bg-gradient-to-r from-transparent via-green-500 to-transparent"
style="
background: linear-gradient(
to right,
transparent 2rem,
var(--color-green) calc(100% - 13rem),
var(--color-green) calc(100% - 5rem),
transparent calc(100% - 2rem)
);
"
></div>
<div class="flex flex-col gap-5">
<h1 class="text-3xl font-extrabold m-0">Please sign-in</h1>
<p class="text-lg m-0">
Please sign into your Minecraft account to use the skin management features of the
Modrinth app.
</p>
<ButtonStyled v-show="accountsCard" color="brand" :disabled="accountsCard.loginDisabled">
<button :disabled="accountsCard.loginDisabled" @click="login">
<LogInIcon v-if="!accountsCard.loginDisabled" />
<SpinnerIcon v-else class="animate-spin" />
Sign In
</button>
</ButtonStyled>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
$skin-card-width: 155px;
$skin-card-gap: 4px;
.skin-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 2.5fr);
gap: 2.5rem;
@media (max-width: 700px) {
grid-template-columns: 1fr;
}
}
.preview-panel {
top: 1.5rem;
position: sticky;
align-self: start;
padding: 0.5rem;
padding-top: 0;
}
.preview-container {
height: 80vh;
display: flex;
align-items: center;
justify-content: center;
margin-left: calc((2.5rem / 2));
@media (max-width: 700px) {
height: 50vh;
}
}
.skins-container {
padding-top: 0.5rem;
}
.skin-card-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: $skin-card-gap;
width: 100%;
@media (min-width: 1300px) {
grid-template-columns: repeat(4, 1fr);
}
@media (min-width: 1750px) {
grid-template-columns: repeat(5, 1fr);
}
@media (min-width: 2050px) {
grid-template-columns: repeat(6, 1fr);
}
}
.skin-card {
aspect-ratio: 0.95;
border-radius: 10px;
box-sizing: border-box;
width: 100%;
min-width: 0;
}
</style>

View File

@@ -1,6 +1,5 @@
import Index from './Index.vue' import Index from './Index.vue'
import Browse from './Browse.vue' import Browse from './Browse.vue'
import Worlds from './Worlds.vue' import Worlds from './Worlds.vue'
import Skins from './Skins.vue'
export { Index, Browse, Worlds, Skins } export { Index, Browse, Worlds }

View File

@@ -32,11 +32,7 @@
<template #actions> <template #actions>
<div class="flex gap-2"> <div class="flex gap-2">
<ButtonStyled <ButtonStyled
v-if=" v-if="instance.install_stage.includes('installing')"
['installing', 'pack_installing', 'minecraft_installing'].includes(
instance.install_stage,
)
"
color="brand" color="brand"
size="large" size="large"
> >

View File

@@ -22,7 +22,7 @@
<FilterIcon class="text-secondary h-5 w-5 mr-1" /> <FilterIcon class="text-secondary h-5 w-5 mr-1" />
<button <button
v-for="filter in filterOptions" v-for="filter in filterOptions"
:key="`content-filter-${filter.id}`" :key="filter"
:class="`px-2 py-1 rounded-full font-semibold leading-none border-none cursor-pointer active:scale-[0.97] duration-100 transition-all ${selectedFilters.includes(filter.id) ? 'bg-brand-highlight text-brand' : 'bg-bg-raised text-secondary'}`" :class="`px-2 py-1 rounded-full font-semibold leading-none border-none cursor-pointer active:scale-[0.97] duration-100 transition-all ${selectedFilters.includes(filter.id) ? 'bg-brand-highlight text-brand' : 'bg-bg-raised text-secondary'}`"
@click="toggleArray(selectedFilters, filter.id)" @click="toggleArray(selectedFilters, filter.id)"
> >
@@ -47,7 +47,7 @@
path: x.path, path: x.path,
disabled: x.disabled, disabled: x.disabled,
filename: x.file_name, filename: x.file_name,
icon: x.icon ?? undefined, icon: x.icon,
title: x.name, title: x.name,
data: x, data: x,
} }
@@ -156,7 +156,7 @@
color-fill="text" color-fill="text"
hover-color-fill="text" hover-color-fill="text"
> >
<button class="w-max" :disabled="installing" @click="modpackVersionModal?.show()"> <button class="w-max" :disabled="installing" @click="modpackVersionModal.show()">
<DownloadIcon /> Update pack <DownloadIcon /> Update pack
</button> </button>
</ButtonStyled> </ButtonStyled>
@@ -170,7 +170,7 @@
> >
<button <button
v-tooltip="`Update`" v-tooltip="`Update`"
:disabled="(item.data as ProjectListEntry).updating" :disabled="(item.data as any).updating"
@click="updateProject(item.data)" @click="updateProject(item.data)"
> >
<DownloadIcon /> <DownloadIcon />
@@ -276,7 +276,6 @@ import {
RadialHeader, RadialHeader,
Toggle, Toggle,
} from '@modrinth/ui' } from '@modrinth/ui'
import type { Organization, Project, TeamMember, Version } from '@modrinth/utils'
import { formatProjectType } from '@modrinth/utils' import { formatProjectType } from '@modrinth/utils'
import type { ComputedRef } from 'vue' import type { ComputedRef } from 'vue'
import { computed, onUnmounted, ref, watch } from 'vue' import { computed, onUnmounted, ref, watch } from 'vue'
@@ -306,18 +305,43 @@ import { profile_listener } from '@/helpers/events.js'
import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue' import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue'
import { getCurrentWebview } from '@tauri-apps/api/webview' import { getCurrentWebview } from '@tauri-apps/api/webview'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import type { CacheBehaviour, ContentFile, GameInstance } from '@/helpers/types'
import type ContextMenu from '@/components/ui/ContextMenu.vue'
import type { ContentItem } from '@modrinth/ui/src/components/content/ContentListItem.vue'
const props = defineProps<{ const props = defineProps({
instance: GameInstance instance: {
options: InstanceType<typeof ContextMenu> type: Object,
offline: boolean default() {
playing: boolean return {}
versions: Version[] },
installed: boolean },
}>() options: {
type: Object,
default() {
return {}
},
},
offline: {
type: Boolean,
default() {
return false
},
},
playing: {
type: Boolean,
default() {
return false
},
},
versions: {
type: Array,
required: true,
},
installed: {
type: Boolean,
default() {
return false
},
},
})
type ProjectListEntryAuthor = { type ProjectListEntryAuthor = {
name: string name: string
@@ -332,15 +356,13 @@ type ProjectListEntry = {
author: ProjectListEntryAuthor | null author: ProjectListEntryAuthor | null
version: string | null version: string | null
file_name: string file_name: string
icon: string | undefined icon: string | null
disabled: boolean disabled: boolean
updateVersion?: string updateVersion?: string
outdated: boolean outdated: boolean
updated: dayjs.Dayjs updated: dayjs.Dayjs
project_type: string project_type: string
id?: string id?: string
updating?: boolean
selected?: boolean
} }
const isPackLocked = computed(() => { const isPackLocked = computed(() => {
@@ -353,20 +375,17 @@ const canUpdatePack = computed(() => {
const exportModal = ref(null) const exportModal = ref(null)
const projects = ref<ProjectListEntry[]>([]) const projects = ref<ProjectListEntry[]>([])
const selectedFiles = ref<string[]>([]) const selectedFiles = ref([])
const selectedProjects = computed(() => const selectedProjects = computed(() =>
projects.value.filter((x) => selectedFiles.value.includes(x.file_name)), projects.value.filter((x) => selectedFiles.value.includes(x.file_name)),
) )
const selectionMap = ref(new Map()) const selectionMap = ref(new Map())
const initProjects = async (cacheBehaviour?: CacheBehaviour) => { const initProjects = async (cacheBehaviour?) => {
const newProjects: ProjectListEntry[] = [] const newProjects: ProjectListEntry[] = []
const profileProjects = (await get_projects(props.instance.path, cacheBehaviour)) as Record< const profileProjects = await get_projects(props.instance.path, cacheBehaviour)
string,
ContentFile
>
const fetchProjects = [] const fetchProjects = []
const fetchVersions = [] const fetchVersions = []
@@ -378,21 +397,21 @@ const initProjects = async (cacheBehaviour?: CacheBehaviour) => {
} }
const [modrinthProjects, modrinthVersions] = await Promise.all([ const [modrinthProjects, modrinthVersions] = await Promise.all([
(await get_project_many(fetchProjects).catch(handleError)) as Project[], await get_project_many(fetchProjects).catch(handleError),
(await get_version_many(fetchVersions).catch(handleError)) as Version[], await get_version_many(fetchVersions).catch(handleError),
]) ])
const [modrinthTeams, modrinthOrganizations] = await Promise.all([ const [modrinthTeams, modrinthOrganizations] = await Promise.all([
(await get_team_many(modrinthProjects.map((x) => x.team)).catch(handleError)) as TeamMember[][], await get_team_many(modrinthProjects.map((x) => x.team)).catch(handleError),
(await get_organization_many( await get_organization_many(
modrinthProjects.map((x) => x.organization).filter((x) => !!x), modrinthProjects.map((x) => x.organization).filter((x) => !!x),
).catch(handleError)) as Organization[], ).catch(handleError),
]) ])
for (const [path, file] of Object.entries(profileProjects)) { for (const [path, file] of Object.entries(profileProjects)) {
if (file.metadata) { if (file.metadata) {
const project = modrinthProjects.find((x) => file.metadata?.project_id === x.id) const project = modrinthProjects.find((x) => file.metadata.project_id === x.id)
const version = modrinthVersions.find((x) => file.metadata?.version_id === x.id) const version = modrinthVersions.find((x) => file.metadata.version_id === x.id)
if (project && version) { if (project && version) {
const org = project.organization const org = project.organization
@@ -401,7 +420,7 @@ const initProjects = async (cacheBehaviour?: CacheBehaviour) => {
const team = modrinthTeams.find((x) => x[0].team_id === project.team) const team = modrinthTeams.find((x) => x[0].team_id === project.team)
let author: ProjectListEntryAuthor | null = null let author: ProjectListEntryAuthor | null
if (org) { if (org) {
author = { author = {
name: org.name, name: org.name,
@@ -410,13 +429,13 @@ const initProjects = async (cacheBehaviour?: CacheBehaviour) => {
} }
} else if (team) { } else if (team) {
const teamMember = team.find((x) => x.is_owner) const teamMember = team.find((x) => x.is_owner)
if (teamMember) { author = {
author = { name: teamMember.user.username,
name: teamMember.user.username, slug: teamMember.user.username,
slug: teamMember.user.username, type: 'user',
type: 'user',
}
} }
} else {
author = null
} }
newProjects.push({ newProjects.push({
@@ -445,7 +464,7 @@ const initProjects = async (cacheBehaviour?: CacheBehaviour) => {
author: null, author: null,
version: null, version: null,
file_name: file.file_name, file_name: file.file_name,
icon: undefined, icon: null,
disabled: file.file_name.endsWith('.disabled'), disabled: file.file_name.endsWith('.disabled'),
outdated: false, outdated: false,
updated: dayjs(0), updated: dayjs(0),
@@ -469,7 +488,7 @@ const initProjects = async (cacheBehaviour?: CacheBehaviour) => {
} }
await initProjects() await initProjects()
const modpackVersionModal = ref<InstanceType<typeof ModpackVersionModal> | null>() const modpackVersionModal = ref(null)
const installing = computed(() => props.instance.install_stage !== 'installed') const installing = computed(() => props.instance.install_stage !== 'installed')
const vintl = useVIntl() const vintl = useVIntl()
@@ -494,7 +513,7 @@ const messages = defineMessages({
const filterOptions: ComputedRef<FilterOption[]> = computed(() => { const filterOptions: ComputedRef<FilterOption[]> = computed(() => {
const options: FilterOption[] = [] const options: FilterOption[] = []
const frequency = projects.value.reduce((map: Record<string, number>, item) => { const frequency = projects.value.reduce((map, item) => {
map[item.project_type] = (map[item.project_type] || 0) + 1 map[item.project_type] = (map[item.project_type] || 0) + 1
return map return map
}, {}) }, {})
@@ -525,7 +544,7 @@ const filterOptions: ComputedRef<FilterOption[]> = computed(() => {
return options return options
}) })
const selectedFilters = ref<string[]>([]) const selectedFilters = ref([])
const filteredProjects = computed(() => { const filteredProjects = computed(() => {
const updatesFilter = selectedFilters.value.includes('updates') const updatesFilter = selectedFilters.value.includes('updates')
const disabledFilter = selectedFilters.value.includes('disabled') const disabledFilter = selectedFilters.value.includes('disabled')
@@ -552,7 +571,7 @@ watch(filterOptions, () => {
} }
}) })
function toggleArray<T>(array: T[], value: T) { function toggleArray(array, value) {
if (array.includes(value)) { if (array.includes(value)) {
array.splice(array.indexOf(value), 1) array.splice(array.indexOf(value), 1)
} else { } else {
@@ -562,7 +581,7 @@ function toggleArray<T>(array: T[], value: T) {
const searchFilter = ref('') const searchFilter = ref('')
const selectAll = ref(false) const selectAll = ref(false)
const shareModal = ref<InstanceType<typeof ShareModalWrapper> | null>() const shareModal = ref(null)
const ascending = ref(true) const ascending = ref(true)
const sortColumn = ref('Name') const sortColumn = ref('Name')
const currentPage = ref(1) const currentPage = ref(1)
@@ -603,7 +622,7 @@ const search = computed(() => {
watch([sortColumn, ascending, selectedFilters.value, searchFilter], () => (currentPage.value = 1)) watch([sortColumn, ascending, selectedFilters.value, searchFilter], () => (currentPage.value = 1))
const sortProjects = (filter: string) => { const sortProjects = (filter) => {
if (sortColumn.value === filter) { if (sortColumn.value === filter) {
ascending.value = !ascending.value ascending.value = !ascending.value
} else { } else {
@@ -621,7 +640,7 @@ const updateAll = async () => {
} }
} }
const paths = (await update_all(props.instance.path).catch(handleError)) as Record<string, string> const paths = await update_all(props.instance.path).catch(handleError)
for (const [oldVal, newVal] of Object.entries(paths)) { for (const [oldVal, newVal] of Object.entries(paths)) {
const index = projects.value.findIndex((x) => x.path === oldVal) const index = projects.value.findIndex((x) => x.path === oldVal)
@@ -630,7 +649,7 @@ const updateAll = async () => {
if (projects.value[index].updateVersion) { if (projects.value[index].updateVersion) {
projects.value[index].version = projects.value[index].updateVersion.version_number projects.value[index].version = projects.value[index].updateVersion.version_number
projects.value[index].updateVersion = undefined projects.value[index].updateVersion = null
} }
} }
for (const project of setProjects) { for (const project of setProjects) {
@@ -645,15 +664,15 @@ const updateAll = async () => {
}) })
} }
const updateProject = async (mod: ProjectListEntry) => { const updateProject = async (mod) => {
mod.updating = true mod.updating = true
await new Promise((resolve) => setTimeout(resolve, 0)) await new Promise((resolve) => setTimeout(resolve, 0))
mod.path = await update_project(props.instance.path, mod.path).catch(handleError) mod.path = await update_project(props.instance.path, mod.path).catch(handleError)
mod.updating = false mod.updating = false
mod.outdated = false mod.outdated = false
mod.version = mod.updateVersion?.version_number mod.version = mod.updateVersion.version_number
mod.updateVersion = undefined mod.updateVersion = null
trackEvent('InstanceProjectUpdate', { trackEvent('InstanceProjectUpdate', {
loader: props.instance.loader, loader: props.instance.loader,
@@ -664,15 +683,15 @@ const updateProject = async (mod: ProjectListEntry) => {
}) })
} }
const locks: Record<string, string | null> = {} const locks = {}
const toggleDisableMod = async (mod: ProjectListEntry) => { const toggleDisableMod = async (mod) => {
// Use mod's id as the key for the lock. If mod doesn't have a unique id, replace `mod.id` with some unique property. // Use mod's id as the key for the lock. If mod doesn't have a unique id, replace `mod.id` with some unique property.
const lock = locks[mod.file_name] const lock = locks[mod.file_name]
while (lock) { while (lock) {
await new Promise((resolve) => { await new Promise((resolve) => {
setTimeout((value: unknown) => resolve(value), 100) setTimeout((_) => resolve(), 100)
}) })
} }
@@ -697,20 +716,20 @@ const toggleDisableMod = async (mod: ProjectListEntry) => {
locks[mod.file_name] = null locks[mod.file_name] = null
} }
const removeMod = async (mod: ContentItem<ProjectListEntry>) => { const removeMod = async (mod) => {
await remove_project(props.instance.path, mod.path).catch(handleError) await remove_project(props.instance.path, mod.path).catch(handleError)
projects.value = projects.value.filter((x) => mod.path !== x.path) projects.value = projects.value.filter((x) => mod.path !== x.path)
trackEvent('InstanceProjectRemove', { trackEvent('InstanceProjectRemove', {
loader: props.instance.loader, loader: props.instance.loader,
game_version: props.instance.game_version, game_version: props.instance.game_version,
id: mod.data.id, id: mod.id,
name: mod.data.name, name: mod.name,
project_type: mod.data.project_type, project_type: mod.project_type,
}) })
} }
const copyModLink = async (mod: ContentItem<ProjectListEntry>) => { const copyModLink = async (mod) => {
await navigator.clipboard.writeText( await navigator.clipboard.writeText(
`https://modrinth.com/${mod.data.project_type}/${mod.data.slug}`, `https://modrinth.com/${mod.data.project_type}/${mod.data.slug}`,
) )
@@ -725,15 +744,15 @@ const deleteSelected = async () => {
} }
const shareNames = async () => { const shareNames = async () => {
await shareModal.value?.show(functionValues.value.map((x) => x.name).join('\n')) await shareModal.value.show(functionValues.value.map((x) => x.name).join('\n'))
} }
const shareFileNames = async () => { const shareFileNames = async () => {
await shareModal.value?.show(functionValues.value.map((x) => x.file_name).join('\n')) await shareModal.value.show(functionValues.value.map((x) => x.file_name).join('\n'))
} }
const shareUrls = async () => { const shareUrls = async () => {
await shareModal.value?.show( await shareModal.value.show(
functionValues.value functionValues.value
.filter((x) => x.slug) .filter((x) => x.slug)
.map((x) => `https://modrinth.com/${x.project_type}/${x.slug}`) .map((x) => `https://modrinth.com/${x.project_type}/${x.slug}`)
@@ -742,7 +761,7 @@ const shareUrls = async () => {
} }
const shareMarkdown = async () => { const shareMarkdown = async () => {
await shareModal.value?.show( await shareModal.value.show(
functionValues.value functionValues.value
.map((x) => { .map((x) => {
if (x.slug) { if (x.slug) {
@@ -807,17 +826,15 @@ const unlisten = await getCurrentWebview().onDragDropEvent(async (event) => {
await initProjects() await initProjects()
}) })
const unlistenProfiles = await profile_listener( const unlistenProfiles = await profile_listener(async (event) => {
async (event: { event: string; profile_path_id: string }) => { if (
if ( event.profile_path_id === props.instance.path &&
event.profile_path_id === props.instance.path && event.event === 'synced' &&
event.event === 'synced' && props.instance.install_stage !== 'pack_installing'
props.instance.install_stage !== 'pack_installing' ) {
) { await initProjects()
await initProjects() }
} })
},
)
onUnmounted(() => { onUnmounted(() => {
unlisten() unlisten()

View File

@@ -60,7 +60,7 @@
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
<FilterBar v-model="filters" :options="filterOptions" show-all-options /> <FilterBar v-model="filters" :options="filterOptions" />
<div class="flex flex-col w-full gap-2"> <div class="flex flex-col w-full gap-2">
<WorldItem <WorldItem
v-for="world in filteredWorlds" v-for="world in filteredWorlds"
@@ -86,7 +86,6 @@
world.type === 'server' ? editServerModal?.show(world) : editWorldModal?.show(world) world.type === 'server' ? editServerModal?.show(world) : editWorldModal?.show(world)
" "
@delete="() => promptToRemoveWorld(world)" @delete="() => promptToRemoveWorld(world)"
@open-folder="(world: SingleplayerWorld) => showWorldInFolder(instance.path, world.path)"
/> />
</div> </div>
</div> </div>
@@ -152,7 +151,6 @@ import {
hasQuickPlaySupport, hasQuickPlaySupport,
refreshWorlds, refreshWorlds,
handleDefaultProfileUpdateEvent, handleDefaultProfileUpdateEvent,
showWorldInFolder,
} from '@/helpers/worlds.ts' } from '@/helpers/worlds.ts'
import AddServerModal from '@/components/ui/world/modal/AddServerModal.vue' import AddServerModal from '@/components/ui/world/modal/AddServerModal.vue'
import EditServerModal from '@/components/ui/world/modal/EditServerModal.vue' import EditServerModal from '@/components/ui/world/modal/EditServerModal.vue'
@@ -227,11 +225,6 @@ const unlistenProfile = await profile_listener(async (e: ProfileEvent) => {
await refreshAllWorlds() await refreshAllWorlds()
async function refreshServer(address: string) { async function refreshServer(address: string) {
if (!serverData.value[address]) {
serverData.value[address] = {
refreshing: true,
}
}
await refreshServerData(serverData.value[address], protocolVersion.value, address) await refreshServerData(serverData.value[address], protocolVersion.value, address)
} }
@@ -270,12 +263,9 @@ async function addServer(server: ServerWorld) {
async function editServer(server: ServerWorld) { async function editServer(server: ServerWorld) {
const index = worlds.value.findIndex((w) => w.type === 'server' && w.index === server.index) const index = worlds.value.findIndex((w) => w.type === 'server' && w.index === server.index)
if (index !== -1) { if (index !== -1) {
const oldServer = worlds.value[index] as ServerWorld
worlds.value[index] = server worlds.value[index] = server
sortWorlds(worlds.value) sortWorlds(worlds.value)
if (oldServer.address !== server.address) { await refreshServer(server.address)
await refreshServer(server.address)
}
} else { } else {
handleError(`Error refreshing server, refreshing all worlds`) handleError(`Error refreshing server, refreshing all worlds`)
await refreshAllWorlds() await refreshAllWorlds()
@@ -359,34 +349,26 @@ const supportsQuickPlay = computed(() =>
const filterOptions = computed(() => { const filterOptions = computed(() => {
const options: FilterBarOption[] = [] const options: FilterBarOption[] = []
const hasServer = worlds.value.some((x) => x.type === 'server') if (worlds.value.some((x) => x.type === 'singleplayer')) {
if (worlds.value.some((x) => x.type === 'singleplayer') && hasServer) {
options.push({ options.push({
id: 'singleplayer', id: 'singleplayer',
message: messages.singleplayer, message: messages.singleplayer,
}) })
}
if (worlds.value.some((x) => x.type === 'server')) {
options.push({ options.push({
id: 'server', id: 'server',
message: messages.server, message: messages.server,
}) })
}
if (hasServer) { // add available filter if there's any offline ("unavailable") servers
// add available filter if there's any offline ("unavailable") servers AND there's any singleplayer worlds or available servers
if ( if (
worlds.value.some( worlds.value.some(
(x) => (x) =>
x.type === 'server' && x.type === 'server' &&
!serverData.value[x.address]?.status && !serverData.value[x.address]?.status &&
!serverData.value[x.address]?.refreshing, !serverData.value[x.address]?.refreshing,
) &&
worlds.value.some(
(x) =>
x.type === 'singleplayer' ||
(x.type === 'server' &&
serverData.value[x.address]?.status &&
!serverData.value[x.address]?.refreshing),
) )
) { ) {
options.push({ options.push({

View File

@@ -155,7 +155,7 @@ import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import { get as getInstance, get_projects as getInstanceProjects } from '@/helpers/profile' import { get as getInstance, get_projects as getInstanceProjects } from '@/helpers/profile'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime' import relativeTime from 'dayjs/plugin/relativeTime'
import { useRoute, useRouter } from 'vue-router' import { useRoute } from 'vue-router'
import { ref, shallowRef, watch } from 'vue' import { ref, shallowRef, watch } from 'vue'
import { useBreadcrumbs } from '@/store/breadcrumbs' import { useBreadcrumbs } from '@/store/breadcrumbs'
import { handleError } from '@/store/notifications.js' import { handleError } from '@/store/notifications.js'
@@ -170,7 +170,6 @@ import { openUrl } from '@tauri-apps/plugin-opener'
dayjs.extend(relativeTime) dayjs.extend(relativeTime)
const route = useRoute() const route = useRoute()
const router = useRouter()
const breadcrumbs = useBreadcrumbs() const breadcrumbs = useBreadcrumbs()
const themeStore = useTheming() const themeStore = useTheming()
@@ -248,9 +247,6 @@ async function install(version) {
installedVersion.value = version installedVersion.value = version
} }
}, },
(profile) => {
router.push(`/instance/${profile}`)
},
) )
} }

View File

@@ -34,14 +34,6 @@ export default new createRouter({
breadcrumb: [{ name: 'Discover content' }], breadcrumb: [{ name: 'Discover content' }],
}, },
}, },
{
path: '/skins',
name: 'Skins',
component: Pages.Skins,
meta: {
breadcrumb: [{ name: 'Skins' }],
},
},
{ {
path: '/library', path: '/library',
name: 'Library', name: 'Library',

View File

@@ -23,8 +23,8 @@ export const useInstall = defineStore('installStore', {
setInstallConfirmModal(ref) { setInstallConfirmModal(ref) {
this.installConfirmModal = ref this.installConfirmModal = ref
}, },
showInstallConfirmModal(project, version_id, onInstall, createInstanceCallback) { showInstallConfirmModal(project, version_id, onInstall) {
this.installConfirmModal.show(project, version_id, onInstall, createInstanceCallback) this.installConfirmModal.show(project, version_id, onInstall)
}, },
setIncompatibilityWarningModal(ref) { setIncompatibilityWarningModal(ref) {
this.incompatibilityWarningModal = ref this.incompatibilityWarningModal = ref
@@ -41,14 +41,7 @@ export const useInstall = defineStore('installStore', {
}, },
}) })
export const install = async ( export const install = async (projectId, versionId, instancePath, source, callback = () => {}) => {
projectId,
versionId,
instancePath,
source,
callback = () => {},
createInstanceCallback = () => {},
) => {
const project = await get_project(projectId, 'must_revalidate').catch(handleError) const project = await get_project(projectId, 'must_revalidate').catch(handleError)
if (project.project_type === 'modpack') { if (project.project_type === 'modpack') {
@@ -56,13 +49,7 @@ export const install = async (
const packs = await list().catch(handleError) const packs = await list().catch(handleError)
if (packs.length === 0 || !packs.find((pack) => pack.linked_data?.project_id === project.id)) { if (packs.length === 0 || !packs.find((pack) => pack.linked_data?.project_id === project.id)) {
await packInstall( await packInstall(project.id, version, project.title, project.icon_url).catch(handleError)
project.id,
version,
project.title,
project.icon_url,
createInstanceCallback,
).catch(handleError)
trackEvent('PackInstall', { trackEvent('PackInstall', {
id: project.id, id: project.id,
@@ -74,7 +61,7 @@ export const install = async (
callback(version) callback(version)
} else { } else {
const install = useInstall() const install = useInstall()
install.showInstallConfirmModal(project, version, callback, createInstanceCallback) install.showInstallConfirmModal(project, version, callback)
} }
} else { } else {
if (instancePath) { if (instancePath) {

View File

@@ -1,4 +1,4 @@
import { useTheming } from './theme.ts' import { useTheming } from './theme'
import { useBreadcrumbs } from './breadcrumbs' import { useBreadcrumbs } from './breadcrumbs'
import { useLoading } from './loading' import { useLoading } from './loading'
import { useNotifications, handleError } from './notifications' import { useNotifications, handleError } from './notifications'

View File

@@ -0,0 +1,38 @@
import { defineStore } from 'pinia'
export const useTheming = defineStore('themeStore', {
state: () => ({
themeOptions: ['dark', 'light', 'oled', 'system'],
advancedRendering: true,
selectedTheme: 'dark',
toggleSidebar: false,
devMode: false,
featureFlags: {},
}),
actions: {
setThemeState(newTheme) {
if (this.themeOptions.includes(newTheme)) this.selectedTheme = newTheme
else console.warn('Selected theme is not present. Check themeOptions.')
this.setThemeClass()
},
setThemeClass() {
for (const theme of this.themeOptions) {
document.getElementsByTagName('html')[0].classList.remove(`${theme}-mode`)
}
let theme = this.selectedTheme
if (this.selectedTheme === 'system') {
const darkThemeMq = window.matchMedia('(prefers-color-scheme: dark)')
if (darkThemeMq.matches) {
theme = 'dark'
} else {
theme = 'light'
}
}
document.getElementsByTagName('html')[0].classList.add(`${theme}-mode`)
},
},
})

View File

@@ -1,70 +0,0 @@
import { defineStore } from 'pinia'
export const DEFAULT_FEATURE_FLAGS = {
project_background: false,
page_path: false,
worlds_tab: false,
worlds_in_home: true,
}
export const THEME_OPTIONS = ['dark', 'light', 'oled', 'system'] as const
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
export type FeatureFlags = Record<FeatureFlag, boolean>
export type ColorTheme = (typeof THEME_OPTIONS)[number]
export type ThemeStore = {
selectedTheme: ColorTheme
advancedRendering: boolean
toggleSidebar: boolean
devMode: boolean
featureFlags: FeatureFlags
}
export const DEFAULT_THEME_STORE: ThemeStore = {
selectedTheme: 'dark',
advancedRendering: true,
toggleSidebar: false,
devMode: false,
featureFlags: DEFAULT_FEATURE_FLAGS,
}
export const useTheming = defineStore('themeStore', {
state: () => DEFAULT_THEME_STORE,
actions: {
setThemeState(newTheme: ColorTheme) {
if (THEME_OPTIONS.includes(newTheme)) {
this.selectedTheme = newTheme
} else {
console.warn('Selected theme is not present. Check themeOptions.')
}
this.setThemeClass()
},
setThemeClass() {
for (const theme of THEME_OPTIONS) {
document.getElementsByTagName('html')[0].classList.remove(`${theme}-mode`)
}
let theme = this.selectedTheme
if (this.selectedTheme === 'system') {
const darkThemeMq = window.matchMedia('(prefers-color-scheme: dark)')
if (darkThemeMq.matches) {
theme = 'dark'
} else {
theme = 'light'
}
}
document.getElementsByTagName('html')[0].classList.add(`${theme}-mode`)
},
getFeatureFlag(key: FeatureFlag) {
return this.featureFlags[key] ?? DEFAULT_FEATURE_FLAGS[key]
},
getThemeOptions() {
return THEME_OPTIONS
},
},
})

View File

@@ -41,7 +41,6 @@ export default {
green: 'var(--color-green-highlight)', green: 'var(--color-green-highlight)',
blue: 'var(--color-blue-highlight)', blue: 'var(--color-blue-highlight)',
purple: 'var(--color-purple-highlight)', purple: 'var(--color-purple-highlight)',
gray: 'var(--color-gray-highlight)',
}, },
divider: { divider: {
DEFAULT: 'var(--color-divider)', DEFAULT: 'var(--color-divider)',

View File

@@ -10,7 +10,6 @@
"isolatedModules": true, "isolatedModules": true,
"moduleDetection": "force", "moduleDetection": "force",
"noEmit": true, "noEmit": true,
"resolveJsonModule": true,
"strict": true "strict": true
}, },

View File

@@ -4,8 +4,6 @@ import svgLoader from 'vite-svg-loader'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import tauriConf from '../app/tauri.conf.json'
const projectRootDir = resolve(__dirname) const projectRootDir = resolve(__dirname)
// https://vitejs.dev/config/ // https://vitejs.dev/config/
@@ -43,32 +41,17 @@ export default defineConfig({
server: { server: {
port: 1420, port: 1420,
strictPort: true, strictPort: true,
headers: {
'content-security-policy': Object.entries(tauriConf.app.security.csp)
.map(([directive, sources]) => {
// An additional websocket connect-src is required for Vite dev tools to work
if (directive === 'connect-src') {
sources = Array.isArray(sources) ? sources : [sources]
sources.push('ws://localhost:1420')
}
return Array.isArray(sources)
? `${directive} ${sources.join(' ')}`
: `${directive} ${sources}`
})
.join('; '),
},
}, },
// to make use of `TAURI_ENV_DEBUG` and other env variables // to make use of `TAURI_ENV_DEBUG` and other env variables
// https://v2.tauri.app/reference/environment-variables/#tauri-cli-hook-commands // https://v2.tauri.app/reference/environment-variables/#tauri-cli-hook-commands
envPrefix: ['VITE_', 'TAURI_'], envPrefix: ['VITE_', 'TAURI_'],
build: { build: {
// Tauri supports es2021 // Tauri supports es2021
target: process.env.TAURI_ENV_PLATFORM == 'windows' ? 'chrome105' : 'safari13', // eslint-disable-line turbo/no-undeclared-env-vars target: process.env.TAURI_PLATFORM == 'windows' ? 'chrome105' : 'safari13', // eslint-disable-line turbo/no-undeclared-env-vars
// don't minify for debug builds // don't minify for debug builds
minify: !process.env.TAURI_ENV_DEBUG ? 'esbuild' : false, // eslint-disable-line turbo/no-undeclared-env-vars minify: !process.env.TAURI_DEBUG ? 'esbuild' : false, // eslint-disable-line turbo/no-undeclared-env-vars
// produce sourcemaps for debug builds // produce sourcemaps for debug builds
sourcemap: !!process.env.TAURI_ENV_DEBUG, // eslint-disable-line turbo/no-undeclared-env-vars sourcemap: !!process.env.TAURI_DEBUG, // eslint-disable-line turbo/no-undeclared-env-vars
commonjsOptions: { commonjsOptions: {
esmExternals: true, esmExternals: true,
}, },

View File

@@ -1,14 +1,13 @@
[package] [package]
name = "theseus_playground" name = "theseus_playground"
version = "0.0.0" version = "0.0.0"
edition.workspace = true edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
theseus = { workspace = true, features = ["cli"] } theseus = { path = "../../packages/app-lib", features = ["cli"] }
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } tokio = { version = "1", features = ["full"] }
enumset.workspace = true webbrowser = "0.8.13"
[lints] tracing = "0.1.37"
workspace = true

View File

@@ -2,9 +2,9 @@
"name": "@modrinth/app-playground", "name": "@modrinth/app-playground",
"scripts": { "scripts": {
"build": "cargo build --release", "build": "cargo build --release",
"lint": "cargo fmt --check && cargo clippy --all-targets", "lint": "cargo fmt --check && cargo clippy --all-targets --all-features -- -D warnings",
"fix": "cargo clippy --all-targets --fix --allow-dirty && cargo fmt", "fix": "cargo fmt && cargo clippy --fix",
"dev": "cargo run", "dev": "cargo run",
"test": "cargo nextest run --all-targets --no-fail-fast" "test": "cargo test"
} }
} }

View File

@@ -3,7 +3,6 @@
windows_subsystem = "windows" windows_subsystem = "windows"
)] )]
use enumset::EnumSet;
use theseus::prelude::*; use theseus::prelude::*;
use theseus::worlds::get_recent_worlds; use theseus::worlds::get_recent_worlds;
@@ -15,7 +14,8 @@ pub async fn authenticate_run() -> theseus::Result<Credentials> {
println!("A browser window will now open, follow the login flow there."); println!("A browser window will now open, follow the login flow there.");
let login = minecraft_auth::begin_login().await?; let login = minecraft_auth::begin_login().await?;
println!("Open URL {} in a browser", login.redirect_uri.as_str()); println!("URL {}", login.redirect_uri.as_str());
webbrowser::open(login.redirect_uri.as_str())?;
println!("Please enter URL code: "); println!("Please enter URL code: ");
let mut input = String::new(); let mut input = String::new();
@@ -27,10 +27,7 @@ pub async fn authenticate_run() -> theseus::Result<Credentials> {
let credentials = minecraft_auth::finish_login(&input, login).await?; let credentials = minecraft_auth::finish_login(&input, login).await?;
println!( println!("Logged in user {}.", credentials.username);
"Logged in user {}.",
credentials.maybe_online_profile().await.name
);
Ok(credentials) Ok(credentials)
} }
@@ -43,7 +40,7 @@ async fn main() -> theseus::Result<()> {
// Initialize state // Initialize state
State::init().await?; State::init().await?;
let worlds = get_recent_worlds(4, EnumSet::all()).await?; let worlds = get_recent_worlds(4).await?;
for world in worlds { for world in worlds {
println!( println!(
"World: {:?}/{:?} played at {:?}: {:#?}", "World: {:?}/{:?} played at {:?}: {:#?}",

4
apps/app/.gitignore vendored
View File

@@ -1,2 +1,6 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by tauri, metadata generated at compile time # Generated by tauri, metadata generated at compile time
/gen/ /gen/

View File

@@ -1,52 +1,62 @@
[package] [package]
name = "theseus_gui" name = "theseus_gui"
version = "1.0.0-local" # The actual version is set by the theseus-build workflow on tagging version = "0.9.4"
description = "The Modrinth App is a desktop application for managing your Minecraft mods" description = "The Modrinth App is a desktop application for managing your Minecraft mods"
license = "GPL-3.0-only" license = "GPL-3.0-only"
repository = "https://github.com/modrinth/code/apps/app/" repository = "https://github.com/modrinth/code/apps/app/"
edition.workspace = true edition = "2021"
build = "build.rs"
[build-dependencies] [build-dependencies]
tauri-build = { workspace = true, features = ["codegen"] } tauri-build = { version = "2.0.3", features = ["codegen"] }
[dependencies] [dependencies]
theseus = { workspace = true, features = ["tauri"] } theseus = { path = "../../packages/app-lib", features = ["tauri"] }
serde_json.workspace = true serde_json = "1.0"
serde = { workspace = true, features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_with.workspace = true serde_with = "3.0.0"
tauri = { workspace = true, features = ["devtools", "macos-private-api", "protocol-asset"] } tauri = { version = "2.1.1", features = ["devtools", "macos-private-api", "protocol-asset", "unstable"] }
tauri-plugin-deep-link.workspace = true tauri-plugin-window-state = "2.2.0"
tauri-plugin-dialog.workspace = true tauri-plugin-deep-link = "2.2.0"
tauri-plugin-http.workspace = true tauri-plugin-os = "2.2.0"
tauri-plugin-opener.workspace = true tauri-plugin-opener = "2.2.1"
tauri-plugin-os.workspace = true tauri-plugin-dialog = "2.2.0"
tauri-plugin-single-instance.workspace = true tauri-plugin-updater = { version = "2.3.0" }
tauri-plugin-updater.workspace = true tauri-plugin-single-instance = { version = "2.2.0" }
tauri-plugin-window-state.workspace = true
tokio = { workspace = true, features = ["time"] } tokio = { version = "1", features = ["full"] }
thiserror.workspace = true thiserror = "1.0"
daedalus.workspace = true daedalus = { path = "../../packages/daedalus" }
chrono.workspace = true chrono = "0.4.26"
either.workspace = true either = "1.15"
url.workspace = true url = "2.2"
urlencoding.workspace = true urlencoding = "2.1"
uuid = { workspace = true, features = ["serde", "v4"] } uuid = { version = "1.1", features = ["serde", "v4"] }
os_info = "3.7.0"
tracing.workspace = true tracing = "0.1.37"
tracing-error.workspace = true tracing-error = "0.2.0"
dashmap.workspace = true dashmap = "6.0.1"
paste.workspace = true paste = "1.0.15"
enumset = { workspace = true, features = ["serde"] }
native-dialog.workspace = true opener = { version = "0.7.2", features = ["reveal", "dbus-vendored"] }
native-dialog = "0.7.0"
[target.'cfg(not(target_os = "linux"))'.dependencies]
window-shadows = "0.2.1"
[target.'cfg(target_os = "macos")'.dependencies]
cocoa = "0.25.0"
objc = "0.2.7"
rand = "0.8.5"
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
tauri-plugin-updater = { workspace = true, optional = true } tauri-plugin-updater = { version = "2.3.0", optional = true, features = ["native-tls-vendored", "zip"], default-features = false }
[features] [features]
# by default Tauri runs in production mode # by default Tauri runs in production mode
@@ -56,6 +66,3 @@ default = ["custom-protocol"]
# DO NOT remove this # DO NOT remove this
custom-protocol = ["tauri/custom-protocol"] custom-protocol = ["tauri/custom-protocol"]
updater = [] updater = []
[lints]
workspace = true

View File

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

View File

@@ -99,24 +99,6 @@ fn main() {
DefaultPermissionRule::AllowAllCommands, DefaultPermissionRule::AllowAllCommands,
), ),
) )
.plugin(
"minecraft-skins",
InlinedPlugin::new()
.commands(&[
"get_available_capes",
"get_available_skins",
"add_and_equip_custom_skin",
"set_default_cape",
"equip_skin",
"remove_custom_skin",
"unequip_skin",
"normalize_skin_texture",
"get_dragged_skin_data",
])
.default_permission(
DefaultPermissionRule::AllowAllCommands,
),
)
.plugin( .plugin(
"mr-auth", "mr-auth",
InlinedPlugin::new() InlinedPlugin::new()
@@ -169,6 +151,7 @@ fn main() {
"profile_update_managed_modrinth_version", "profile_update_managed_modrinth_version",
"profile_repair_managed_modrinth", "profile_repair_managed_modrinth",
"profile_run", "profile_run",
"profile_run_credentials",
"profile_kill", "profile_kill",
"profile_edit", "profile_edit",
"profile_edit_icon", "profile_edit_icon",
@@ -265,7 +248,6 @@ fn main() {
"get_recent_worlds", "get_recent_worlds",
"get_profile_worlds", "get_profile_worlds",
"get_singleplayer_world", "get_singleplayer_world",
"set_world_display_status",
"rename_world", "rename_world",
"reset_world_icon", "reset_world_icon",
"backup_world", "backup_world",

View File

@@ -19,21 +19,12 @@
"window-state:default", "window-state:default",
"window-state:allow-restore-state", "window-state:allow-restore-state",
"window-state:allow-save-window-state", "window-state:allow-save-window-state",
{
"identifier": "http:default",
"allow": [
{ "url": "https://modrinth.com/*" },
{ "url": "https://*.modrinth.com/*" }
]
},
"auth:default", "auth:default",
"import:default", "import:default",
"jre:default", "jre:default",
"logs:default", "logs:default",
"metadata:default", "metadata:default",
"minecraft-skins:default",
"mr-auth:default", "mr-auth:default",
"profile-create:default", "profile-create:default",
"pack:default", "pack:default",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 13 KiB

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