Compare commits
105 Commits
v0.10.1
...
app-update
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2774cdca76 | ||
|
|
d4516d3527 | ||
|
|
87de47fe5e | ||
|
|
3b6a9dde0f | ||
|
|
a1e0c134a0 | ||
|
|
7d76fe1b6a | ||
|
|
ae25a15abd | ||
|
|
0f755b94ce | ||
|
|
bcf46d440b | ||
|
|
526561f2de | ||
|
|
a8caa1afc3 | ||
|
|
98e9a8473d | ||
|
|
936395484e | ||
|
|
0c3e23db96 | ||
|
|
0310cc52d0 | ||
|
|
013ba4d86d | ||
|
|
93813c448c | ||
|
|
c20b869e62 | ||
|
|
56c556821b | ||
|
|
071e2b58b3 | ||
|
|
44267619b6 | ||
|
|
30e93e0880 | ||
|
|
2c90f1c142 | ||
|
|
90043fe84d | ||
|
|
a6a98ff63e | ||
|
|
911652133b | ||
|
|
cee1b5f522 | ||
|
|
62f5a23fcb | ||
|
|
eb595cdc3e | ||
|
|
83bd4dde45 | ||
|
|
221c26d613 | ||
|
|
7cc39cb54d | ||
|
|
80e0f84b62 | ||
|
|
572cd065ed | ||
|
|
76dc8a0897 | ||
|
|
4723de6269 | ||
|
|
e15fa35bad | ||
|
|
2cc6bc8ce4 | ||
|
|
5d19d31b2c | ||
|
|
c1b95ede07 | ||
|
|
058185c7fd | ||
|
|
6fb125cf0f | ||
|
|
1e934312a4 | ||
|
|
a945e9b005 | ||
|
|
b943638afb | ||
|
|
6fa0ee487d | ||
|
|
207dc0e2bb | ||
|
|
359fbd4738 | ||
|
|
62e2e5ea6f | ||
|
|
69a461dffc | ||
|
|
35baa1af5e | ||
|
|
59ab09a275 | ||
|
|
286ab6d4a0 | ||
|
|
f9a4042f13 | ||
|
|
f7700acce4 | ||
|
|
3b74e021b5 | ||
|
|
8922c7ab03 | ||
|
|
5495b01bc5 | ||
|
|
9b103e063a | ||
|
|
7b73aa2908 | ||
|
|
ae75292fd0 | ||
|
|
9a43d49b3b | ||
|
|
3d4d0afa59 | ||
|
|
9aab6c3e08 | ||
|
|
52d6bf3907 | ||
|
|
523800ea39 | ||
|
|
35aea3cab2 | ||
|
|
36cb3f1686 | ||
|
|
e59dd086fc | ||
|
|
607c42cf01 | ||
|
|
87a3e2d022 | ||
|
|
5d17663040 | ||
|
|
cff3c72f94 | ||
|
|
fadf475f06 | ||
|
|
7228499737 | ||
|
|
bca467a634 | ||
|
|
cb72d2ac80 | ||
|
|
3c79607d1f | ||
|
|
36ad1f16e4 | ||
|
|
5d4f334505 | ||
|
|
1fdb5ba748 | ||
|
|
26df6f51ef | ||
|
|
6caf794ae1 | ||
|
|
2692953e31 | ||
|
|
242fd713ab | ||
|
|
7a12c4d5e2 | ||
|
|
f256ef43c0 | ||
|
|
e0cde2d6ff | ||
|
|
e4e77dc0d2 | ||
|
|
8ba6467f21 | ||
|
|
088cb54317 | ||
|
|
c47bcf665d | ||
|
|
bc90c27e27 | ||
|
|
c1be57773a | ||
|
|
315c68912c | ||
|
|
559d203996 | ||
|
|
54522518c3 | ||
|
|
bacb1561d5 | ||
|
|
b8521f926f | ||
|
|
b29672f4b4 | ||
|
|
a32fe6a41f | ||
|
|
0e35135093 | ||
|
|
31ecace083 | ||
|
|
e5b134f8f4 | ||
|
|
139a4863d1 |
@@ -2,5 +2,8 @@
|
||||
[target.'cfg(windows)']
|
||||
rustflags = ["-C", "link-args=/STACK:16777220", "--cfg", "tokio_unstable"]
|
||||
|
||||
[target.x86_64-pc-windows-msvc]
|
||||
linker = "rust-lld"
|
||||
|
||||
[build]
|
||||
rustflags = ["--cfg", "tokio_unstable"]
|
||||
|
||||
1
.dockerignore
Symbolic link
1
.dockerignore
Symbolic link
@@ -0,0 +1 @@
|
||||
.gitignore
|
||||
5
.github/workflows/labrinth-docker.yml
vendored
5
.github/workflows/labrinth-docker.yml
vendored
@@ -18,9 +18,6 @@ on:
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./apps/labrinth
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
@@ -38,8 +35,6 @@ jobs:
|
||||
- name: Build and push
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v2
|
||||
env:
|
||||
SQLX_OFFLINE: true
|
||||
with:
|
||||
file: ./apps/labrinth/Dockerfile
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
152
.github/workflows/theseus-build.yml
vendored
Normal file
152
.github/workflows/theseus-build.yml
vendored
Normal file
@@ -0,0 +1,152 @@
|
||||
name: Modrinth App build
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'v*'
|
||||
paths:
|
||||
- .github/workflows/theseus-build.yml
|
||||
- 'apps/app/**'
|
||||
- 'apps/app-frontend/**'
|
||||
- 'packages/app-lib/**'
|
||||
- 'packages/app-macros/**'
|
||||
- 'packages/assets/**'
|
||||
- 'packages/ui/**'
|
||||
- 'packages/utils/**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
sign-windows-binaries:
|
||||
description: Sign Windows binaries
|
||||
type: boolean
|
||||
default: true
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform: [macos-latest, windows-latest, ubuntu-22.04]
|
||||
include:
|
||||
- platform: macos-latest
|
||||
artifact-target-name: universal-apple-darwin
|
||||
- platform: windows-latest
|
||||
artifact-target-name: x86_64-pc-windows-msvc
|
||||
- platform: ubuntu-22.04
|
||||
artifact-target-name: x86_64-unknown-linux-gnu
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
|
||||
steps:
|
||||
- name: 📥 Check out code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 🧰 Setup Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
rustflags: ''
|
||||
target: ${{ startsWith(matrix.platform, 'macos') && 'x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: 🧰 Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: 🧰 Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: pnpm
|
||||
|
||||
- name: 🧰 Install Linux build dependencies
|
||||
if: startsWith(matrix.platform, 'ubuntu')
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -yq libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev
|
||||
|
||||
- name: 🧰 Setup Dasel
|
||||
uses: jaxxstorm/action-install-gh-release@v2.1.0
|
||||
with:
|
||||
repo: TomWright/dasel
|
||||
tag: v2.8.1
|
||||
extension-matching: disable
|
||||
rename-to: ${{ startsWith(matrix.platform, 'windows') && 'dasel.exe' || 'dasel' }}
|
||||
chmod: 0755
|
||||
|
||||
- name: ⚙️ Set application version and environment
|
||||
shell: bash
|
||||
run: |
|
||||
APP_VERSION="$(git describe --tags --always | sed -E 's/-([0-9]+)-(g[0-9a-fA-F]+)$/-canary+\1.\2/')"
|
||||
echo "Setting application version to $APP_VERSION"
|
||||
dasel put -f apps/app/Cargo.toml -t string -v "${APP_VERSION#v}" 'package.version'
|
||||
dasel put -f packages/app-lib/Cargo.toml -t string -v "${APP_VERSION#v}" 'package.version'
|
||||
dasel put -f apps/app-frontend/package.json -t string -v "${APP_VERSION#v}" 'version'
|
||||
|
||||
cp packages/app-lib/.env.prod packages/app-lib/.env
|
||||
|
||||
- name: 💨 Setup Turbo cache
|
||||
uses: rharkor/caching-for-turbo@v1.8
|
||||
|
||||
- name: 🧰 Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: ✍️ Set up Windows code signing
|
||||
if: startsWith(matrix.platform, 'windows')
|
||||
shell: bash
|
||||
run: |
|
||||
if [ '${{ startsWith(github.ref, 'refs/tags/v') || inputs.sign-windows-binaries }}' = 'true' ]; then
|
||||
choco install jsign --ignore-dependencies # GitHub runners come with a global Java installation already
|
||||
else
|
||||
dasel delete -f apps/app/tauri-release.conf.json 'bundle.windows.signCommand'
|
||||
fi
|
||||
|
||||
- name: 🔨 Build macOS app
|
||||
run: pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-release.conf.json
|
||||
if: startsWith(matrix.platform, 'macos')
|
||||
env:
|
||||
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
|
||||
- name: 🔨 Build Linux app
|
||||
run: pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json
|
||||
if: startsWith(matrix.platform, 'ubuntu')
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
|
||||
- name: 🔨 Build Windows app
|
||||
run: |
|
||||
[System.Convert]::FromBase64String("$env:DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64") | Set-Content -Path signer-client-cert.p12 -AsByteStream
|
||||
$env:DIGICERT_ONE_SIGNER_CREDENTIALS = "$env:DIGICERT_ONE_SIGNER_API_KEY|$PWD\signer-client-cert.p12|$env:DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD"
|
||||
$env:JAVA_HOME = "$env:JAVA_HOME_11_X64"
|
||||
pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json --verbose --bundles 'nsis,updater'
|
||||
Remove-Item -Path signer-client-cert.p12 -ErrorAction SilentlyContinue
|
||||
if: startsWith(matrix.platform, 'windows')
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
DIGICERT_ONE_SIGNER_API_KEY: ${{ secrets.DIGICERT_ONE_SIGNER_API_KEY }}
|
||||
DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64: ${{ secrets.DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64 }}
|
||||
DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD: ${{ secrets.DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD }}
|
||||
|
||||
- name: 📤 Upload app bundles
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: App bundle (${{ matrix.artifact-target-name }})
|
||||
path: |
|
||||
target/release/bundle/appimage/Modrinth App_*.AppImage*
|
||||
target/release/bundle/deb/Modrinth App_*.deb*
|
||||
target/release/bundle/rpm/Modrinth App-*.rpm*
|
||||
target/universal-apple-darwin/release/bundle/macos/Modrinth App.app.tar.gz*
|
||||
target/universal-apple-darwin/release/bundle/dmg/Modrinth App_*.dmg*
|
||||
target/release/bundle/nsis/Modrinth App_*-setup.exe*
|
||||
target/release/bundle/nsis/Modrinth App_*-setup.nsis.zip*
|
||||
272
.github/workflows/theseus-release.yml
vendored
272
.github/workflows/theseus-release.yml
vendored
@@ -1,186 +1,118 @@
|
||||
name: 'Modrinth App build'
|
||||
name: Modrinth App release
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'v*'
|
||||
paths:
|
||||
- .github/workflows/theseus-release.yml
|
||||
- 'apps/app/**'
|
||||
- 'apps/app-frontend/**'
|
||||
- 'packages/app-lib/**'
|
||||
- 'packages/app-macros/**'
|
||||
- 'packages/assets/**'
|
||||
- 'packages/ui/**'
|
||||
- 'packages/utils/**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
sign-windows-binaries:
|
||||
description: Sign Windows binaries
|
||||
type: boolean
|
||||
default: true
|
||||
required: false
|
||||
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:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform: [macos-latest, windows-latest, ubuntu-22.04]
|
||||
release:
|
||||
name: Release Modrinth App
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
env:
|
||||
LINUX_X64_BUNDLE_ARTIFACT_NAME: App bundle (x86_64-unknown-linux-gnu)
|
||||
WINDOWS_X64_BUNDLE_ARTIFACT_NAME: App bundle (x86_64-pc-windows-msvc)
|
||||
MACOS_UNIVERSAL_BUNDLE_ARTIFACT_NAME: App bundle (universal-apple-darwin)
|
||||
LAUNCHER_FILES_BUCKET_BASE_URL: https://launcher-files.modrinth.com
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Rust setup (mac)
|
||||
if: startsWith(matrix.platform, 'macos')
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
- name: 📥 Download Modrinth App artifacts
|
||||
uses: dawidd6/action-download-artifact@v11
|
||||
with:
|
||||
rustflags: ''
|
||||
target: x86_64-apple-darwin
|
||||
workflow: theseus-build.yml
|
||||
workflow_conclusion: success
|
||||
event: push
|
||||
branch: ${{ inputs.version-tag }}
|
||||
use_unzip: true
|
||||
|
||||
- name: Rust setup
|
||||
if: "!startsWith(matrix.platform, 'macos')"
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
rustflags: ''
|
||||
|
||||
- name: Setup rust cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
target/**
|
||||
!target/*/release/bundle/*/*.dmg
|
||||
!target/*/release/bundle/*/*.app.tar.gz
|
||||
!target/*/release/bundle/*/*.app.tar.gz.sig
|
||||
!target/release/bundle/*/*.dmg
|
||||
!target/release/bundle/*/*.app.tar.gz
|
||||
!target/release/bundle/*/*.app.tar.gz.sig
|
||||
|
||||
!target/release/bundle/appimage/*.AppImage
|
||||
!target/release/bundle/appimage/*.AppImage.tar.gz
|
||||
!target/release/bundle/appimage/*.AppImage.tar.gz.sig
|
||||
!target/release/bundle/deb/*.deb
|
||||
!target/release/bundle/rpm/*.rpm
|
||||
|
||||
!target/release/bundle/msi/*.msi
|
||||
!target/release/bundle/msi/*.msi.zip
|
||||
!target/release/bundle/msi/*.msi.zip.sig
|
||||
|
||||
!target/release/bundle/nsis/*.exe
|
||||
!target/release/bundle/nsis/*.nsis.zip
|
||||
!target/release/bundle/nsis/*.nsis.zip.sig
|
||||
key: ${{ runner.os }}-rust-target-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-rust-target-
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Install pnpm via corepack
|
||||
shell: bash
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare --activate
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: install dependencies (ubuntu only)
|
||||
if: startsWith(matrix.platform, 'ubuntu')
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev pkg-config libayatana-appindicator3-dev librsvg2-dev
|
||||
|
||||
- name: Install code signing client (Windows only)
|
||||
if: startsWith(matrix.platform, 'windows')
|
||||
run: choco install jsign --ignore-dependencies # GitHub runners come with a global Java installation already
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Disable Windows code signing for non-final release builds
|
||||
if: ${{ startsWith(matrix.platform, 'windows') && !startsWith(github.ref, 'refs/tags/v') && !inputs.sign-windows-binaries }}
|
||||
run: |
|
||||
jq 'del(.bundle.windows.signCommand)' apps/app/tauri-release.conf.json > apps/app/tauri-release.conf.json.new
|
||||
Move-Item -Path apps/app/tauri-release.conf.json.new -Destination apps/app/tauri-release.conf.json -Force
|
||||
|
||||
- name: build app (macos)
|
||||
run: pnpm --filter=@modrinth/app run tauri build --target universal-apple-darwin --config tauri-release.conf.json
|
||||
if: startsWith(matrix.platform, 'macos')
|
||||
- name: 🛠️ Generate version manifest
|
||||
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 (Linux)
|
||||
run: pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json
|
||||
if: startsWith(matrix.platform, 'ubuntu')
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
|
||||
- name: build app (Windows)
|
||||
VERSION_TAG: ${{ inputs.version-tag }}
|
||||
RELEASE_NOTES: ${{ inputs.release-notes }}
|
||||
run: |
|
||||
[System.Convert]::FromBase64String("$env:DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64") | Set-Content -Path signer-client-cert.p12 -AsByteStream
|
||||
$env:DIGICERT_ONE_SIGNER_CREDENTIALS = "$env:DIGICERT_ONE_SIGNER_API_KEY|$PWD\signer-client-cert.p12|$env:DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD"
|
||||
$env:JAVA_HOME = "$env:JAVA_HOME_11_X64"
|
||||
pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json --verbose --bundles 'nsis,updater'
|
||||
Remove-Item -Path signer-client-cert.p12
|
||||
if: startsWith(matrix.platform, 'windows')
|
||||
# Reference: https://tauri.app/plugin/updater/#server-support
|
||||
jq -nc \
|
||||
--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}:"
|
||||
cat updates.json
|
||||
|
||||
- name: 📤 Upload release artifacts
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
DIGICERT_ONE_SIGNER_API_KEY: ${{ secrets.DIGICERT_ONE_SIGNER_API_KEY }}
|
||||
DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64: ${{ secrets.DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_BASE64 }}
|
||||
DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD: ${{ secrets.DIGICERT_ONE_SIGNER_CLIENT_CERTIFICATE_PASSWORD }}
|
||||
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: |
|
||||
for macosBundleType in 'macos' 'dmg'; do
|
||||
aws s3 cp --recursive \
|
||||
"${MACOS_UNIVERSAL_BUNDLE_ARTIFACT_NAME}/universal-apple-darwin/release/bundle/${macosBundleType}" \
|
||||
"s3://${AWS_BUCKET}/versions/${VERSION_TAG#v}/macos"
|
||||
done
|
||||
|
||||
- name: upload ${{ matrix.platform }}
|
||||
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
|
||||
for linuxBundleType in 'appimage' 'deb' 'rpm'; do
|
||||
aws s3 cp --recursive \
|
||||
"${LINUX_X64_BUNDLE_ARTIFACT_NAME}/release/bundle/${linuxBundleType}" \
|
||||
"s3://${AWS_BUCKET}/versions/${VERSION_TAG#v}/linux"
|
||||
done
|
||||
|
||||
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
|
||||
for windowsBundleType in 'nsis'; do
|
||||
aws s3 cp --recursive \
|
||||
"${WINDOWS_X64_BUNDLE_ARTIFACT_NAME}/release/bundle/${windowsBundleType}" \
|
||||
"s3://${AWS_BUCKET}/versions/${VERSION_TAG#v}/windows"
|
||||
done
|
||||
|
||||
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
|
||||
aws s3 cp updates.json "s3://${AWS_BUCKET}"
|
||||
|
||||
9
.github/workflows/turbo-ci.yml
vendored
9
.github/workflows/turbo-ci.yml
vendored
@@ -74,5 +74,14 @@ jobs:
|
||||
cp .env.local .env
|
||||
sqlx database setup
|
||||
|
||||
- name: ⚙️ Set app environment
|
||||
working-directory: packages/app-lib
|
||||
run: cp .env.staging .env
|
||||
|
||||
- name: 🔍 Lint and test
|
||||
run: pnpm run ci
|
||||
|
||||
- name: 🔍 Verify intl:extract has been run
|
||||
run: |
|
||||
pnpm intl:extract
|
||||
git diff --exit-code --color */*/src/locales/en-US/index.json
|
||||
|
||||
118
Cargo.lock
generated
118
Cargo.lock
generated
@@ -1706,7 +1706,7 @@ dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"core-foundation 0.10.0",
|
||||
"core-graphics-types",
|
||||
"foreign-types 0.5.0",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -2699,15 +2699,6 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||
dependencies = [
|
||||
"foreign-types-shared 0.1.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.5.0"
|
||||
@@ -2715,7 +2706,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
||||
dependencies = [
|
||||
"foreign-types-macros",
|
||||
"foreign-types-shared 0.3.1",
|
||||
"foreign-types-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2729,12 +2720,6 @@ dependencies = [
|
||||
"syn 2.0.101",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.3.1"
|
||||
@@ -3678,11 +3663,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.5"
|
||||
version = "0.27.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d191583f3da1305256f22463b9bb0471acad48a4e534a5218b9963e9c1f59b2"
|
||||
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"http 1.3.1",
|
||||
"hyper 1.6.0",
|
||||
"hyper-util",
|
||||
@@ -3692,7 +3676,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.2",
|
||||
"tower-service",
|
||||
"webpki-roots 0.26.11",
|
||||
"webpki-roots 1.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3708,22 +3692,6 @@ dependencies = [
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-tls"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http-body-util",
|
||||
"hyper 1.6.0",
|
||||
"hyper-util",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.14"
|
||||
@@ -4389,7 +4357,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"hex",
|
||||
"hmac",
|
||||
"hyper-tls",
|
||||
"hyper-rustls 0.27.7",
|
||||
"hyper-util",
|
||||
"iana-time-zone",
|
||||
"image",
|
||||
@@ -4986,23 +4954,6 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"openssl",
|
||||
"openssl-probe",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework 2.11.1",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
@@ -5577,50 +5528,12 @@ dependencies = [
|
||||
"pathdiff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.72"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"cfg-if",
|
||||
"foreign-types 0.3.2",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.108"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e145e1651e858e820e4860f7b9c5e169bc1d8ce1c86043be79fa7b7634821847"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
@@ -6850,7 +6763,7 @@ dependencies = [
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
"hyper 1.6.0",
|
||||
"hyper-rustls 0.27.5",
|
||||
"hyper-rustls 0.27.7",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
@@ -7980,7 +7893,7 @@ dependencies = [
|
||||
"bytemuck",
|
||||
"cfg_aliases",
|
||||
"core-graphics",
|
||||
"foreign-types 0.5.0",
|
||||
"foreign-types",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2 0.5.2",
|
||||
@@ -8999,7 +8912,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "theseus"
|
||||
version = "0.10.1"
|
||||
version = "1.0.0-local"
|
||||
dependencies = [
|
||||
"ariadne",
|
||||
"async-compression",
|
||||
@@ -9017,6 +8930,7 @@ dependencies = [
|
||||
"data-url",
|
||||
"dirs",
|
||||
"discord-rich-presence",
|
||||
"dotenvy",
|
||||
"dunce",
|
||||
"either",
|
||||
"encoding_rs",
|
||||
@@ -9064,7 +8978,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "theseus_gui"
|
||||
version = "0.10.1"
|
||||
version = "1.0.0-local"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"daedalus",
|
||||
@@ -9292,16 +9206,6 @@ dependencies = [
|
||||
"syn 2.0.101",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-native-tls"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||
dependencies = [
|
||||
"native-tls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.24.1"
|
||||
|
||||
@@ -67,7 +67,12 @@ heck = "0.5.0"
|
||||
hex = "0.4.3"
|
||||
hickory-resolver = "0.25.2"
|
||||
hmac = "0.12.1"
|
||||
hyper-tls = "0.6.0"
|
||||
hyper-rustls = { version = "0.27.7", default-features = false, features = [
|
||||
"http1",
|
||||
"native-tokio",
|
||||
"ring",
|
||||
"tls12",
|
||||
] }
|
||||
hyper-util = "0.1.14"
|
||||
iana-time-zone = "0.1.63"
|
||||
image = { version = "0.25.6", default-features = false, features = ["rayon"] }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@modrinth/app-frontend",
|
||||
"private": true,
|
||||
"version": "0.10.1",
|
||||
"version": "1.0.0-local",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -9,7 +9,7 @@
|
||||
"tsc:check": "vue-tsc --noEmit",
|
||||
"lint": "eslint . && prettier --check .",
|
||||
"fix": "eslint . --fix && prettier --write .",
|
||||
"intl:extract": "formatjs extract \"{,src/components,src/composables,src/helpers,src/pages,src/store}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore '**/*.d.ts' --ignore 'node_modules' --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
|
||||
"intl:extract": "formatjs extract \"src/**/*.{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": {
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref, watch, provide } from 'vue'
|
||||
import {
|
||||
computed,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
watch,
|
||||
useTemplateRef,
|
||||
provide,
|
||||
nextTick,
|
||||
} from 'vue'
|
||||
import { RouterView, useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
ArrowBigUpDashIcon,
|
||||
@@ -33,7 +42,7 @@ import { useLoading, useTheming } from '@/store/state'
|
||||
import ModrinthAppLogo from '@/assets/modrinth_app.svg?component'
|
||||
import AccountsCard from '@/components/ui/AccountsCard.vue'
|
||||
import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue'
|
||||
import { get } from '@/helpers/settings.ts'
|
||||
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
|
||||
import RunningAppBar from '@/components/ui/RunningAppBar.vue'
|
||||
import SplashScreen from '@/components/ui/SplashScreen.vue'
|
||||
@@ -42,7 +51,7 @@ import ModrinthLoadingIndicator from '@/components/LoadingIndicatorBar.vue'
|
||||
import { handleError, useNotifications } from '@/store/notifications.js'
|
||||
import { command_listener, warning_listener } from '@/helpers/events.js'
|
||||
import { type } from '@tauri-apps/plugin-os'
|
||||
import { getOS, isDev, restartApp } from '@/helpers/utils.js'
|
||||
import { areUpdatesEnabled, getOS, isDev } from '@/helpers/utils.js'
|
||||
import { debugAnalytics, initAnalytics, optOutAnalytics, trackEvent } from '@/helpers/analytics'
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||
import { getVersion } from '@tauri-apps/api/app'
|
||||
@@ -59,7 +68,6 @@ import { get_opening_command, initialize_state } from '@/helpers/state'
|
||||
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
|
||||
import { renderString } from '@modrinth/utils'
|
||||
import { useFetch } from '@/helpers/fetch.js'
|
||||
import { check } from '@tauri-apps/plugin-updater'
|
||||
import NavButton from '@/components/ui/NavButton.vue'
|
||||
import { get as getCreds, login, logout } from '@/helpers/mr_auth.js'
|
||||
import { get_user } from '@/helpers/cache.js'
|
||||
@@ -69,8 +77,11 @@ import { hide_ads_window, init_ads_window } from '@/helpers/ads.js'
|
||||
import FriendsList from '@/components/ui/friends/FriendsList.vue'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
|
||||
import UpdateModal from '@/components/ui/UpdateModal.vue'
|
||||
import { get_available_capes, get_available_skins } from './helpers/skins'
|
||||
import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import { createTooltip, destroyTooltip } from 'floating-vue'
|
||||
|
||||
const themeStore = useTheming()
|
||||
|
||||
@@ -109,6 +120,18 @@ onUnmounted(() => {
|
||||
document.querySelector('body').removeEventListener('auxclick', handleAuxClick)
|
||||
})
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
updateInstalledToastTitle: {
|
||||
id: 'app.update.complete-toast.title',
|
||||
defaultMessage: 'Version {version} was successfully installed!',
|
||||
},
|
||||
updateInstalledToastText: {
|
||||
id: 'app.update.complete-toast.text',
|
||||
defaultMessage: 'Click here to view the changelog.',
|
||||
},
|
||||
})
|
||||
|
||||
async function setupApp() {
|
||||
stateInitialized.value = true
|
||||
const {
|
||||
@@ -122,7 +145,8 @@ async function setupApp() {
|
||||
toggle_sidebar,
|
||||
developer_mode,
|
||||
feature_flags,
|
||||
} = await get()
|
||||
pending_update_toast_for_version,
|
||||
} = await getSettings()
|
||||
|
||||
if (default_page === 'Library') {
|
||||
await router.push('/library')
|
||||
@@ -209,7 +233,6 @@ async function setupApp() {
|
||||
})
|
||||
|
||||
get_opening_command().then(handleCommand)
|
||||
checkUpdates()
|
||||
fetchCredentials()
|
||||
|
||||
try {
|
||||
@@ -219,6 +242,22 @@ async function setupApp() {
|
||||
} catch (error) {
|
||||
console.warn('Failed to generate skin previews in app setup.', error)
|
||||
}
|
||||
|
||||
if (pending_update_toast_for_version !== null) {
|
||||
const settings = await getSettings()
|
||||
settings.pending_update_toast_for_version = null
|
||||
await setSettings(settings)
|
||||
|
||||
const version = await getVersion()
|
||||
if (pending_update_toast_for_version === version) {
|
||||
notifications.addNotification({
|
||||
type: 'success',
|
||||
title: formatMessage(messages.updateInstalledToastTitle, { version }),
|
||||
text: formatMessage(messages.updateInstalledToastText),
|
||||
clickAction: () => openUrl('https://modrinth.com/news/changelog?filter=app'),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const stateFailed = ref(false)
|
||||
@@ -346,19 +385,95 @@ async function handleCommand(e) {
|
||||
}
|
||||
}
|
||||
|
||||
const updateAvailable = ref(false)
|
||||
const availableUpdate = ref(null)
|
||||
const updateSkipped = ref(false)
|
||||
const enqueuedUpdate = ref(null)
|
||||
const updateModal = useTemplateRef('updateModal')
|
||||
async function checkUpdates() {
|
||||
const update = await check()
|
||||
updateAvailable.value = !!update
|
||||
if (!(await areUpdatesEnabled())) {
|
||||
console.log('Skipping update check as updates are disabled in this build')
|
||||
return
|
||||
}
|
||||
|
||||
async function performCheck() {
|
||||
if (updateModal.value.isOpen) {
|
||||
console.log('Skipping update check because the update modal is already open')
|
||||
return
|
||||
}
|
||||
|
||||
const update = await invoke('plugin:updater|check')
|
||||
if (!update) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`Update ${update.version} is available.`)
|
||||
|
||||
if (update.version === availableUpdate.value?.version) {
|
||||
console.log(
|
||||
'Skipping update modal because the new version is the same as the dismissed update',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
availableUpdate.value = update
|
||||
|
||||
const settings = await getSettings()
|
||||
if (settings.skipped_update === update.version) {
|
||||
updateSkipped.value = true
|
||||
console.log('Skipping update modal because the user chose to skip this update')
|
||||
return
|
||||
}
|
||||
|
||||
updateSkipped.value = false
|
||||
updateModal.value.show(update)
|
||||
}
|
||||
|
||||
await performCheck()
|
||||
setTimeout(
|
||||
() => {
|
||||
checkUpdates()
|
||||
},
|
||||
5 * 1000 * 60,
|
||||
5 * 60 * 1000,
|
||||
)
|
||||
}
|
||||
|
||||
async function skipUpdate(version) {
|
||||
enqueuedUpdate.value = null
|
||||
|
||||
updateSkipped.value = true
|
||||
const settings = await getSettings()
|
||||
settings.skipped_update = version
|
||||
await setSettings(settings)
|
||||
}
|
||||
|
||||
async function updateEnqueuedForLater(version) {
|
||||
enqueuedUpdate.value = version
|
||||
}
|
||||
|
||||
async function forceOpenUpdateModal() {
|
||||
if (updateSkipped.value) {
|
||||
updateSkipped.value = false
|
||||
const settings = await getSettings()
|
||||
settings.skipped_update = null
|
||||
await setSettings(settings)
|
||||
}
|
||||
updateModal.value.show(availableUpdate.value)
|
||||
}
|
||||
|
||||
const updateButton = useTemplateRef('updateButton')
|
||||
async function showUpdateButtonTooltip() {
|
||||
await nextTick()
|
||||
const tooltip = createTooltip(updateButton.value.$el, {
|
||||
placement: 'right',
|
||||
content: 'Click here to view the update again.',
|
||||
})
|
||||
tooltip.show()
|
||||
setTimeout(() => {
|
||||
tooltip.hide()
|
||||
destroyTooltip(updateButton.value.$el)
|
||||
}, 3500)
|
||||
}
|
||||
|
||||
function handleClick(e) {
|
||||
let target = e.target
|
||||
while (target != null) {
|
||||
@@ -399,6 +514,14 @@ function handleAuxClick(e) {
|
||||
<SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region />
|
||||
<div id="teleports"></div>
|
||||
<div v-if="stateInitialized" class="app-grid-layout experimental-styles-within relative">
|
||||
<Suspense @resolve="checkUpdates">
|
||||
<UpdateModal
|
||||
ref="updateModal"
|
||||
@update-skipped="skipUpdate"
|
||||
@update-enqueued-for-later="updateEnqueuedForLater"
|
||||
@modal-hidden="showUpdateButtonTooltip"
|
||||
/>
|
||||
</Suspense>
|
||||
<Suspense>
|
||||
<AppSettingsModal ref="settingsModal" />
|
||||
</Suspense>
|
||||
@@ -449,8 +572,18 @@ function handleAuxClick(e) {
|
||||
<PlusIcon />
|
||||
</NavButton>
|
||||
<div class="flex flex-grow"></div>
|
||||
<NavButton v-if="updateAvailable" v-tooltip.right="'Install update'" :to="() => restartApp()">
|
||||
<DownloadIcon />
|
||||
<NavButton
|
||||
v-if="!!availableUpdate"
|
||||
ref="updateButton"
|
||||
v-tooltip.right="
|
||||
enqueuedUpdate === availableUpdate?.version
|
||||
? 'Update installation queued for next restart'
|
||||
: 'Update available'
|
||||
"
|
||||
:to="forceOpenUpdateModal"
|
||||
>
|
||||
<DownloadIcon v-if="updateSkipped || enqueuedUpdate === availableUpdate?.version" />
|
||||
<DownloadIcon v-else class="text-brand-green" />
|
||||
</NavButton>
|
||||
<NavButton v-tooltip.right="'Settings'" :to="() => $refs.settingsModal.show()">
|
||||
<SettingsIcon />
|
||||
@@ -485,13 +618,13 @@ function handleAuxClick(e) {
|
||||
<ModrinthAppLogo class="h-full w-auto text-contrast pointer-events-none" />
|
||||
<div class="flex items-center gap-1 ml-3">
|
||||
<button
|
||||
class="cursor-pointer p-0 m-0 border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
|
||||
class="cursor-pointer p-0 m-0 text-contrast border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
|
||||
@click="router.back()"
|
||||
>
|
||||
<LeftArrowIcon />
|
||||
</button>
|
||||
<button
|
||||
class="cursor-pointer p-0 m-0 border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
|
||||
class="cursor-pointer p-0 m-0 text-contrast border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
|
||||
@click="router.forward()"
|
||||
>
|
||||
<RightArrowIcon />
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"asset":{"version":"2.0","generator":"Blockbench 4.12.4 glTF exporter"},"scenes":[{"nodes":[1],"name":"blockbench_export"}],"scene":0,"nodes":[{"rotation":[0,0,0.19509032201612825,0.9807852804032304],"translation":[0.15625,1,0],"name":"Cape","mesh":0},{"children":[0]}],"bufferViews":[{"buffer":0,"byteOffset":0,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":288,"byteLength":288,"target":34962,"byteStride":12},{"buffer":0,"byteOffset":576,"byteLength":192,"target":34962,"byteStride":8},{"buffer":0,"byteOffset":768,"byteLength":72,"target":34963}],"buffers":[{"byteLength":840,"uri":"data:application/octet-stream;base64,AAAAPQAAAAAAAKA+AAAAPQAAAAAAAKC+AAAAPQAAgL8AAKA+AAAAPQAAgL8AAKC+AAAAvQAAAAAAAKC+AAAAvQAAAAAAAKA+AAAAvQAAgL8AAKC+AAAAvQAAgL8AAKA+AAAAvQAAAAAAAKC+AAAAPQAAAAAAAKC+AAAAvQAAAAAAAKA+AAAAPQAAAAAAAKA+AAAAvQAAgL8AAKA+AAAAPQAAgL8AAKA+AAAAvQAAgL8AAKC+AAAAPQAAgL8AAKC+AAAAvQAAAAAAAKA+AAAAPQAAAAAAAKA+AAAAvQAAgL8AAKA+AAAAPQAAgL8AAKA+AAAAPQAAAAAAAKC+AAAAvQAAAAAAAKC+AAAAPQAAgL8AAKC+AAAAvQAAgL8AAKC+AACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AACAPAAAgD0AADA+AACAPQAAgDwAAAg/AAAwPgAACD8AAEA+AACAPQAAsD4AAIA9AABAPgAACD8AALA+AAAIPwAAgDwAAAA9AACAPAAAgD0AADA+AAAAPQAAMD4AAIA9AAAwPgAAAD0AAKg+AAAAPQAAMD4AAAAAAACoPgAAAAAAAEA+AACAPQAAMD4AAIA9AABAPgAACD8AADA+AAAIPwAAAAAAAIA9AACAPAAAgD0AAAAAAAAIPwAAgDwAAAg/AAACAAEAAgADAAEABAAGAAUABgAHAAUACAAKAAkACgALAAkADAAOAA0ADgAPAA0AEAASABEAEgATABEAFAAWABUAFgAXABUA"}],"accessors":[{"bufferView":0,"componentType":5126,"count":24,"max":[0.03125,0,0.3125],"min":[-0.03125,-1,-0.3125],"type":"VEC3"},{"bufferView":1,"componentType":5126,"count":24,"max":[1,1,1],"min":[-1,-1,-1],"type":"VEC3"},{"bufferView":2,"componentType":5126,"count":24,"max":[0.34375,0.53125],"min":[0,0],"type":"VEC2"},{"bufferView":3,"componentType":5123,"count":36,"max":[23],"min":[0],"type":"SCALAR"}],"materials":[{"pbrMetallicRoughness":{"metallicFactor":0,"roughnessFactor":1,"baseColorTexture":{"index":0}},"alphaMode":"MASK","alphaCutoff":0.05,"doubleSided":true}],"textures":[{"sampler":0,"source":0,"name":"cape.png"}],"samplers":[{"magFilter":9728,"minFilter":9728,"wrapS":33071,"wrapT":33071}],"images":[{"mimeType":"image/png","uri":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAAAgCAYAAACinX6EAAAAAXNSR0IArs4c6QAABCRJREFUaEPtlktoE1EUhm/sIzFpYsBgHyn2oRZt0C5ESxWFYutKNyKIWBBR6kpBd3Ur2J0FRdCiuKmIIG4qLnzgQqTUXSlVqdoXTU0lljRpYtOHI/8dznBnOm1mphMwJWeTzJ0795zznf/cex2MMXam2S/ht38siJ8V1lgd5mOBARerc3rZnmK37rwvCyk2nE6wezMRh+4EncH9B1ql+fkU64rPsaBz5bqh4T7Daxn1Kc5zVNeEePJIci0Aq71bzenY6JChwAnA0OBHQ/OtJLnWNwqAy61R1tO3k891ueRKoDKwtqbv7MGbgCnfRgHQoqG9hyX4hc/yiho+/HNqVPFtdj2jwTpQgd/RKT7/0ZUku/o4qAJw50KYXbzr4e+3BioYzc3kwGzAUCLWFw2+UBiCb3bNTDHiPQeAP3CGNmg/6VcSBpDu3hhvDQpOCwABwrQKsRIsqYAChy/EQAXwlPiZ3a3igNPkXIyTjr8o5L7PPdzGf59c+sV/faeWeIIIAHPJ8M3kc7l1K09LKghWAIgqoPZ7djPFTlxb4D6yAgBOQfntrUVWVeRk44tplXJorOVGkVIJTBCTpw9ECFYBIEkyMfmsAXh3u1qi5MlxbbGX/x1ZSCjBAAwgfPr6h49R5UVavk0FXC2wju5p07s6iqEF0PtqSlEf+bKzDRwdgaDU7AkoySJ5Oo/D6ZRq/H0yyuJ/lxkSheG/FgCNm7kL0BoEAG32sqtY1YIHd2/mGzTMVgD3y2slqjgW9xY6ma9AThAARIMiBtOpjADwTWc0bEkB+FZsSTxTW0KBsGPXx0yvrUpEeHAAQIM7wBJLcu8DwHaP/H8i6VSND6SiSjBlRW4WWUypVIBbIgzjVgB0tpdKqLReS0KVPTMTfH0ra2cEgAmAAEd+l1z52LybqwBQYAQAyZPh6gtDW4jjuC4fHx8wXSm0JDbeI95SRYHiFZlUaWVtPQiKAugl5E8ASAUYiy8vc0C474uGasPE5PHc4g0wK/f4obom6UNimol7kTZwQLANwOuqBokqDEeQf4lfvvnNxZJcBTBAGZplWQcQ3tcgwY9oWlXinRW4ugoAgNAWWe6ocn1QvgyRTUb4RZFVljlY/3hSBYCqb6cCZo8ekuATVRZPI/gQW8FWAI1VHganVgDQUajdA6y2gAgAcZFBjTBSpG0AcAqc3VVmCMDTbxGWZvIRCaMNkJ7pFMCzVQD4liCQ8kRFUlvaBkCvL+wYw2ZmNUgCgLajFhRPJlv3ADuS1VtjPQCk823S574ffN/x1dQqy8dHR5SN2Spcbaymz2mjwNYDAD4IQn3TDpVLQIAqNjwAANRrAdoI/3sAOF7Xe1khCNQGVH1bL0JGJb1R52VtD8gVYHkAuVKpbMWZV0C2yObKunkF5EqlshVnXgHZIpsr6/4DlbxcPydnT74AAAAASUVORK5CYII="}],"meshes":[{"primitives":[{"mode":4,"attributes":{"POSITION":0,"NORMAL":1,"TEXCOORD_0":2},"indices":3,"material":0}]}]}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -136,7 +136,7 @@ const filteredResults = computed(() => {
|
||||
|
||||
if (sortBy.value === 'Game version') {
|
||||
instances.sort((a, b) => {
|
||||
return a.game_version.localeCompare(b.game_version)
|
||||
return a.game_version.localeCompare(b.game_version, undefined, { numeric: true })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -213,6 +213,17 @@ const filteredResults = computed(() => {
|
||||
instanceMap.set(entry[0], entry[1])
|
||||
})
|
||||
}
|
||||
// default sorting would do 1.20.4 < 1.8.9 because 2 < 8
|
||||
// localeCompare with numeric=true puts 1.8.9 < 1.20.4 because 8 < 20
|
||||
if (group.value === 'Game version') {
|
||||
const sortedEntries = [...instanceMap.entries()].sort((a, b) => {
|
||||
return a[0].localeCompare(b[0], undefined, { numeric: true })
|
||||
})
|
||||
instanceMap.clear()
|
||||
sortedEntries.forEach((entry) => {
|
||||
instanceMap.set(entry[0], entry[1])
|
||||
})
|
||||
}
|
||||
|
||||
return instanceMap
|
||||
})
|
||||
|
||||
@@ -108,7 +108,6 @@ async function testJava() {
|
||||
testingJava.value = true
|
||||
testingJavaSuccess.value = await test_jre(
|
||||
props.modelValue ? props.modelValue.path : '',
|
||||
1,
|
||||
props.version,
|
||||
)
|
||||
testingJava.value = false
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
<template>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-bar__fill" :style="{ width: `${progress}%` }"></div>
|
||||
<div
|
||||
class="progress-bar__fill"
|
||||
:style="{
|
||||
width: `${progress}%`,
|
||||
'background-color': error ? 'var(--color-red)' : 'var(--color-brand)',
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -13,6 +19,10 @@ defineProps({
|
||||
return value >= 0 && value <= 100
|
||||
},
|
||||
},
|
||||
error: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -27,7 +37,6 @@ defineProps({
|
||||
|
||||
.progress-bar__fill {
|
||||
height: 100%;
|
||||
background-color: var(--color-brand);
|
||||
transition: width 0.3s;
|
||||
}
|
||||
</style>
|
||||
|
||||
303
apps/app-frontend/src/components/ui/UpdateModal.vue
Normal file
303
apps/app-frontend/src/components/ui/UpdateModal.vue
Normal file
@@ -0,0 +1,303 @@
|
||||
<template>
|
||||
<ModalWrapper ref="modal" hide-header :closable="false" :on-hide="onHide">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="w-[500px]">
|
||||
<div class="font-extrabold text-contrast text-xl">
|
||||
{{ formatMessage(messages.header) }} Modrinth App v{{ update!.version }}
|
||||
</div>
|
||||
<template v-if="!downloadInProgress && !downloadError">
|
||||
<div class="mb-1 leading-tight">{{ formatMessage(messages.bodyVersion) }}</div>
|
||||
<div class="text-sm text-secondary mb-2">
|
||||
{{ formatMessage(messages.downloadSize, { size: formatBytes(updateSize) }) }}
|
||||
</div>
|
||||
</template>
|
||||
<AppearingProgressBar
|
||||
v-if="!downloadError"
|
||||
:max-value="shouldShowProgress ? updateSize || 0 : 0"
|
||||
:current-value="shouldShowProgress ? downloadedBytes : 0"
|
||||
color="green"
|
||||
class="w-full mb-4 mt-2"
|
||||
/>
|
||||
<div v-if="downloadError" class="leading-tight">
|
||||
<div class="text-red font-medium mb-4">
|
||||
{{ formatMessage(messages.downloadError) }}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="installUpdateNow">
|
||||
<DownloadIcon />
|
||||
{{ formatMessage(messages.tryAgain) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="copyError">
|
||||
<ClipboardCopyIcon />
|
||||
{{
|
||||
copiedError
|
||||
? formatMessage(messages.copiedError)
|
||||
: formatMessage(messages.copyError)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<a href="https://support.modrinth.com"><ChatIcon /> Get support</a>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!downloadError" class="flex flex-wrap gap-2 w-full">
|
||||
<JoinedButtons
|
||||
:actions="installActions"
|
||||
:disabled="updatingImmediately || downloadInProgress"
|
||||
color="brand"
|
||||
/>
|
||||
<div>
|
||||
<ButtonStyled>
|
||||
<button @click="() => openUrl('https://modrinth.com/news/changelog?filter=app')">
|
||||
<ExternalIcon /> {{ formatMessage(messages.changelog) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import { useTemplateRef, ref, computed } from 'vue'
|
||||
import { AppearingProgressBar, ButtonStyled, JoinedButtons } from '@modrinth/ui'
|
||||
import type { JoinedButtonAction } from '@modrinth/ui'
|
||||
import { ExternalIcon, DownloadIcon, RedoIcon, ClipboardCopyIcon, XIcon } from '@modrinth/assets'
|
||||
import { enqueueUpdateForInstallation, getUpdateSize } from '@/helpers/utils'
|
||||
import { formatBytes } from '@modrinth/utils'
|
||||
import { handleError } from '@/store/notifications'
|
||||
import { loading_listener } from '@/helpers/events'
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { ChatIcon } from '@/assets/icons'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'updateEnqueuedForLater', version: string | null): Promise<void>
|
||||
(e: 'modalHidden'): void
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'app.update.modal-header',
|
||||
defaultMessage: 'Update available - ',
|
||||
},
|
||||
copiedError: {
|
||||
id: 'app.update.copied-error',
|
||||
defaultMessage: 'Copied to clipboard!',
|
||||
},
|
||||
bodyVersion: {
|
||||
id: 'app.update.modal-body-version',
|
||||
defaultMessage:
|
||||
'We recommend updating as soon as possible so you can enjoy the latest features, fixes, and improvements.',
|
||||
},
|
||||
downloadSize: {
|
||||
id: 'app.update.download-size',
|
||||
defaultMessage: 'The update is {size}.',
|
||||
},
|
||||
changelog: {
|
||||
id: 'app.update.changelog',
|
||||
defaultMessage: 'View changelog',
|
||||
},
|
||||
restartNow: {
|
||||
id: 'app.update.restart',
|
||||
defaultMessage: 'Update now',
|
||||
},
|
||||
later: {
|
||||
id: 'app.update.later',
|
||||
defaultMessage: 'Update on exit',
|
||||
},
|
||||
downloadError: {
|
||||
id: 'app.update.download-error',
|
||||
defaultMessage:
|
||||
'An error occurred while downloading the update. Please try again later. Contact support if the issue persists.',
|
||||
},
|
||||
copyError: {
|
||||
id: 'app.update.copy-error',
|
||||
defaultMessage: 'Copy error',
|
||||
},
|
||||
tryAgain: {
|
||||
id: 'app.update.try-again',
|
||||
defaultMessage: 'Try again',
|
||||
},
|
||||
hide: {
|
||||
id: 'app.update.hide',
|
||||
defaultMessage: 'Hide update reminder',
|
||||
},
|
||||
})
|
||||
|
||||
type UpdateData = {
|
||||
rid: number
|
||||
currentVersion: string
|
||||
version: string
|
||||
date?: string
|
||||
body?: string
|
||||
rawJson: Record<string, unknown>
|
||||
}
|
||||
|
||||
const update = ref<UpdateData>()
|
||||
const updateSize = ref<number>()
|
||||
|
||||
const updatingImmediately = ref(false)
|
||||
const downloadInProgress = ref(false)
|
||||
const downloadProgress = ref(0)
|
||||
const copiedError = ref(false)
|
||||
const downloadError = ref<Error | null>(null)
|
||||
|
||||
const enqueuedUpdate = ref<string | null>(null)
|
||||
|
||||
const installActions = computed<JoinedButtonAction[]>(() => [
|
||||
{
|
||||
id: 'install-now',
|
||||
label: formatMessage(messages.restartNow),
|
||||
icon: DownloadIcon,
|
||||
action: installUpdateNow,
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
id: 'install-later',
|
||||
label: formatMessage(messages.later),
|
||||
icon: RedoIcon,
|
||||
action: updateAtNextExit,
|
||||
},
|
||||
{
|
||||
id: 'hide',
|
||||
label: formatMessage(messages.hide),
|
||||
action: () => {
|
||||
hide()
|
||||
emit('modalHidden')
|
||||
},
|
||||
icon: XIcon,
|
||||
},
|
||||
])
|
||||
|
||||
const downloadedBytes = computed(() => {
|
||||
return updateSize.value ? Math.round((downloadProgress.value / 100) * updateSize.value) : 0
|
||||
})
|
||||
|
||||
const shouldShowProgress = computed(() => {
|
||||
return downloadInProgress.value || updatingImmediately.value
|
||||
})
|
||||
|
||||
const modal = useTemplateRef('modal')
|
||||
const isOpen = ref(false)
|
||||
|
||||
async function show(newUpdate: UpdateData) {
|
||||
const oldVersion = update.value?.version
|
||||
|
||||
update.value = newUpdate
|
||||
updateSize.value = await getUpdateSize(newUpdate.rid).catch(handleError)
|
||||
|
||||
if (oldVersion !== update.value?.version) {
|
||||
downloadProgress.value = 0
|
||||
}
|
||||
|
||||
modal.value!.show(new MouseEvent('click'))
|
||||
isOpen.value = true
|
||||
}
|
||||
|
||||
function onHide() {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value!.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide, isOpen })
|
||||
|
||||
async function copyError() {
|
||||
if (downloadError.value) {
|
||||
copiedError.value = true
|
||||
const errorData = {
|
||||
message: downloadError.value.message,
|
||||
stack: downloadError.value.stack,
|
||||
name: downloadError.value.name,
|
||||
timestamp: new Date().toISOString(),
|
||||
updateVersion: update.value?.version,
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
copiedError.value = false
|
||||
}, 3000)
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(JSON.stringify(errorData, null, 2))
|
||||
} catch (e) {
|
||||
console.error('Failed to copy error to clipboard:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Migrate to common events.ts helper when events/listeners are refactored
|
||||
interface LoadingListenerEvent {
|
||||
event: {
|
||||
type: 'launcher_update'
|
||||
version: string
|
||||
}
|
||||
fraction?: number
|
||||
}
|
||||
|
||||
loading_listener((event: LoadingListenerEvent) => {
|
||||
if (event.event.type === 'launcher_update') {
|
||||
if (event.event.version === update.value!.version) {
|
||||
downloadProgress.value = (event.fraction ?? 1.0) * 100
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function installUpdateNow() {
|
||||
updatingImmediately.value = true
|
||||
|
||||
if (enqueuedUpdate.value !== update.value!.version) {
|
||||
downloadUpdate()
|
||||
} else if (!downloadInProgress.value) {
|
||||
// Update already downloaded. Simply close the app
|
||||
getCurrentWindow().close()
|
||||
}
|
||||
}
|
||||
|
||||
function updateAtNextExit() {
|
||||
enqueuedUpdate.value = update.value!.version
|
||||
emit('updateEnqueuedForLater', update.value!.version)
|
||||
|
||||
downloadUpdate()
|
||||
hide()
|
||||
}
|
||||
|
||||
async function downloadUpdate() {
|
||||
downloadError.value = null
|
||||
downloadProgress.value = 0
|
||||
|
||||
const versionToDownload = update.value!.version
|
||||
|
||||
downloadInProgress.value = true
|
||||
try {
|
||||
await enqueueUpdateForInstallation(update.value!.rid)
|
||||
} catch (e) {
|
||||
downloadInProgress.value = false
|
||||
downloadError.value = e instanceof Error ? e : new Error(String(e))
|
||||
|
||||
handleError(e)
|
||||
|
||||
enqueuedUpdate.value = null
|
||||
updatingImmediately.value = false
|
||||
await emit('updateEnqueuedForLater', null)
|
||||
return
|
||||
}
|
||||
downloadInProgress.value = false
|
||||
|
||||
if (updatingImmediately.value && update.value!.version === versionToDownload) {
|
||||
await getCurrentWindow().close()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -6,9 +6,9 @@ import { edit, get_optimal_jre_key } from '@/helpers/profile'
|
||||
import { handleError } from '@/store/notifications'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import JavaSelector from '@/components/ui/JavaSelector.vue'
|
||||
import { get_max_memory } from '@/helpers/jre'
|
||||
import { get } from '@/helpers/settings.ts'
|
||||
import type { InstanceSettingsTabProps, AppSettings, MemorySettings } from '../../../helpers/types'
|
||||
import useMemorySlider from '@/composables/useMemorySlider'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
@@ -34,7 +34,7 @@ const envVars = ref(
|
||||
|
||||
const overrideMemorySettings = ref(!!props.instance.memory)
|
||||
const memory = ref(props.instance.memory ?? globalSettings.memory)
|
||||
const maxMemory = Math.floor((await get_max_memory().catch(handleError)) / 1024)
|
||||
const { maxMemory, snapPoints } = await useMemorySlider()
|
||||
|
||||
const editProfileObject = computed(() => {
|
||||
const editProfile: {
|
||||
@@ -156,6 +156,8 @@ const messages = defineMessages({
|
||||
:min="512"
|
||||
:max="maxMemory"
|
||||
:step="64"
|
||||
:snap-points="snapPoints"
|
||||
:snap-range="512"
|
||||
unit="MB"
|
||||
/>
|
||||
<h2 id="project-name" class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
|
||||
|
||||
@@ -11,6 +11,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
hideHeader: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
closable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
@@ -48,7 +52,14 @@ function onModalHide() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal ref="modal" :header="header" :noblur="!themeStore.advancedRendering" @hide="onModalHide">
|
||||
<Modal
|
||||
ref="modal"
|
||||
:header="header"
|
||||
:noblur="!themeStore.advancedRendering"
|
||||
:closable="closable"
|
||||
:hide-header="hideHeader"
|
||||
@hide="onModalHide"
|
||||
>
|
||||
<template #title>
|
||||
<slot name="title" />
|
||||
</template>
|
||||
|
||||
@@ -59,7 +59,7 @@ watch(
|
||||
<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. page.</p>
|
||||
<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>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { get, set } from '@/helpers/settings.ts'
|
||||
import { ref, watch } from 'vue'
|
||||
import { get_max_memory } from '@/helpers/jre'
|
||||
import { handleError } from '@/store/notifications'
|
||||
import { Slider, Toggle } from '@modrinth/ui'
|
||||
import useMemorySlider from '@/composables/useMemorySlider'
|
||||
|
||||
const fetchSettings = await get()
|
||||
fetchSettings.launchArgs = fetchSettings.extra_launch_args.join(' ')
|
||||
@@ -11,7 +10,7 @@ fetchSettings.envVars = fetchSettings.custom_env_vars.map((x) => x.join('=')).jo
|
||||
|
||||
const settings = ref(fetchSettings)
|
||||
|
||||
const maxMemory = ref(Math.floor((await get_max_memory().catch(handleError)) / 1024))
|
||||
const { maxMemory, snapPoints } = await useMemorySlider()
|
||||
|
||||
watch(
|
||||
settings,
|
||||
@@ -107,6 +106,8 @@ watch(
|
||||
:min="512"
|
||||
:max="maxMemory"
|
||||
:step="64"
|
||||
:snap-points="snapPoints"
|
||||
:snap-range="512"
|
||||
unit="MB"
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
<script lang="ts">
|
||||
import capeModelUrl from '@/assets/models/cape.gltf?url'
|
||||
import wideModelUrl from '@/assets/models/classic_player.gltf?url'
|
||||
import slimModelUrl from '@/assets/models/slim_player.gltf?url'
|
||||
</script>
|
||||
<template>
|
||||
<UploadSkinModal ref="uploadModal" />
|
||||
<ModalWrapper ref="modal" @on-hide="resetState">
|
||||
@@ -16,9 +11,6 @@ import slimModelUrl from '@/assets/models/slim_player.gltf?url'
|
||||
<div class="max-h-[25rem] w-[16rem] min-w-[16rem] overflow-hidden relative">
|
||||
<div class="absolute top-[-4rem] left-0 h-[32rem] w-[16rem] flex-shrink-0">
|
||||
<SkinPreviewRenderer
|
||||
:slim-model-src="slimModelUrl"
|
||||
:wide-model-src="wideModelUrl"
|
||||
:cape-model-src="capeModelUrl"
|
||||
:variant="variant"
|
||||
:texture-src="previewSkin || ''"
|
||||
:cape-src="selectedCapeTexture"
|
||||
@@ -126,6 +118,7 @@ import {
|
||||
type Cape,
|
||||
type SkinModel,
|
||||
get_normalized_skin_texture,
|
||||
determineModelType,
|
||||
} from '@/helpers/skins.ts'
|
||||
import { handleError } from '@/store/notifications'
|
||||
import {
|
||||
@@ -261,7 +254,7 @@ async function showNew(e: MouseEvent, skinTextureUrl: string) {
|
||||
mode.value = 'new'
|
||||
currentSkin.value = null
|
||||
uploadedTextureUrl.value = skinTextureUrl
|
||||
variant.value = 'CLASSIC'
|
||||
variant.value = await determineModelType(skinTextureUrl)
|
||||
selectedCape.value = undefined
|
||||
visibleCapeList.value = []
|
||||
initVisibleCapeList()
|
||||
|
||||
@@ -10,9 +10,6 @@ import {
|
||||
} from '@modrinth/ui'
|
||||
import { CheckIcon, XIcon } from '@modrinth/assets'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import capeModelUrl from '@/assets/models/cape.gltf?url'
|
||||
import wideModelUrl from '@/assets/models/classic_player.gltf?url'
|
||||
import slimModelUrl from '@/assets/models/slim_player.gltf?url'
|
||||
|
||||
const modal = useTemplateRef('modal')
|
||||
|
||||
@@ -88,9 +85,6 @@ defineExpose({
|
||||
<div class="absolute top-[-4rem] left-0 h-[32rem] w-[16rem] flex-shrink-0">
|
||||
<SkinPreviewRenderer
|
||||
v-if="currentSkinTexture"
|
||||
:slim-model-src="slimModelUrl"
|
||||
:wide-model-src="wideModelUrl"
|
||||
:cape-model-src="capeModelUrl"
|
||||
:cape-src="currentCapeTexture"
|
||||
:texture-src="currentSkinTexture"
|
||||
:variant="currentSkinVariant"
|
||||
|
||||
@@ -128,6 +128,14 @@ const messages = defineMessages({
|
||||
id: 'instance.worlds.game_already_open',
|
||||
defaultMessage: 'Instance is already open',
|
||||
},
|
||||
noContact: {
|
||||
id: 'instance.worlds.no_contact',
|
||||
defaultMessage: "Server couldn't be contacted",
|
||||
},
|
||||
incompatibleServer: {
|
||||
id: 'instance.worlds.incompatible_server',
|
||||
defaultMessage: 'Server is incompatible',
|
||||
},
|
||||
copyAddress: {
|
||||
id: 'instance.worlds.copy_address',
|
||||
defaultMessage: 'Copy address',
|
||||
@@ -302,39 +310,33 @@ const messages = defineMessages({
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex gap-1 justify-end smart-clickable:allow-pointer-events">
|
||||
<template v-if="world.type === 'singleplayer' || serverStatus">
|
||||
<ButtonStyled
|
||||
v-if="(playingWorld || (locked && playingInstance)) && !startingInstance"
|
||||
color="red"
|
||||
>
|
||||
<button @click="emit('stop')">
|
||||
<StopCircleIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.stopButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else>
|
||||
<button
|
||||
v-tooltip="
|
||||
serverIncompatible
|
||||
? 'Server is incompatible'
|
||||
<ButtonStyled
|
||||
v-if="(playingWorld || (locked && playingInstance)) && !startingInstance"
|
||||
color="red"
|
||||
>
|
||||
<button @click="emit('stop')">
|
||||
<StopCircleIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.stopButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else>
|
||||
<button
|
||||
v-tooltip="
|
||||
!serverStatus
|
||||
? formatMessage(messages.noContact)
|
||||
: serverIncompatible
|
||||
? formatMessage(messages.incompatibleServer)
|
||||
: !supportsQuickPlay
|
||||
? formatMessage(messages.noQuickPlay)
|
||||
: playingOtherWorld || locked
|
||||
? formatMessage(messages.gameAlreadyOpen)
|
||||
: null
|
||||
"
|
||||
:disabled="!supportsQuickPlay || playingOtherWorld || startingInstance"
|
||||
@click="emit('play')"
|
||||
>
|
||||
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
|
||||
<PlayIcon v-else aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.playButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<ButtonStyled v-else>
|
||||
<button class="invisible">
|
||||
<PlayIcon aria-hidden="true" />
|
||||
"
|
||||
:disabled="!supportsQuickPlay || playingOtherWorld || startingInstance"
|
||||
@click="emit('play')"
|
||||
>
|
||||
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
|
||||
<PlayIcon v-else aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.playButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
21
apps/app-frontend/src/composables/useMemorySlider.js
Normal file
21
apps/app-frontend/src/composables/useMemorySlider.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { get_max_memory } from '@/helpers/jre.js'
|
||||
import { handleError } from '@/store/notifications.js'
|
||||
|
||||
export default async function () {
|
||||
const maxMemory = ref(Math.floor((await get_max_memory().catch(handleError)) / 1024))
|
||||
|
||||
const snapPoints = computed(() => {
|
||||
let points = []
|
||||
let memory = 2048
|
||||
|
||||
while (memory <= maxMemory.value) {
|
||||
points.push(memory)
|
||||
memory *= 2
|
||||
}
|
||||
|
||||
return points
|
||||
})
|
||||
|
||||
return { maxMemory, snapPoints }
|
||||
}
|
||||
@@ -36,8 +36,8 @@ export async function get_jre(path) {
|
||||
|
||||
// Tests JRE version by running 'java -version' on it.
|
||||
// Returns true if the version is valid, and matches given (after extraction)
|
||||
export async function test_jre(path, majorVersion, minorVersion) {
|
||||
return await invoke('plugin:jre|jre_test_jre', { path, majorVersion, minorVersion })
|
||||
export async function test_jre(path, majorVersion) {
|
||||
return await invoke('plugin:jre|jre_test_jre', { path, majorVersion })
|
||||
}
|
||||
|
||||
// Automatically installs specified java version
|
||||
|
||||
@@ -2,27 +2,46 @@ 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 {
|
||||
setupSkinModel,
|
||||
disposeCaches,
|
||||
loadTexture,
|
||||
applyCapeTexture,
|
||||
createTransparentTexture,
|
||||
} from '@modrinth/utils'
|
||||
import { skinPreviewStorage } from '../storage/skin-preview-storage'
|
||||
import capeModelUrl from '@/assets/models/cape.gltf?url'
|
||||
import wideModelUrl from '@/assets/models/classic_player.gltf?url'
|
||||
import slimModelUrl from '@/assets/models/slim_player.gltf?url'
|
||||
import { headStorage } from '../storage/head-storage'
|
||||
import { ClassicPlayerModel, SlimPlayerModel } from '@modrinth/assets'
|
||||
|
||||
export interface RenderResult {
|
||||
forwards: string
|
||||
backwards: string
|
||||
}
|
||||
|
||||
export interface RawRenderResult {
|
||||
forwards: Blob
|
||||
backwards: Blob
|
||||
}
|
||||
|
||||
class BatchSkinRenderer {
|
||||
private renderer: THREE.WebGLRenderer
|
||||
private readonly scene: THREE.Scene
|
||||
private readonly camera: THREE.PerspectiveCamera
|
||||
private renderer: THREE.WebGLRenderer | null = null
|
||||
private scene: THREE.Scene | null = null
|
||||
private camera: THREE.PerspectiveCamera | null = null
|
||||
private currentModel: THREE.Group | null = null
|
||||
private readonly width: number
|
||||
private readonly height: number
|
||||
|
||||
constructor(width: number = 360, height: number = 504) {
|
||||
this.width = width
|
||||
this.height = height
|
||||
}
|
||||
|
||||
private initializeRenderer(): void {
|
||||
if (this.renderer) return
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
canvas.width = this.width
|
||||
canvas.height = this.height
|
||||
|
||||
this.renderer = new THREE.WebGLRenderer({
|
||||
canvas: canvas,
|
||||
@@ -35,10 +54,10 @@ class BatchSkinRenderer {
|
||||
this.renderer.toneMapping = THREE.NoToneMapping
|
||||
this.renderer.toneMappingExposure = 10.0
|
||||
this.renderer.setClearColor(0x000000, 0)
|
||||
this.renderer.setSize(width, height)
|
||||
this.renderer.setSize(this.width, this.height)
|
||||
|
||||
this.scene = new THREE.Scene()
|
||||
this.camera = new THREE.PerspectiveCamera(20, width / height, 0.4, 1000)
|
||||
this.camera = new THREE.PerspectiveCamera(20, this.width / this.height, 0.4, 1000)
|
||||
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 2)
|
||||
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2)
|
||||
@@ -52,9 +71,12 @@ class BatchSkinRenderer {
|
||||
textureUrl: string,
|
||||
modelUrl: string,
|
||||
capeUrl?: string,
|
||||
capeModelUrl?: string,
|
||||
): Promise<RenderResult> {
|
||||
await this.setupModel(modelUrl, textureUrl, capeModelUrl, capeUrl)
|
||||
): Promise<RawRenderResult> {
|
||||
this.initializeRenderer()
|
||||
|
||||
this.clearScene()
|
||||
|
||||
await this.setupModel(modelUrl, textureUrl, capeUrl)
|
||||
|
||||
const headPart = this.currentModel!.getObjectByName('Head')
|
||||
let lookAtTarget: [number, number, number]
|
||||
@@ -79,35 +101,35 @@ class BatchSkinRenderer {
|
||||
private async renderView(
|
||||
cameraPosition: [number, number, number],
|
||||
lookAtPosition: [number, number, number],
|
||||
): Promise<string> {
|
||||
): Promise<Blob> {
|
||||
if (!this.camera || !this.renderer || !this.scene) {
|
||||
throw new Error('Renderer not initialized')
|
||||
}
|
||||
|
||||
this.camera.position.set(...cameraPosition)
|
||||
this.camera.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')
|
||||
})
|
||||
const dataUrl = this.renderer.domElement.toDataURL('image/webp', 0.9)
|
||||
const response = await fetch(dataUrl)
|
||||
return await response.blob()
|
||||
}
|
||||
|
||||
private async setupModel(
|
||||
modelUrl: string,
|
||||
textureUrl: string,
|
||||
capeModelUrl?: string,
|
||||
capeUrl?: string,
|
||||
): Promise<void> {
|
||||
if (this.currentModel) {
|
||||
this.scene.remove(this.currentModel)
|
||||
private async setupModel(modelUrl: string, textureUrl: string, capeUrl?: string): Promise<void> {
|
||||
if (!this.scene) {
|
||||
throw new Error('Renderer not initialized')
|
||||
}
|
||||
|
||||
const { model } = await setupSkinModel(modelUrl, textureUrl, capeModelUrl, capeUrl)
|
||||
const { model } = await setupSkinModel(modelUrl, textureUrl)
|
||||
|
||||
if (capeUrl) {
|
||||
const capeTexture = await loadTexture(capeUrl)
|
||||
applyCapeTexture(model, capeTexture)
|
||||
} else {
|
||||
const transparentTexture = createTransparentTexture()
|
||||
applyCapeTexture(model, null, transparentTexture)
|
||||
}
|
||||
|
||||
const group = new THREE.Group()
|
||||
group.add(model)
|
||||
@@ -118,8 +140,39 @@ class BatchSkinRenderer {
|
||||
this.currentModel = group
|
||||
}
|
||||
|
||||
private clearScene(): void {
|
||||
if (!this.scene) return
|
||||
|
||||
while (this.scene.children.length > 0) {
|
||||
const child = this.scene.children[0]
|
||||
this.scene.remove(child)
|
||||
|
||||
if (child instanceof THREE.Mesh) {
|
||||
if (child.geometry) child.geometry.dispose()
|
||||
if (child.material) {
|
||||
if (Array.isArray(child.material)) {
|
||||
child.material.forEach((material) => material.dispose())
|
||||
} else {
|
||||
child.material.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 2)
|
||||
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2)
|
||||
directionalLight.castShadow = true
|
||||
directionalLight.position.set(2, 4, 3)
|
||||
this.scene.add(ambientLight)
|
||||
this.scene.add(directionalLight)
|
||||
|
||||
this.currentModel = null
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.renderer.dispose()
|
||||
if (this.renderer) {
|
||||
this.renderer.dispose()
|
||||
}
|
||||
disposeCaches()
|
||||
}
|
||||
}
|
||||
@@ -127,18 +180,33 @@ class BatchSkinRenderer {
|
||||
function getModelUrlForVariant(variant: string): string {
|
||||
switch (variant) {
|
||||
case 'SLIM':
|
||||
return slimModelUrl
|
||||
return SlimPlayerModel
|
||||
case 'CLASSIC':
|
||||
case 'UNKNOWN':
|
||||
default:
|
||||
return wideModelUrl
|
||||
return ClassicPlayerModel
|
||||
}
|
||||
}
|
||||
|
||||
export const map = reactive(new Map<string, RenderResult>())
|
||||
export const headMap = reactive(new Map<string, string>())
|
||||
export const skinBlobUrlMap = reactive(new Map<string, RenderResult>())
|
||||
export const headBlobUrlMap = reactive(new Map<string, string>())
|
||||
const DEBUG_MODE = false
|
||||
|
||||
let sharedRenderer: BatchSkinRenderer | null = null
|
||||
function getSharedRenderer(): BatchSkinRenderer {
|
||||
if (!sharedRenderer) {
|
||||
sharedRenderer = new BatchSkinRenderer()
|
||||
}
|
||||
return sharedRenderer
|
||||
}
|
||||
|
||||
export function disposeSharedRenderer(): void {
|
||||
if (sharedRenderer) {
|
||||
sharedRenderer.dispose()
|
||||
sharedRenderer = null
|
||||
}
|
||||
}
|
||||
|
||||
export async function cleanupUnusedPreviews(skins: Skin[]): Promise<void> {
|
||||
const validKeys = new Set<string>()
|
||||
const validHeadKeys = new Set<string>()
|
||||
@@ -152,7 +220,7 @@ export async function cleanupUnusedPreviews(skins: Skin[]): Promise<void> {
|
||||
|
||||
try {
|
||||
await skinPreviewStorage.cleanupInvalidKeys(validKeys)
|
||||
await skinPreviewStorage.cleanupInvalidKeys(validHeadKeys)
|
||||
await headStorage.cleanupInvalidKeys(validHeadKeys)
|
||||
} catch (error) {
|
||||
console.warn('Failed to cleanup unused skin previews:', error)
|
||||
}
|
||||
@@ -231,13 +299,17 @@ export async function generatePlayerHeadBlob(skinUrl: string, size: number = 64)
|
||||
outputCtx.drawImage(hatCanvas, 0, 0, 8, 8, 0, 0, size, size)
|
||||
}
|
||||
|
||||
outputCanvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
resolve(blob)
|
||||
} else {
|
||||
reject(new Error('Failed to create blob from canvas'))
|
||||
}
|
||||
}, 'image/png')
|
||||
outputCanvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
resolve(blob)
|
||||
} else {
|
||||
reject(new Error('Failed to create blob from canvas'))
|
||||
}
|
||||
},
|
||||
'image/webp',
|
||||
0.9,
|
||||
)
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
@@ -254,34 +326,24 @@ export async function generatePlayerHeadBlob(skinUrl: string, size: number = 64)
|
||||
async function generateHeadRender(skin: Skin): Promise<string> {
|
||||
const headKey = `${skin.texture_key}-head`
|
||||
|
||||
if (headMap.has(headKey)) {
|
||||
if (headBlobUrlMap.has(headKey)) {
|
||||
if (DEBUG_MODE) {
|
||||
const url = headMap.get(headKey)!
|
||||
const url = headBlobUrlMap.get(headKey)!
|
||||
URL.revokeObjectURL(url)
|
||||
headMap.delete(headKey)
|
||||
headBlobUrlMap.delete(headKey)
|
||||
} else {
|
||||
return headMap.get(headKey)!
|
||||
return headBlobUrlMap.get(headKey)!
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const cached = await skinPreviewStorage.retrieve(headKey)
|
||||
if (cached && typeof cached === 'string') {
|
||||
headMap.set(headKey, cached)
|
||||
return cached
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to retrieve cached head render:', error)
|
||||
}
|
||||
|
||||
const skinUrl = await get_normalized_skin_texture(skin)
|
||||
const headBlob = await generatePlayerHeadBlob(skinUrl, 64)
|
||||
const headUrl = URL.createObjectURL(headBlob)
|
||||
|
||||
headMap.set(headKey, headUrl)
|
||||
headBlobUrlMap.set(headKey, headUrl)
|
||||
|
||||
try {
|
||||
await skinPreviewStorage.store(headKey, headUrl)
|
||||
await headStorage.store(headKey, headBlob)
|
||||
} catch (error) {
|
||||
console.warn('Failed to store head render in persistent storage:', error)
|
||||
}
|
||||
@@ -294,30 +356,49 @@ export async function getPlayerHeadUrl(skin: Skin): Promise<string> {
|
||||
}
|
||||
|
||||
export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promise<void> {
|
||||
const renderer = new BatchSkinRenderer()
|
||||
|
||||
try {
|
||||
const skinKeys = skins.map(
|
||||
(skin) => `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`,
|
||||
)
|
||||
const headKeys = skins.map((skin) => `${skin.texture_key}-head`)
|
||||
|
||||
const [cachedSkinPreviews, cachedHeadPreviews] = await Promise.all([
|
||||
skinPreviewStorage.batchRetrieve(skinKeys),
|
||||
headStorage.batchRetrieve(headKeys),
|
||||
])
|
||||
|
||||
for (let i = 0; i < skins.length; i++) {
|
||||
const skinKey = skinKeys[i]
|
||||
const headKey = headKeys[i]
|
||||
|
||||
const rawCached = cachedSkinPreviews[skinKey]
|
||||
if (rawCached) {
|
||||
const cached: RenderResult = {
|
||||
forwards: URL.createObjectURL(rawCached.forwards),
|
||||
backwards: URL.createObjectURL(rawCached.backwards),
|
||||
}
|
||||
skinBlobUrlMap.set(skinKey, cached)
|
||||
}
|
||||
|
||||
const cachedHead = cachedHeadPreviews[headKey]
|
||||
if (cachedHead) {
|
||||
headBlobUrlMap.set(headKey, URL.createObjectURL(cachedHead))
|
||||
}
|
||||
}
|
||||
|
||||
for (const skin of skins) {
|
||||
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
|
||||
|
||||
if (map.has(key)) {
|
||||
if (skinBlobUrlMap.has(key)) {
|
||||
if (DEBUG_MODE) {
|
||||
const result = map.get(key)!
|
||||
const result = skinBlobUrlMap.get(key)!
|
||||
URL.revokeObjectURL(result.forwards)
|
||||
URL.revokeObjectURL(result.backwards)
|
||||
map.delete(key)
|
||||
skinBlobUrlMap.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)
|
||||
}
|
||||
const renderer = getSharedRenderer()
|
||||
|
||||
let variant = skin.variant
|
||||
if (variant === 'UNKNOWN') {
|
||||
@@ -331,25 +412,35 @@ export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promis
|
||||
|
||||
const modelUrl = getModelUrlForVariant(variant)
|
||||
const cape: Cape | undefined = capes.find((_cape) => _cape.id === skin.cape_id)
|
||||
const renderResult = await renderer.renderSkin(
|
||||
const rawRenderResult = await renderer.renderSkin(
|
||||
await get_normalized_skin_texture(skin),
|
||||
modelUrl,
|
||||
cape?.texture,
|
||||
capeModelUrl,
|
||||
)
|
||||
|
||||
map.set(key, renderResult)
|
||||
const renderResult: RenderResult = {
|
||||
forwards: URL.createObjectURL(rawRenderResult.forwards),
|
||||
backwards: URL.createObjectURL(rawRenderResult.backwards),
|
||||
}
|
||||
|
||||
skinBlobUrlMap.set(key, renderResult)
|
||||
|
||||
try {
|
||||
await skinPreviewStorage.store(key, renderResult)
|
||||
await skinPreviewStorage.store(key, rawRenderResult)
|
||||
} catch (error) {
|
||||
console.warn('Failed to store skin preview in persistent storage:', error)
|
||||
}
|
||||
|
||||
await generateHeadRender(skin)
|
||||
const headKey = `${skin.texture_key}-head`
|
||||
if (!headBlobUrlMap.has(headKey)) {
|
||||
await generateHeadRender(skin)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
renderer.dispose()
|
||||
disposeSharedRenderer()
|
||||
await cleanupUnusedPreviews(skins)
|
||||
|
||||
await skinPreviewStorage.debugCalculateStorage()
|
||||
await headStorage.debugCalculateStorage()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,9 @@ export type AppSettings = {
|
||||
|
||||
developer_mode: boolean
|
||||
feature_flags: Record<FeatureFlag, boolean>
|
||||
|
||||
skipped_update: string | null
|
||||
pending_update_toast_for_version: string | null
|
||||
}
|
||||
|
||||
// Get full settings object
|
||||
|
||||
@@ -62,15 +62,12 @@ export async function determineModelType(texture: string): Promise<'SLIM' | 'CLA
|
||||
|
||||
context.drawImage(image, 0, 0)
|
||||
|
||||
const armX = 44
|
||||
const armY = 16
|
||||
const armWidth = 4
|
||||
const armX = 54
|
||||
const armY = 20
|
||||
const armWidth = 2
|
||||
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
|
||||
for (let alphaIndex = 3; alphaIndex < imageData.length; alphaIndex += 4) {
|
||||
if (imageData[alphaIndex] !== 0) {
|
||||
resolve('CLASSIC')
|
||||
return
|
||||
@@ -97,7 +94,11 @@ export async function fixUnknownSkins(list: Skin[]) {
|
||||
|
||||
export function filterDefaultSkins(list: Skin[]) {
|
||||
return list
|
||||
.filter((s) => s.source === 'default' && (!s.name || s.variant === DEFAULT_MODELS[s.name]))
|
||||
.filter(
|
||||
(s) =>
|
||||
s.source === 'default' &&
|
||||
(!s.name || !(s.name in DEFAULT_MODELS) || s.variant === DEFAULT_MODELS[s.name]),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const aIndex = a.name ? DEFAULT_MODEL_SORTING.indexOf(a.name) : -1
|
||||
const bIndex = b.name ? DEFAULT_MODEL_SORTING.indexOf(b.name) : -1
|
||||
|
||||
229
apps/app-frontend/src/helpers/storage/head-storage.ts
Normal file
229
apps/app-frontend/src/helpers/storage/head-storage.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
interface StoredHead {
|
||||
blob: Blob
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export class HeadStorage {
|
||||
private dbName = 'head-storage'
|
||||
private version = 1
|
||||
private db: IDBDatabase | null = null
|
||||
|
||||
async init(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(this.dbName, this.version)
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => {
|
||||
this.db = request.result
|
||||
resolve()
|
||||
}
|
||||
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result
|
||||
if (!db.objectStoreNames.contains('heads')) {
|
||||
db.createObjectStore('heads')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async store(key: string, blob: Blob): Promise<void> {
|
||||
if (!this.db) await this.init()
|
||||
|
||||
const transaction = this.db!.transaction(['heads'], 'readwrite')
|
||||
const store = transaction.objectStore('heads')
|
||||
|
||||
const storedHead: StoredHead = {
|
||||
blob,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.put(storedHead, key)
|
||||
|
||||
request.onsuccess = () => resolve()
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
}
|
||||
|
||||
async retrieve(key: string): Promise<string | null> {
|
||||
if (!this.db) await this.init()
|
||||
|
||||
const transaction = this.db!.transaction(['heads'], 'readonly')
|
||||
const store = transaction.objectStore('heads')
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.get(key)
|
||||
|
||||
request.onsuccess = () => {
|
||||
const result = request.result as StoredHead | undefined
|
||||
|
||||
if (!result) {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(result.blob)
|
||||
resolve(url)
|
||||
}
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
}
|
||||
|
||||
async batchRetrieve(keys: string[]): Promise<Record<string, Blob | null>> {
|
||||
if (!this.db) await this.init()
|
||||
|
||||
const transaction = this.db!.transaction(['heads'], 'readonly')
|
||||
const store = transaction.objectStore('heads')
|
||||
const results: Record<string, Blob | null> = {}
|
||||
|
||||
return new Promise((resolve, _reject) => {
|
||||
let completedRequests = 0
|
||||
|
||||
if (keys.length === 0) {
|
||||
resolve(results)
|
||||
return
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
const request = store.get(key)
|
||||
|
||||
request.onsuccess = () => {
|
||||
const result = request.result as StoredHead | undefined
|
||||
|
||||
if (result) {
|
||||
results[key] = result.blob
|
||||
} else {
|
||||
results[key] = null
|
||||
}
|
||||
|
||||
completedRequests++
|
||||
if (completedRequests === keys.length) {
|
||||
resolve(results)
|
||||
}
|
||||
}
|
||||
|
||||
request.onerror = () => {
|
||||
results[key] = null
|
||||
completedRequests++
|
||||
if (completedRequests === keys.length) {
|
||||
resolve(results)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async cleanupInvalidKeys(validKeys: Set<string>): Promise<number> {
|
||||
if (!this.db) await this.init()
|
||||
|
||||
const transaction = this.db!.transaction(['heads'], 'readwrite')
|
||||
const store = transaction.objectStore('heads')
|
||||
let deletedCount = 0
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.openCursor()
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result
|
||||
|
||||
if (cursor) {
|
||||
const key = cursor.primaryKey as string
|
||||
|
||||
if (!validKeys.has(key)) {
|
||||
const deleteRequest = cursor.delete()
|
||||
deleteRequest.onsuccess = () => {
|
||||
deletedCount++
|
||||
}
|
||||
deleteRequest.onerror = () => {
|
||||
console.warn('Failed to delete invalid head entry:', key)
|
||||
}
|
||||
}
|
||||
|
||||
cursor.continue()
|
||||
} else {
|
||||
resolve(deletedCount)
|
||||
}
|
||||
}
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
}
|
||||
|
||||
async debugCalculateStorage(): Promise<void> {
|
||||
if (!this.db) await this.init()
|
||||
|
||||
const transaction = this.db!.transaction(['heads'], 'readonly')
|
||||
const store = transaction.objectStore('heads')
|
||||
|
||||
let totalSize = 0
|
||||
let count = 0
|
||||
const entries: Array<{ key: string; size: number }> = []
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.openCursor()
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result
|
||||
|
||||
if (cursor) {
|
||||
const key = cursor.primaryKey as string
|
||||
const value = cursor.value as StoredHead
|
||||
|
||||
const entrySize = value.blob.size
|
||||
totalSize += entrySize
|
||||
count++
|
||||
|
||||
entries.push({
|
||||
key,
|
||||
size: entrySize,
|
||||
})
|
||||
|
||||
cursor.continue()
|
||||
} else {
|
||||
console.group('🗄️ Head Storage Debug Info')
|
||||
console.log(`Total entries: ${count}`)
|
||||
console.log(`Total size: ${(totalSize / 1024 / 1024).toFixed(2)} MB`)
|
||||
console.log(
|
||||
`Average size per entry: ${count > 0 ? (totalSize / count / 1024).toFixed(2) : 0} KB`,
|
||||
)
|
||||
|
||||
if (entries.length > 0) {
|
||||
const sortedEntries = entries.sort((a, b) => b.size - a.size)
|
||||
console.log(
|
||||
'Largest entry:',
|
||||
sortedEntries[0].key,
|
||||
'(' + (sortedEntries[0].size / 1024).toFixed(2) + ' KB)',
|
||||
)
|
||||
console.log(
|
||||
'Smallest entry:',
|
||||
sortedEntries[sortedEntries.length - 1].key,
|
||||
'(' + (sortedEntries[sortedEntries.length - 1].size / 1024).toFixed(2) + ' KB)',
|
||||
)
|
||||
}
|
||||
|
||||
console.groupEnd()
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
}
|
||||
|
||||
async clearAll(): Promise<void> {
|
||||
if (!this.db) await this.init()
|
||||
|
||||
const transaction = this.db!.transaction(['heads'], 'readwrite')
|
||||
const store = transaction.objectStore('heads')
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.clear()
|
||||
|
||||
request.onsuccess = () => resolve()
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const headStorage = new HeadStorage()
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RenderResult } from '../rendering/batch-skin-renderer'
|
||||
import type { RawRenderResult } from '../rendering/batch-skin-renderer'
|
||||
|
||||
interface StoredPreview {
|
||||
forwards: Blob
|
||||
@@ -30,18 +30,15 @@ export class SkinPreviewStorage {
|
||||
})
|
||||
}
|
||||
|
||||
async store(key: string, result: RenderResult): Promise<void> {
|
||||
async store(key: string, result: RawRenderResult): Promise<void> {
|
||||
if (!this.db) await this.init()
|
||||
|
||||
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,
|
||||
forwards: result.forwards,
|
||||
backwards: result.backwards,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
@@ -53,7 +50,7 @@ export class SkinPreviewStorage {
|
||||
})
|
||||
}
|
||||
|
||||
async retrieve(key: string): Promise<RenderResult | null> {
|
||||
async retrieve(key: string): Promise<RawRenderResult | null> {
|
||||
if (!this.db) await this.init()
|
||||
|
||||
const transaction = this.db!.transaction(['previews'], 'readonly')
|
||||
@@ -70,14 +67,56 @@ export class SkinPreviewStorage {
|
||||
return
|
||||
}
|
||||
|
||||
const forwards = URL.createObjectURL(result.forwards)
|
||||
const backwards = URL.createObjectURL(result.backwards)
|
||||
resolve({ forwards, backwards })
|
||||
resolve({ forwards: result.forwards, backwards: result.backwards })
|
||||
}
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
}
|
||||
|
||||
async batchRetrieve(keys: string[]): Promise<Record<string, RawRenderResult | null>> {
|
||||
if (!this.db) await this.init()
|
||||
|
||||
const transaction = this.db!.transaction(['previews'], 'readonly')
|
||||
const store = transaction.objectStore('previews')
|
||||
const results: Record<string, RawRenderResult | null> = {}
|
||||
|
||||
return new Promise((resolve, _reject) => {
|
||||
let completedRequests = 0
|
||||
|
||||
if (keys.length === 0) {
|
||||
resolve(results)
|
||||
return
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
const request = store.get(key)
|
||||
|
||||
request.onsuccess = () => {
|
||||
const result = request.result as StoredPreview | undefined
|
||||
|
||||
if (result) {
|
||||
results[key] = { forwards: result.forwards, backwards: result.backwards }
|
||||
} else {
|
||||
results[key] = null
|
||||
}
|
||||
|
||||
completedRequests++
|
||||
if (completedRequests === keys.length) {
|
||||
resolve(results)
|
||||
}
|
||||
}
|
||||
|
||||
request.onerror = () => {
|
||||
results[key] = null
|
||||
completedRequests++
|
||||
if (completedRequests === keys.length) {
|
||||
resolve(results)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async cleanupInvalidKeys(validKeys: Set<string>): Promise<number> {
|
||||
if (!this.db) await this.init()
|
||||
|
||||
@@ -113,6 +152,67 @@ export class SkinPreviewStorage {
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
}
|
||||
|
||||
async debugCalculateStorage(): Promise<void> {
|
||||
if (!this.db) await this.init()
|
||||
|
||||
const transaction = this.db!.transaction(['previews'], 'readonly')
|
||||
const store = transaction.objectStore('previews')
|
||||
|
||||
let totalSize = 0
|
||||
let count = 0
|
||||
const entries: Array<{ key: string; size: number }> = []
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.openCursor()
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result
|
||||
|
||||
if (cursor) {
|
||||
const key = cursor.primaryKey as string
|
||||
const value = cursor.value as StoredPreview
|
||||
|
||||
const entrySize = value.forwards.size + value.backwards.size
|
||||
totalSize += entrySize
|
||||
count++
|
||||
|
||||
entries.push({
|
||||
key,
|
||||
size: entrySize,
|
||||
})
|
||||
|
||||
cursor.continue()
|
||||
} else {
|
||||
console.group('🗄️ Skin Preview Storage Debug Info')
|
||||
console.log(`Total entries: ${count}`)
|
||||
console.log(`Total size: ${(totalSize / 1024 / 1024).toFixed(2)} MB`)
|
||||
console.log(
|
||||
`Average size per entry: ${count > 0 ? (totalSize / count / 1024).toFixed(2) : 0} KB`,
|
||||
)
|
||||
|
||||
if (entries.length > 0) {
|
||||
const sortedEntries = entries.sort((a, b) => b.size - a.size)
|
||||
console.log(
|
||||
'Largest entry:',
|
||||
sortedEntries[0].key,
|
||||
'(' + (sortedEntries[0].size / 1024).toFixed(2) + ' KB)',
|
||||
)
|
||||
console.log(
|
||||
'Smallest entry:',
|
||||
sortedEntries[sortedEntries.length - 1].key,
|
||||
'(' + (sortedEntries[sortedEntries.length - 1].size / 1024).toFixed(2) + ' KB)',
|
||||
)
|
||||
}
|
||||
|
||||
console.groupEnd()
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const skinPreviewStorage = new SkinPreviewStorage()
|
||||
|
||||
@@ -5,6 +5,22 @@ export async function isDev() {
|
||||
return await invoke('is_dev')
|
||||
}
|
||||
|
||||
export async function areUpdatesEnabled() {
|
||||
return await invoke('are_updates_enabled')
|
||||
}
|
||||
|
||||
export async function getUpdateSize(updateRid) {
|
||||
return await invoke('get_update_size', { rid: updateRid })
|
||||
}
|
||||
|
||||
export async function enqueueUpdateForInstallation(updateRid) {
|
||||
return await invoke('enqueue_update_for_installation', { rid: updateRid })
|
||||
}
|
||||
|
||||
export async function removeEnqueuedUpdate() {
|
||||
return await invoke('remove_enqueued_update')
|
||||
}
|
||||
|
||||
// One of 'Windows', 'Linux', 'MacOS'
|
||||
export async function getOS() {
|
||||
return await invoke('plugin:utils|get_os')
|
||||
@@ -37,13 +53,6 @@ export async function restartApp() {
|
||||
return await invoke('restart_app')
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated This method is no longer needed, and just returns its parameter
|
||||
*/
|
||||
export function sanitizePotentialFileUrl(url) {
|
||||
return url
|
||||
}
|
||||
|
||||
export const releaseColor = (releaseType) => {
|
||||
switch (releaseType) {
|
||||
case 'release':
|
||||
|
||||
@@ -20,6 +20,45 @@
|
||||
"app.settings.tabs.resource-management": {
|
||||
"message": "Resource management"
|
||||
},
|
||||
"app.update.changelog": {
|
||||
"message": "View changelog"
|
||||
},
|
||||
"app.update.complete-toast.text": {
|
||||
"message": "Click here to view the changelog."
|
||||
},
|
||||
"app.update.complete-toast.title": {
|
||||
"message": "Version {version} was successfully installed!"
|
||||
},
|
||||
"app.update.copied-error": {
|
||||
"message": "Copied to clipboard!"
|
||||
},
|
||||
"app.update.copy-error": {
|
||||
"message": "Copy error"
|
||||
},
|
||||
"app.update.download-error": {
|
||||
"message": "An error occurred while downloading the update. Please try again later. Contact support if the issue persists."
|
||||
},
|
||||
"app.update.download-size": {
|
||||
"message": "The update is {size}."
|
||||
},
|
||||
"app.update.hide": {
|
||||
"message": "Hide update reminder"
|
||||
},
|
||||
"app.update.later": {
|
||||
"message": "Update on exit"
|
||||
},
|
||||
"app.update.modal-body-version": {
|
||||
"message": "We recommend updating as soon as possible so you can enjoy the latest features, fixes, and improvements."
|
||||
},
|
||||
"app.update.modal-header": {
|
||||
"message": "Update available - "
|
||||
},
|
||||
"app.update.restart": {
|
||||
"message": "Update now"
|
||||
},
|
||||
"app.update.try-again": {
|
||||
"message": "Try again"
|
||||
},
|
||||
"instance.add-server.add-and-play": {
|
||||
"message": "Add and play"
|
||||
},
|
||||
@@ -377,6 +416,12 @@
|
||||
"instance.worlds.hardcore": {
|
||||
"message": "Hardcore mode"
|
||||
},
|
||||
"instance.worlds.incompatible_server": {
|
||||
"message": "Server is incompatible"
|
||||
},
|
||||
"instance.worlds.no_contact": {
|
||||
"message": "Server couldn't be contacted"
|
||||
},
|
||||
"instance.worlds.no_quick_play": {
|
||||
"message": "You can only jump straight into worlds on Minecraft 1.20+"
|
||||
},
|
||||
|
||||
@@ -220,7 +220,7 @@ async function refreshSearch() {
|
||||
}
|
||||
}
|
||||
results.value = rawResults.result
|
||||
currentPage.value = Math.max(1, Math.min(pageCount.value, currentPage.value))
|
||||
currentPage.value = 1
|
||||
|
||||
const persistentParams: LocationQuery = {}
|
||||
|
||||
@@ -266,6 +266,7 @@ async function onSearchChangeToTop() {
|
||||
|
||||
function clearSearch() {
|
||||
query.value = ''
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
watch(
|
||||
|
||||
@@ -38,15 +38,11 @@ import {
|
||||
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 { generateSkinPreviews, skinBlobUrlMap } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||
import { handleSevereError } from '@/store/error'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import type AccountsCard from '@/components/ui/AccountsCard.vue'
|
||||
import { arrayBufferToBase64 } from '@modrinth/utils'
|
||||
import capeModelUrl from '@/assets/models/cape.gltf?url'
|
||||
import wideModelUrl from '@/assets/models/classic_player.gltf?url'
|
||||
import slimModelUrl from '@/assets/models/slim_player.gltf?url'
|
||||
|
||||
const editSkinModal = useTemplateRef('editSkinModal')
|
||||
const selectCapeModal = useTemplateRef('selectCapeModal')
|
||||
const uploadSkinModal = useTemplateRef('uploadSkinModal')
|
||||
@@ -219,7 +215,7 @@ async function loadCurrentUser() {
|
||||
|
||||
function getBakedSkinTextures(skin: Skin): RenderResult | undefined {
|
||||
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
|
||||
return map.get(key)
|
||||
return skinBlobUrlMap.get(key)
|
||||
}
|
||||
|
||||
async function login() {
|
||||
@@ -320,9 +316,6 @@ await Promise.all([loadCapes(), loadSkins(), loadCurrentUser()])
|
||||
</h1>
|
||||
<div class="preview-container">
|
||||
<SkinPreviewRenderer
|
||||
:wide-model-src="wideModelUrl"
|
||||
:slim-model-src="slimModelUrl"
|
||||
:cape-model-src="capeModelUrl"
|
||||
:cape-src="capeTexture"
|
||||
:texture-src="skinTexture || ''"
|
||||
:variant="skinVariant"
|
||||
|
||||
@@ -483,7 +483,7 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
height: calc(100vh - 11rem);
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "theseus_gui"
|
||||
version = "0.10.1"
|
||||
version = "1.0.0-local" # The actual version is set by the theseus-build workflow on tagging
|
||||
description = "The Modrinth App is a desktop application for managing your Minecraft mods"
|
||||
license = "GPL-3.0-only"
|
||||
repository = "https://github.com/modrinth/code/apps/app/"
|
||||
|
||||
@@ -18,5 +18,25 @@
|
||||
<string>A Minecraft mod wants to access your camera.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<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>
|
||||
</plist>
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"build": "tauri build",
|
||||
"dev": "tauri dev",
|
||||
"test": "cargo nextest run --all-targets --no-fail-fast",
|
||||
"lint": "cargo fmt --check && cargo clippy --all-targets",
|
||||
"fix": "cargo clippy --all-targets --fix --allow-dirty && cargo fmt"
|
||||
"lint": "cargo fmt --check && cargo clippy --all-targets && cargo clippy --all-targets --features updater",
|
||||
"fix": "cargo clippy --all-targets --fix --allow-dirty && cargo clippy --all-targets --features updater --fix --allow-dirty && cargo fmt"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "2.5.0"
|
||||
|
||||
@@ -45,8 +45,12 @@ pub enum TheseusSerializableError {
|
||||
Tauri(#[from] tauri::Error),
|
||||
|
||||
#[cfg(feature = "updater")]
|
||||
#[error("Tauri updater error: {0}")]
|
||||
TauriUpdater(#[from] tauri_plugin_updater::Error),
|
||||
#[error("Updater error: {0}")]
|
||||
Updater(#[from] tauri_plugin_updater::Error),
|
||||
|
||||
#[cfg(feature = "updater")]
|
||||
#[error("HTTP error: {0}")]
|
||||
Http(#[from] tauri_plugin_http::reqwest::Error),
|
||||
}
|
||||
|
||||
// Generic implementation of From<T> for ErrorTypeA
|
||||
@@ -104,5 +108,6 @@ impl_serialize! {
|
||||
impl_serialize! {
|
||||
IO,
|
||||
Tauri,
|
||||
TauriUpdater,
|
||||
Updater,
|
||||
Http,
|
||||
}
|
||||
|
||||
@@ -14,6 +14,11 @@ mod error;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos;
|
||||
|
||||
#[cfg(feature = "updater")]
|
||||
mod updater_impl;
|
||||
#[cfg(not(feature = "updater"))]
|
||||
mod updater_impl_noop;
|
||||
|
||||
// Should be called in launcher initialization
|
||||
#[tracing::instrument(skip_all)]
|
||||
#[tauri::command]
|
||||
@@ -21,75 +26,9 @@ async fn initialize_state(app: tauri::AppHandle) -> api::Result<()> {
|
||||
tracing::info!("Initializing app event state...");
|
||||
theseus::EventState::init(app.clone()).await?;
|
||||
|
||||
#[cfg(feature = "updater")]
|
||||
'updater: {
|
||||
if env::var("MODRINTH_EXTERNAL_UPDATE_PROVIDER").is_ok() {
|
||||
State::init().await?;
|
||||
break 'updater;
|
||||
}
|
||||
tracing::info!("Initializing app state...");
|
||||
State::init().await?;
|
||||
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
|
||||
let updater = app.updater_builder().build()?;
|
||||
|
||||
let update_fut = updater.check();
|
||||
|
||||
tracing::info!("Initializing app state...");
|
||||
State::init().await?;
|
||||
|
||||
let check_bar = theseus::init_loading(
|
||||
theseus::LoadingBarType::CheckingForUpdates,
|
||||
1.0,
|
||||
"Checking for updates...",
|
||||
)
|
||||
.await?;
|
||||
|
||||
tracing::info!("Checking for updates...");
|
||||
let update = update_fut.await;
|
||||
|
||||
drop(check_bar);
|
||||
|
||||
if let Some(update) = update.ok().flatten() {
|
||||
tracing::info!("Update found: {:?}", update.download_url);
|
||||
let loader_bar_id = theseus::init_loading(
|
||||
theseus::LoadingBarType::LauncherUpdate {
|
||||
version: update.version.clone(),
|
||||
current_version: update.current_version.clone(),
|
||||
},
|
||||
1.0,
|
||||
"Updating Modrinth App...",
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 100 MiB
|
||||
const DEFAULT_CONTENT_LENGTH: u64 = 1024 * 1024 * 100;
|
||||
|
||||
update
|
||||
.download_and_install(
|
||||
|chunk_length, content_length| {
|
||||
let _ = theseus::emit_loading(
|
||||
&loader_bar_id,
|
||||
(chunk_length as f64)
|
||||
/ (content_length
|
||||
.unwrap_or(DEFAULT_CONTENT_LENGTH)
|
||||
as f64),
|
||||
None,
|
||||
);
|
||||
},
|
||||
|| {},
|
||||
)
|
||||
.await?;
|
||||
|
||||
app.restart();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "updater"))]
|
||||
{
|
||||
State::init().await?;
|
||||
}
|
||||
|
||||
tracing::info!("Finished checking for updates!");
|
||||
let state = State::get().await?;
|
||||
app.asset_protocol_scope()
|
||||
.allow_directory(state.directories.caches_dir(), true)?;
|
||||
@@ -125,6 +64,17 @@ fn is_dev() -> bool {
|
||||
cfg!(debug_assertions)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn are_updates_enabled() -> bool {
|
||||
cfg!(feature = "updater")
|
||||
}
|
||||
|
||||
#[cfg(feature = "updater")]
|
||||
pub use updater_impl::*;
|
||||
|
||||
#[cfg(not(feature = "updater"))]
|
||||
pub use updater_impl_noop::*;
|
||||
|
||||
// Toggles decorations
|
||||
#[tauri::command]
|
||||
async fn toggle_decorations(b: bool, window: tauri::Window) -> api::Result<()> {
|
||||
@@ -166,7 +116,17 @@ fn main() {
|
||||
|
||||
#[cfg(feature = "updater")]
|
||||
{
|
||||
builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
|
||||
use tauri_plugin_http::reqwest::header::{HeaderValue, USER_AGENT};
|
||||
use theseus::LAUNCHER_USER_AGENT;
|
||||
builder = builder.plugin(
|
||||
tauri_plugin_updater::Builder::new()
|
||||
.header(
|
||||
USER_AGENT,
|
||||
HeaderValue::from_str(LAUNCHER_USER_AGENT).unwrap(),
|
||||
)
|
||||
.unwrap()
|
||||
.build(),
|
||||
);
|
||||
}
|
||||
|
||||
builder = builder
|
||||
@@ -261,9 +221,14 @@ fn main() {
|
||||
.plugin(api::ads::init())
|
||||
.plugin(api::friends::init())
|
||||
.plugin(api::worlds::init())
|
||||
.manage(PendingUpdateData::default())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
initialize_state,
|
||||
is_dev,
|
||||
are_updates_enabled,
|
||||
get_update_size,
|
||||
enqueue_update_for_installation,
|
||||
remove_enqueued_update,
|
||||
toggle_decorations,
|
||||
show_window,
|
||||
restart_app,
|
||||
@@ -275,8 +240,41 @@ fn main() {
|
||||
match app {
|
||||
Ok(app) => {
|
||||
app.run(|app, event| {
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[cfg(not(any(feature = "updater", target_os = "macos")))]
|
||||
drop((app, event));
|
||||
|
||||
#[cfg(feature = "updater")]
|
||||
if matches!(event, tauri::RunEvent::Exit) {
|
||||
let update_data = app.state::<PendingUpdateData>().inner();
|
||||
if let Some((update, data)) = &*update_data.0.lock().unwrap() {
|
||||
fn set_changelog_toast(version: Option<String>) {
|
||||
let toast_result: theseus::Result<()> = tauri::async_runtime::block_on(async move {
|
||||
let mut settings = settings::get().await?;
|
||||
settings.pending_update_toast_for_version = version;
|
||||
settings::set(settings).await?;
|
||||
Ok(())
|
||||
});
|
||||
if let Err(e) = toast_result {
|
||||
tracing::warn!("Failed to set pending_update_toast: {e}")
|
||||
}
|
||||
}
|
||||
|
||||
set_changelog_toast(Some(update.version.clone()));
|
||||
if let Err(e) = update.install(data) {
|
||||
tracing::error!("Error while updating: {e}");
|
||||
set_changelog_toast(None);
|
||||
|
||||
DialogBuilder::message()
|
||||
.set_level(MessageLevel::Error)
|
||||
.set_title("Update error")
|
||||
.set_text(format!("Failed to install update due to an error:\n{e}"))
|
||||
.alert()
|
||||
.show()
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
if let tauri::RunEvent::Opened { urls } = event {
|
||||
tracing::info!("Handling webview open {urls:?}");
|
||||
@@ -304,6 +302,8 @@ fn main() {
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Error while running tauri application: {:?}", e);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// tauri doesn't expose runtime errors, so matching a string representation seems like the only solution
|
||||
@@ -332,7 +332,6 @@ fn main() {
|
||||
.show()
|
||||
.unwrap();
|
||||
|
||||
tracing::error!("Error while running tauri application: {:?}", e);
|
||||
panic!("{1}: {:?}", e, "error while running tauri application")
|
||||
}
|
||||
}
|
||||
|
||||
121
apps/app/src/updater_impl.rs
Normal file
121
apps/app/src/updater_impl.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
use crate::api::Result;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tauri::http::HeaderValue;
|
||||
use tauri::http::header::ACCEPT;
|
||||
use tauri::{Manager, ResourceId, Runtime, Webview};
|
||||
use tauri_plugin_http::reqwest;
|
||||
use tauri_plugin_http::reqwest::ClientBuilder;
|
||||
use tauri_plugin_updater::Error;
|
||||
use tauri_plugin_updater::Update;
|
||||
use theseus::{
|
||||
LAUNCHER_USER_AGENT, LoadingBarType, emit_loading, init_loading,
|
||||
};
|
||||
use tokio::time::Instant;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct PendingUpdateData(pub Mutex<Option<(Arc<Update>, Vec<u8>)>>);
|
||||
|
||||
// Reimplementation of Update::download mostly, minus the actual download part
|
||||
#[tauri::command]
|
||||
pub async fn get_update_size<R: Runtime>(
|
||||
webview: Webview<R>,
|
||||
rid: ResourceId,
|
||||
) -> Result<Option<u64>> {
|
||||
let update = webview.resources_table().get::<Update>(rid)?;
|
||||
|
||||
let mut headers = update.headers.clone();
|
||||
if !headers.contains_key(ACCEPT) {
|
||||
headers.insert(
|
||||
ACCEPT,
|
||||
HeaderValue::from_static("application/octet-stream"),
|
||||
);
|
||||
}
|
||||
|
||||
let mut request = ClientBuilder::new().user_agent(LAUNCHER_USER_AGENT);
|
||||
if let Some(timeout) = update.timeout {
|
||||
request = request.timeout(timeout);
|
||||
}
|
||||
if let Some(ref proxy) = update.proxy {
|
||||
let proxy = reqwest::Proxy::all(proxy.as_str())?;
|
||||
request = request.proxy(proxy);
|
||||
}
|
||||
let response = request
|
||||
.build()?
|
||||
.head(update.download_url.clone())
|
||||
.headers(headers)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(Error::Network(format!(
|
||||
"Download request failed with status: {}",
|
||||
response.status()
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
let content_length = response
|
||||
.headers()
|
||||
.get("Content-Length")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.parse().ok());
|
||||
|
||||
Ok(content_length)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn enqueue_update_for_installation<R: Runtime>(
|
||||
webview: Webview<R>,
|
||||
rid: ResourceId,
|
||||
) -> Result<()> {
|
||||
let pending_data = webview.state::<PendingUpdateData>().inner();
|
||||
|
||||
let update = webview.resources_table().get::<Update>(rid)?;
|
||||
|
||||
let progress = init_loading(
|
||||
LoadingBarType::LauncherUpdate {
|
||||
version: update.version.clone(),
|
||||
current_version: update.current_version.clone(),
|
||||
},
|
||||
1.0,
|
||||
"Downloading update...",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let download_start = Instant::now();
|
||||
let update_data = update
|
||||
.download(
|
||||
|chunk_size, total_size| {
|
||||
let Some(total_size) = total_size else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = emit_loading(
|
||||
&progress,
|
||||
chunk_size as f64 / total_size as f64,
|
||||
None,
|
||||
) {
|
||||
tracing::error!(
|
||||
"Failed to update download progress bar: {e}"
|
||||
);
|
||||
}
|
||||
},
|
||||
|| {},
|
||||
)
|
||||
.await?;
|
||||
let download_duration = download_start.elapsed();
|
||||
tracing::info!("Downloaded update in {download_duration:?}");
|
||||
|
||||
pending_data
|
||||
.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.replace((update, update_data));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn remove_enqueued_update<R: Runtime>(webview: Webview<R>) {
|
||||
let pending_data = webview.state::<PendingUpdateData>().inner();
|
||||
pending_data.0.lock().unwrap().take();
|
||||
}
|
||||
26
apps/app/src/updater_impl_noop.rs
Normal file
26
apps/app/src/updater_impl_noop.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
use crate::api::Result;
|
||||
use theseus::ErrorKind;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct PendingUpdateData(());
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_update_size() -> Result<()> {
|
||||
updates_are_disabled()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn enqueue_update_for_installation() -> Result<()> {
|
||||
updates_are_disabled()
|
||||
}
|
||||
|
||||
fn updates_are_disabled() -> Result<()> {
|
||||
let error: theseus::Error = ErrorKind::OtherError(
|
||||
"Updates are disabled in this build.".to_string(),
|
||||
)
|
||||
.into();
|
||||
Err(error.into())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn remove_enqueued_update() {}
|
||||
@@ -41,7 +41,7 @@
|
||||
]
|
||||
},
|
||||
"productName": "Modrinth App",
|
||||
"version": "0.10.1",
|
||||
"version": "../app-frontend/package.json",
|
||||
"mainBinaryName": "Modrinth App",
|
||||
"identifier": "ModrinthApp",
|
||||
"plugins": {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
FROM rust:1.88.0 AS build
|
||||
ENV PKG_CONFIG_ALLOW_CROSS=1
|
||||
|
||||
WORKDIR /usr/src/daedalus
|
||||
COPY . .
|
||||
@@ -10,11 +9,8 @@ FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates openssl \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN update-ca-certificates
|
||||
|
||||
COPY --from=build /usr/src/daedalus/target/release/daedalus_client /daedalus/daedalus_client
|
||||
WORKDIR /daedalus_client
|
||||
|
||||
|
||||
@@ -19,8 +19,6 @@ From there, you can create the database and perform all database migrations with
|
||||
sqlx database setup
|
||||
```
|
||||
|
||||
Finally, if on Linux, you will need the OpenSSL library. On Debian-based systems, this involves the `pkg-config` and `libssl-dev` packages.
|
||||
|
||||
To enable labrinth to create a project, you need to add two things.
|
||||
|
||||
1. An entry in the `loaders` table.
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"postinstall": "nuxi prepare",
|
||||
"lint": "eslint . && prettier --check .",
|
||||
"fix": "eslint . --fix && prettier --write .",
|
||||
"intl:extract": "formatjs extract \"{,src/components,src/composables,src/layouts,src/middleware,src/modules,src/pages,src/plugins,src/utils}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" \"src/error.vue\" --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/layouts,src/middleware,src/modules,src/pages,src/plugins,src/utils}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" \"src/error.vue\" --ignore \"**/*.d.ts\" --ignore node_modules --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
|
||||
"test": "nuxi build"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -38,9 +38,10 @@
|
||||
"@intercom/messenger-js-sdk": "^0.0.14",
|
||||
"@ltd/j-toml": "^1.38.0",
|
||||
"@modrinth/assets": "workspace:*",
|
||||
"@modrinth/blog": "workspace:*",
|
||||
"@modrinth/moderation": "workspace:*",
|
||||
"@modrinth/ui": "workspace:*",
|
||||
"@modrinth/utils": "workspace:*",
|
||||
"@modrinth/blog": "workspace:*",
|
||||
"@pinia/nuxt": "^0.5.1",
|
||||
"@types/three": "^0.172.0",
|
||||
"@vintl/vintl": "^4.4.1",
|
||||
@@ -58,6 +59,7 @@
|
||||
"markdown-it": "14.1.0",
|
||||
"pathe": "^1.1.2",
|
||||
"pinia": "^2.1.7",
|
||||
"prettier": "^3.6.2",
|
||||
"qrcode.vue": "^3.4.0",
|
||||
"semver": "^7.5.4",
|
||||
"three": "^0.172.0",
|
||||
|
||||
@@ -197,13 +197,13 @@
|
||||
}
|
||||
|
||||
> :where(
|
||||
input + *,
|
||||
.input-group + *,
|
||||
.textarea-wrapper + *,
|
||||
.chips + *,
|
||||
.resizable-textarea-wrapper + *,
|
||||
.input-div + *
|
||||
) {
|
||||
input + *,
|
||||
.input-group + *,
|
||||
.textarea-wrapper + *,
|
||||
.chips + *,
|
||||
.resizable-textarea-wrapper + *,
|
||||
.input-div + *
|
||||
) {
|
||||
&:not(:empty) {
|
||||
margin-block-start: var(--spacing-card-md);
|
||||
}
|
||||
|
||||
@@ -115,10 +115,12 @@ html {
|
||||
--shadow-inset-sm: inset 0px -1px 2px hsla(221, 39%, 11%, 0.15);
|
||||
|
||||
--shadow-raised-lg: 0px 2px 4px hsla(221, 39%, 11%, 0.2);
|
||||
--shadow-raised: 0.3px 0.5px 0.6px hsl(var(--shadow-color) / 0.15),
|
||||
--shadow-raised:
|
||||
0.3px 0.5px 0.6px hsl(var(--shadow-color) / 0.15),
|
||||
1px 2px 2.2px -1.7px hsl(var(--shadow-color) / 0.12),
|
||||
4.4px 8.8px 9.7px -3.4px hsl(var(--shadow-color) / 0.09);
|
||||
--shadow-floating: hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
|
||||
--shadow-floating:
|
||||
hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
|
||||
hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, hsla(0, 0%, 0%, 0.1) 0px 2px 4px -1px;
|
||||
|
||||
--shadow-card: rgba(50, 50, 100, 0.1) 0px 2px 4px 0px;
|
||||
@@ -150,8 +152,8 @@ html {
|
||||
rgba(255, 255, 255, 0.35) 0%,
|
||||
rgba(255, 255, 255, 0.2695) 100%
|
||||
);
|
||||
--landing-blob-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16),
|
||||
inset 2px 2px 64px rgba(255, 255, 255, 0.45);
|
||||
--landing-blob-shadow:
|
||||
2px 2px 12px rgba(0, 0, 0, 0.16), inset 2px 2px 64px rgba(255, 255, 255, 0.45);
|
||||
|
||||
--landing-card-bg: rgba(255, 255, 255, 0.8);
|
||||
--landing-card-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16);
|
||||
@@ -251,13 +253,15 @@ html {
|
||||
|
||||
--shadow-raised-lg: 0px 2px 4px hsla(221, 39%, 11%, 0.2);
|
||||
--shadow-raised: 0px -2px 4px hsla(221, 39%, 11%, 0.1);
|
||||
--shadow-floating: hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
|
||||
--shadow-floating:
|
||||
hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
|
||||
hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.06) 0px 2px 4px -1px;
|
||||
|
||||
--shadow-card: rgba(0, 0, 0, 0.25) 0px 2px 4px 0px;
|
||||
|
||||
--landing-maze-bg: url("https://cdn.modrinth.com/landing-new/landing.webp");
|
||||
--landing-maze-gradient-bg: linear-gradient(0deg, #31375f 0%, rgba(8, 14, 55, 0) 100%),
|
||||
--landing-maze-gradient-bg:
|
||||
linear-gradient(0deg, #31375f 0%, rgba(8, 14, 55, 0) 100%),
|
||||
url("https://cdn.modrinth.com/landing-new/landing-lower.webp");
|
||||
--landing-maze-outer-bg: linear-gradient(180deg, #06060d 0%, #000000 100%);
|
||||
|
||||
@@ -284,7 +288,8 @@ html {
|
||||
rgba(44, 48, 79, 0.35) 0%,
|
||||
rgba(32, 35, 50, 0.2695) 100%
|
||||
);
|
||||
--landing-blob-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16), inset 2px 2px 64px rgba(57, 61, 94, 0.45);
|
||||
--landing-blob-shadow:
|
||||
2px 2px 12px rgba(0, 0, 0, 0.16), inset 2px 2px 64px rgba(57, 61, 94, 0.45);
|
||||
|
||||
--landing-card-bg: rgba(59, 63, 85, 0.15);
|
||||
--landing-card-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16);
|
||||
@@ -360,8 +365,9 @@ body {
|
||||
// Defaults
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
--font-standard: Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto,
|
||||
Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
||||
--font-standard:
|
||||
Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell,
|
||||
Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
||||
--mono-font: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
|
||||
font-family: var(--font-standard);
|
||||
font-size: 16px;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<template>
|
||||
<div
|
||||
class="vue-notification-group experimental-styles-within"
|
||||
:class="{ 'intercom-present': isIntercomPresent }"
|
||||
:class="{
|
||||
'intercom-present': isIntercomPresent,
|
||||
rightwards: moveNotificationsRight,
|
||||
}"
|
||||
>
|
||||
<transition-group name="notifs">
|
||||
<div
|
||||
@@ -82,6 +85,7 @@ import {
|
||||
CopyIcon,
|
||||
} from "@modrinth/assets";
|
||||
const notifications = useNotifications();
|
||||
const { isVisible: moveNotificationsRight } = useNotificationRightwards();
|
||||
|
||||
const isIntercomPresent = ref(false);
|
||||
|
||||
@@ -160,6 +164,15 @@ function copyToClipboard(notif) {
|
||||
bottom: 5rem;
|
||||
}
|
||||
|
||||
&.rightwards {
|
||||
right: unset !important;
|
||||
left: 1.5rem;
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
left: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.vue-notification-wrapper {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<NewModal ref="modal" header="Moderation shortcuts" :closable="true">
|
||||
<div>
|
||||
<div class="keybinds-sections">
|
||||
<div class="grid grid-cols-2 gap-x-12 gap-y-3">
|
||||
<div
|
||||
v-for="keybind in keybinds"
|
||||
:key="keybind.id"
|
||||
class="keybind-item flex items-center justify-between gap-4"
|
||||
:class="{
|
||||
'col-span-2': keybinds.length % 2 === 1 && keybinds[keybinds.length - 1] === keybind,
|
||||
}"
|
||||
>
|
||||
<span class="text-sm text-secondary">{{ keybind.description }}</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<kbd
|
||||
v-for="(key, index) in parseKeybindDisplay(keybind.keybind)"
|
||||
:key="`${keybind.id}-key-${index}`"
|
||||
class="keybind-key"
|
||||
>
|
||||
{{ key }}
|
||||
</kbd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import NewModal from "@modrinth/ui/src/components/modal/NewModal.vue";
|
||||
import { keybinds, type KeybindListener, normalizeKeybind } from "@modrinth/moderation";
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>();
|
||||
|
||||
function parseKeybindDisplay(keybind: KeybindListener["keybind"]): string[] {
|
||||
const keybinds = Array.isArray(keybind) ? keybind : [keybind];
|
||||
const normalized = keybinds[0];
|
||||
const def = normalizeKeybind(normalized);
|
||||
|
||||
const keys = [];
|
||||
|
||||
if (def.ctrl || def.meta) {
|
||||
keys.push(isMac() ? "CMD" : "CTRL");
|
||||
}
|
||||
if (def.shift) keys.push("SHIFT");
|
||||
if (def.alt) keys.push("ALT");
|
||||
|
||||
const mainKey = def.key
|
||||
.replace("ArrowLeft", "←")
|
||||
.replace("ArrowRight", "→")
|
||||
.replace("ArrowUp", "↑")
|
||||
.replace("ArrowDown", "↓")
|
||||
.replace("Enter", "↵")
|
||||
.replace("Space", "SPACE")
|
||||
.replace("Escape", "ESC")
|
||||
.toUpperCase();
|
||||
|
||||
keys.push(mainKey);
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
function isMac() {
|
||||
return navigator.platform.toUpperCase().indexOf("MAC") >= 0;
|
||||
}
|
||||
|
||||
function show(event?: MouseEvent) {
|
||||
modal.value?.show(event);
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide();
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
hide,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.keybind-key {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background-color: var(--color-bg);
|
||||
border: 1px solid var(--color-divider);
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-contrast);
|
||||
|
||||
+ .keybind-key {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.keybind-item {
|
||||
min-height: 2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.keybinds-sections {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,422 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2 v-if="modPackData" class="m-0 mb-2 text-lg font-extrabold">
|
||||
Modpack permissions ({{ Math.min(modPackData.length, currentIndex + 1) }} /
|
||||
{{ modPackData.length }})
|
||||
</h2>
|
||||
|
||||
<div v-if="!modPackData">Loading data...</div>
|
||||
|
||||
<div v-else-if="modPackData.length === 0">
|
||||
<p>All permissions obtained. You may skip this step!</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!modPackData[currentIndex]">
|
||||
<p>All permission checks complete!</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div v-if="modPackData[currentIndex].type === 'unknown'">
|
||||
<p>What is the approval type of {{ modPackData[currentIndex].file_name }}?</p>
|
||||
<div class="input-group">
|
||||
<ButtonStyled
|
||||
v-for="(option, index) in fileApprovalTypes"
|
||||
:key="index"
|
||||
:color="modPackData[currentIndex].status === option.id ? 'brand' : 'standard'"
|
||||
@click="setStatus(currentIndex, option.id)"
|
||||
>
|
||||
<button>
|
||||
{{ option.name }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div v-if="modPackData[currentIndex].status !== 'unidentified'" class="flex flex-col gap-1">
|
||||
<label for="proof">
|
||||
<span class="label__title">Proof</span>
|
||||
</label>
|
||||
<input
|
||||
id="proof"
|
||||
v-model="(modPackData[currentIndex] as ModerationUnknownModpackItem).proof"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="Enter proof of status..."
|
||||
@input="persistAll()"
|
||||
/>
|
||||
<label for="link">
|
||||
<span class="label__title">Link</span>
|
||||
</label>
|
||||
<input
|
||||
id="link"
|
||||
v-model="(modPackData[currentIndex] as ModerationUnknownModpackItem).url"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="Enter link of project..."
|
||||
@input="persistAll()"
|
||||
/>
|
||||
<label for="title">
|
||||
<span class="label__title">Title</span>
|
||||
</label>
|
||||
<input
|
||||
id="title"
|
||||
v-model="(modPackData[currentIndex] as ModerationUnknownModpackItem).title"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="Enter title of project..."
|
||||
@input="persistAll()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="modPackData[currentIndex].type === 'flame'">
|
||||
<p>
|
||||
What is the approval type of {{ modPackData[currentIndex].title }} (<a
|
||||
:href="modPackData[currentIndex].url"
|
||||
target="_blank"
|
||||
class="text-link"
|
||||
>{{ modPackData[currentIndex].url }}</a
|
||||
>)?
|
||||
</p>
|
||||
<div class="input-group">
|
||||
<ButtonStyled
|
||||
v-for="(option, index) in fileApprovalTypes"
|
||||
:key="index"
|
||||
:color="modPackData[currentIndex].status === option.id ? 'brand' : 'standard'"
|
||||
@click="setStatus(currentIndex, option.id)"
|
||||
>
|
||||
<button>
|
||||
{{ option.name }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
['unidentified', 'no', 'with-attribution'].includes(
|
||||
modPackData[currentIndex].status || '',
|
||||
)
|
||||
"
|
||||
>
|
||||
<p v-if="modPackData[currentIndex].status === 'unidentified'">
|
||||
Does this project provide identification and permission for
|
||||
<strong>{{ modPackData[currentIndex].file_name }}</strong
|
||||
>?
|
||||
</p>
|
||||
<p v-else-if="modPackData[currentIndex].status === 'with-attribution'">
|
||||
Does this project provide attribution for
|
||||
<strong>{{ modPackData[currentIndex].file_name }}</strong
|
||||
>?
|
||||
</p>
|
||||
<p v-else>
|
||||
Does this project provide proof of permission for
|
||||
<strong>{{ modPackData[currentIndex].file_name }}</strong
|
||||
>?
|
||||
</p>
|
||||
<div class="input-group">
|
||||
<ButtonStyled
|
||||
v-for="(option, index) in filePermissionTypes"
|
||||
:key="index"
|
||||
:color="modPackData[currentIndex].approved === option.id ? 'brand' : 'standard'"
|
||||
@click="setApproval(currentIndex, option.id)"
|
||||
>
|
||||
<button>
|
||||
{{ option.name }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex gap-2">
|
||||
<ButtonStyled>
|
||||
<button :disabled="currentIndex <= 0" @click="goToPrevious">
|
||||
<LeftArrowIcon aria-hidden="true" />
|
||||
Previous
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="modPackData && currentIndex < modPackData.length" color="blue">
|
||||
<button :disabled="!canGoNext" @click="goToNext">
|
||||
<RightArrowIcon aria-hidden="true" />
|
||||
{{ currentIndex + 1 >= modPackData.length ? "Complete" : "Next" }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { LeftArrowIcon, RightArrowIcon } from "@modrinth/assets";
|
||||
import type {
|
||||
ModerationJudgements,
|
||||
ModerationModpackItem,
|
||||
ModerationModpackResponse,
|
||||
ModerationUnknownModpackItem,
|
||||
ModerationFlameModpackItem,
|
||||
ModerationModpackPermissionApprovalType,
|
||||
ModerationPermissionType,
|
||||
} from "@modrinth/utils";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import { ref, computed, watch, onMounted } from "vue";
|
||||
import { useLocalStorage } from "@vueuse/core";
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: string;
|
||||
modelValue?: ModerationJudgements;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
complete: [];
|
||||
"update:modelValue": [judgements: ModerationJudgements];
|
||||
}>();
|
||||
|
||||
const persistedModPackData = useLocalStorage<ModerationModpackItem[] | null>(
|
||||
`modpack-permissions-${props.projectId}`,
|
||||
null,
|
||||
{
|
||||
serializer: {
|
||||
read: (v: any) => (v ? JSON.parse(v) : null),
|
||||
write: (v: any) => JSON.stringify(v),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const persistedIndex = useLocalStorage<number>(`modpack-permissions-index-${props.projectId}`, 0);
|
||||
|
||||
const modPackData = ref<ModerationModpackItem[] | null>(null);
|
||||
const currentIndex = ref(0);
|
||||
|
||||
const fileApprovalTypes: ModerationModpackPermissionApprovalType[] = [
|
||||
{
|
||||
id: "yes",
|
||||
name: "Yes",
|
||||
},
|
||||
{
|
||||
id: "with-attribution-and-source",
|
||||
name: "With attribution and source",
|
||||
},
|
||||
{
|
||||
id: "with-attribution",
|
||||
name: "With attribution",
|
||||
},
|
||||
{
|
||||
id: "no",
|
||||
name: "No",
|
||||
},
|
||||
{
|
||||
id: "permanent-no",
|
||||
name: "Permanent no",
|
||||
},
|
||||
{
|
||||
id: "unidentified",
|
||||
name: "Unidentified",
|
||||
},
|
||||
];
|
||||
|
||||
const filePermissionTypes: ModerationPermissionType[] = [
|
||||
{ id: "yes", name: "Yes" },
|
||||
{ id: "no", name: "No" },
|
||||
];
|
||||
|
||||
function persistAll() {
|
||||
persistedModPackData.value = modPackData.value;
|
||||
persistedIndex.value = currentIndex.value;
|
||||
}
|
||||
|
||||
watch(
|
||||
modPackData,
|
||||
(newValue) => {
|
||||
persistedModPackData.value = newValue;
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
watch(currentIndex, (newValue) => {
|
||||
persistedIndex.value = newValue;
|
||||
});
|
||||
|
||||
function loadPersistedData(): void {
|
||||
if (persistedModPackData.value) {
|
||||
modPackData.value = persistedModPackData.value;
|
||||
}
|
||||
currentIndex.value = persistedIndex.value;
|
||||
}
|
||||
|
||||
function clearPersistedData(): void {
|
||||
persistedModPackData.value = null;
|
||||
persistedIndex.value = 0;
|
||||
}
|
||||
|
||||
async function fetchModPackData(): Promise<void> {
|
||||
try {
|
||||
const data = (await useBaseFetch(`moderation/project/${props.projectId}`, {
|
||||
internal: true,
|
||||
})) as ModerationModpackResponse;
|
||||
const sortedData: ModerationModpackItem[] = [
|
||||
...Object.entries(data.unknown_files || {})
|
||||
.map(
|
||||
([sha1, fileName]): ModerationUnknownModpackItem => ({
|
||||
sha1,
|
||||
file_name: fileName,
|
||||
type: "unknown",
|
||||
status: null,
|
||||
approved: null,
|
||||
proof: "",
|
||||
url: "",
|
||||
title: "",
|
||||
}),
|
||||
)
|
||||
.sort((a, b) => a.file_name.localeCompare(b.file_name)),
|
||||
...Object.entries(data.flame_files || {})
|
||||
.map(
|
||||
([sha1, info]): ModerationFlameModpackItem => ({
|
||||
sha1,
|
||||
file_name: info.file_name,
|
||||
type: "flame",
|
||||
status: null,
|
||||
approved: null,
|
||||
id: info.id,
|
||||
title: info.title || info.file_name,
|
||||
url: info.url || `https://www.curseforge.com/minecraft/mc-mods/${info.id}`,
|
||||
}),
|
||||
)
|
||||
.sort((a, b) => a.file_name.localeCompare(b.file_name)),
|
||||
];
|
||||
|
||||
if (modPackData.value) {
|
||||
const existingMap = new Map(modPackData.value.map((item) => [item.sha1, item]));
|
||||
|
||||
sortedData.forEach((item) => {
|
||||
const existing = existingMap.get(item.sha1);
|
||||
if (existing) {
|
||||
Object.assign(item, {
|
||||
status: existing.status,
|
||||
approved: existing.approved,
|
||||
...(item.type === "unknown" && {
|
||||
proof: (existing as ModerationUnknownModpackItem).proof || "",
|
||||
url: (existing as ModerationUnknownModpackItem).url || "",
|
||||
title: (existing as ModerationUnknownModpackItem).title || "",
|
||||
}),
|
||||
...(item.type === "flame" && {
|
||||
url: (existing as ModerationFlameModpackItem).url || item.url,
|
||||
title: (existing as ModerationFlameModpackItem).title || item.title,
|
||||
}),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
modPackData.value = sortedData;
|
||||
persistAll();
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch modpack data:", error);
|
||||
modPackData.value = [];
|
||||
persistAll();
|
||||
}
|
||||
}
|
||||
|
||||
function goToPrevious(): void {
|
||||
if (currentIndex.value > 0) {
|
||||
currentIndex.value--;
|
||||
persistAll();
|
||||
}
|
||||
}
|
||||
|
||||
function goToNext(): void {
|
||||
if (modPackData.value && currentIndex.value < modPackData.value.length) {
|
||||
currentIndex.value++;
|
||||
|
||||
if (currentIndex.value >= modPackData.value.length) {
|
||||
const judgements = getJudgements();
|
||||
emit("update:modelValue", judgements);
|
||||
emit("complete");
|
||||
clearPersistedData();
|
||||
} else {
|
||||
persistAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(index: number, status: ModerationModpackPermissionApprovalType["id"]): void {
|
||||
if (modPackData.value && modPackData.value[index]) {
|
||||
modPackData.value[index].status = status;
|
||||
modPackData.value[index].approved = null;
|
||||
persistAll();
|
||||
emit("update:modelValue", getJudgements());
|
||||
}
|
||||
}
|
||||
|
||||
function setApproval(index: number, approved: ModerationPermissionType["id"]): void {
|
||||
if (modPackData.value && modPackData.value[index]) {
|
||||
modPackData.value[index].approved = approved;
|
||||
persistAll();
|
||||
emit("update:modelValue", getJudgements());
|
||||
}
|
||||
}
|
||||
|
||||
const canGoNext = computed(() => {
|
||||
if (!modPackData.value || !modPackData.value[currentIndex.value]) return false;
|
||||
const current = modPackData.value[currentIndex.value];
|
||||
return current.status !== null;
|
||||
});
|
||||
|
||||
function getJudgements(): ModerationJudgements {
|
||||
if (!modPackData.value) return {};
|
||||
|
||||
const judgements: ModerationJudgements = {};
|
||||
|
||||
modPackData.value.forEach((item) => {
|
||||
if (item.type === "flame") {
|
||||
judgements[item.sha1] = {
|
||||
type: "flame",
|
||||
id: item.id,
|
||||
status: item.status,
|
||||
link: item.url,
|
||||
title: item.title,
|
||||
file_name: item.file_name,
|
||||
};
|
||||
} else if (item.type === "unknown") {
|
||||
judgements[item.sha1] = {
|
||||
type: "unknown",
|
||||
status: item.status,
|
||||
proof: item.proof,
|
||||
link: item.url,
|
||||
title: item.title,
|
||||
file_name: item.file_name,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return judgements;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadPersistedData();
|
||||
if (!modPackData.value) {
|
||||
fetchModPackData();
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.projectId,
|
||||
() => {
|
||||
clearPersistedData();
|
||||
loadPersistedData();
|
||||
if (!modPackData.value) {
|
||||
fetchModPackData();
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.input-group {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.modpack-buttons {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -172,6 +172,7 @@ const flags = useFeatureFlags();
|
||||
|
||||
.markdown-body {
|
||||
grid-area: body;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.reporter-info {
|
||||
|
||||
@@ -31,9 +31,9 @@
|
||||
class="flex cursor-pointer items-center gap-1 bg-transparent p-0"
|
||||
@click="
|
||||
versionFilter &&
|
||||
(unlockFilterAccordion.isOpen
|
||||
? unlockFilterAccordion.close()
|
||||
: unlockFilterAccordion.open())
|
||||
(unlockFilterAccordion.isOpen
|
||||
? unlockFilterAccordion.close()
|
||||
: unlockFilterAccordion.open())
|
||||
"
|
||||
>
|
||||
<TagItem
|
||||
|
||||
@@ -1,36 +1,7 @@
|
||||
<template>
|
||||
<NewModal ref="mrpackModal" header="Uploading mrpack" :closable="!isLoading" @show="onShow">
|
||||
<div class="flex flex-col gap-4 md:w-[600px]">
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-20"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-20"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div v-if="isLoading" class="w-full">
|
||||
<div class="mb-2 flex justify-between text-sm">
|
||||
<Transition name="phrase-fade" mode="out-in">
|
||||
<span :key="currentPhrase" class="text-lg font-medium text-contrast">{{
|
||||
currentPhrase
|
||||
}}</span>
|
||||
</Transition>
|
||||
<div class="flex flex-col items-end">
|
||||
<span class="text-secondary">{{ Math.round(uploadProgress) }}%</span>
|
||||
<span class="text-xs text-secondary"
|
||||
>{{ formatBytes(uploadedBytes) }} / {{ formatBytes(totalBytes) }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-2 w-full rounded-full bg-divider">
|
||||
<div
|
||||
class="h-2 animate-pulse rounded-full bg-brand transition-all duration-300 ease-out"
|
||||
:style="{ width: `${uploadProgress}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<AppearingProgressBar :max-value="totalBytes" :current-value="uploadedBytes" />
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
@@ -144,7 +115,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { BackupWarning, ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { BackupWarning, ButtonStyled, NewModal, AppearingProgressBar } from "@modrinth/ui";
|
||||
import {
|
||||
UploadIcon,
|
||||
RightArrowIcon,
|
||||
@@ -187,50 +158,9 @@ const hardReset = ref(false);
|
||||
const isLoading = ref(false);
|
||||
const loadingServerCheck = ref(false);
|
||||
const mrpackFile = ref<File | null>(null);
|
||||
const uploadProgress = ref(0);
|
||||
const uploadedBytes = ref(0);
|
||||
const totalBytes = ref(0);
|
||||
|
||||
const uploadPhrases = [
|
||||
"Removing Herobrine...",
|
||||
"Feeding parrots...",
|
||||
"Teaching villagers new trades...",
|
||||
"Convincing creepers to be friendly...",
|
||||
"Polishing diamonds...",
|
||||
"Training wolves to fetch...",
|
||||
"Building pixel art...",
|
||||
"Explaining redstone to beginners...",
|
||||
"Collecting all the cats...",
|
||||
"Negotiating with endermen...",
|
||||
"Planting suspicious stew ingredients...",
|
||||
"Calibrating TNT blast radius...",
|
||||
"Teaching chickens to fly...",
|
||||
"Sorting inventory alphabetically...",
|
||||
"Convincing iron golems to smile...",
|
||||
];
|
||||
|
||||
const currentPhrase = ref("Uploading...");
|
||||
let phraseInterval: NodeJS.Timeout | null = null;
|
||||
const usedPhrases = ref(new Set<number>());
|
||||
|
||||
const getNextPhrase = () => {
|
||||
if (usedPhrases.value.size >= uploadPhrases.length) {
|
||||
const currentPhraseIndex = uploadPhrases.indexOf(currentPhrase.value);
|
||||
usedPhrases.value.clear();
|
||||
if (currentPhraseIndex !== -1) {
|
||||
usedPhrases.value.add(currentPhraseIndex);
|
||||
}
|
||||
}
|
||||
const availableIndices = uploadPhrases
|
||||
.map((_, index) => index)
|
||||
.filter((index) => !usedPhrases.value.has(index));
|
||||
|
||||
const randomIndex = availableIndices[Math.floor(Math.random() * availableIndices.length)];
|
||||
usedPhrases.value.add(randomIndex);
|
||||
|
||||
return uploadPhrases[randomIndex];
|
||||
};
|
||||
|
||||
const isDangerous = computed(() => hardReset.value);
|
||||
const canInstall = computed(() => !mrpackFile.value || isLoading.value || loadingServerCheck.value);
|
||||
|
||||
@@ -259,31 +189,17 @@ const handleReinstall = async () => {
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
uploadProgress.value = 0;
|
||||
uploadProgress.value = 0;
|
||||
uploadedBytes.value = 0;
|
||||
totalBytes.value = mrpackFile.value.size;
|
||||
|
||||
currentPhrase.value = getNextPhrase();
|
||||
phraseInterval = setInterval(() => {
|
||||
currentPhrase.value = getNextPhrase();
|
||||
}, 4500);
|
||||
|
||||
const { onProgress, promise } = props.server.general.reinstallFromMrpack(
|
||||
mrpackFile.value,
|
||||
hardReset.value,
|
||||
);
|
||||
|
||||
onProgress(({ loaded, total, progress }) => {
|
||||
uploadProgress.value = progress;
|
||||
onProgress(({ loaded, total }) => {
|
||||
uploadedBytes.value = loaded;
|
||||
totalBytes.value = total;
|
||||
|
||||
if (phraseInterval && progress >= 100) {
|
||||
clearInterval(phraseInterval);
|
||||
phraseInterval = null;
|
||||
currentPhrase.value = "Installing modpack...";
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -316,10 +232,6 @@ const handleReinstall = async () => {
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
if (phraseInterval) {
|
||||
clearInterval(phraseInterval);
|
||||
phraseInterval = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
const onShow = () => {
|
||||
@@ -328,15 +240,8 @@ const onShow = () => {
|
||||
loadingServerCheck.value = false;
|
||||
isLoading.value = false;
|
||||
mrpackFile.value = null;
|
||||
uploadProgress.value = 0;
|
||||
uploadedBytes.value = 0;
|
||||
totalBytes.value = 0;
|
||||
currentPhrase.value = "Uploading...";
|
||||
usedPhrases.value.clear();
|
||||
if (phraseInterval) {
|
||||
clearInterval(phraseInterval);
|
||||
phraseInterval = null;
|
||||
}
|
||||
};
|
||||
|
||||
const show = () => mrpackModal.value?.show();
|
||||
@@ -349,14 +254,4 @@ defineExpose({ show, hide });
|
||||
.stylized-toggle:checked::after {
|
||||
background: var(--color-accent-contrast) !important;
|
||||
}
|
||||
|
||||
.phrase-fade-enter-active,
|
||||
.phrase-fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.phrase-fade-enter-from,
|
||||
.phrase-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -102,7 +102,7 @@ export class ModrinthServer {
|
||||
try {
|
||||
const fileData = await useServersFetch(`/download?path=/server-icon-original.png`, {
|
||||
override: auth,
|
||||
retry: false,
|
||||
retry: 1, // Reduce retries for optional resources
|
||||
});
|
||||
|
||||
if (fileData instanceof Blob && import.meta.client) {
|
||||
@@ -124,8 +124,14 @@ export class ModrinthServer {
|
||||
return dataURL;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ModrinthServerError && error.statusCode === 404) {
|
||||
if (iconUrl) {
|
||||
if (error instanceof ModrinthServerError) {
|
||||
if (error.statusCode && error.statusCode >= 500) {
|
||||
console.debug("Service unavailable, skipping icon processing");
|
||||
sharedImage.value = undefined;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (error.statusCode === 404 && iconUrl) {
|
||||
try {
|
||||
const response = await fetch(iconUrl);
|
||||
if (!response.ok) throw new Error("Failed to fetch icon");
|
||||
@@ -187,6 +193,44 @@ export class ModrinthServer {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async testNodeReachability(): Promise<boolean> {
|
||||
if (!this.general?.node?.instance) {
|
||||
console.warn("No node instance available for ping test");
|
||||
return false;
|
||||
}
|
||||
|
||||
const wsUrl = `wss://${this.general.node.instance}/pingtest`;
|
||||
|
||||
try {
|
||||
return await new Promise((resolve) => {
|
||||
const socket = new WebSocket(wsUrl);
|
||||
const timeout = setTimeout(() => {
|
||||
socket.close();
|
||||
resolve(false);
|
||||
}, 5000);
|
||||
|
||||
socket.onopen = () => {
|
||||
clearTimeout(timeout);
|
||||
socket.send(performance.now().toString());
|
||||
};
|
||||
|
||||
socket.onmessage = () => {
|
||||
clearTimeout(timeout);
|
||||
socket.close();
|
||||
resolve(true);
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(false);
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Failed to ping node ${wsUrl}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async refresh(
|
||||
modules: ModuleName[] = [],
|
||||
options?: {
|
||||
@@ -200,6 +244,8 @@ export class ModrinthServer {
|
||||
: (["general", "content", "backups", "network", "startup", "ws", "fs"] as ModuleName[]);
|
||||
|
||||
for (const module of modulesToRefresh) {
|
||||
this.errors[module] = undefined;
|
||||
|
||||
try {
|
||||
switch (module) {
|
||||
case "general": {
|
||||
@@ -250,7 +296,7 @@ export class ModrinthServer {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (error.statusCode === 503) {
|
||||
if (error.statusCode && error.statusCode >= 500) {
|
||||
console.debug(`Temporary ${module} unavailable:`, error.message);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -22,26 +22,49 @@ export class FSModule extends ServerModule {
|
||||
this.opsQueuedForModification = [];
|
||||
}
|
||||
|
||||
private async retryWithAuth<T>(requestFn: () => Promise<T>): Promise<T> {
|
||||
private async retryWithAuth<T>(
|
||||
requestFn: () => Promise<T>,
|
||||
ignoreFailure: boolean = false,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await requestFn();
|
||||
} catch (error) {
|
||||
if (error instanceof ModrinthServerError && error.statusCode === 401) {
|
||||
console.debug("Auth failed, refreshing JWT and retrying");
|
||||
await this.fetch(); // Refresh auth
|
||||
return await requestFn();
|
||||
}
|
||||
|
||||
const available = await this.server.testNodeReachability();
|
||||
if (!available && !ignoreFailure) {
|
||||
this.server.moduleErrors.general = {
|
||||
error: new ModrinthServerError(
|
||||
"Unable to reach node. FS operation failed and subsequent ping test failed.",
|
||||
500,
|
||||
error as Error,
|
||||
"fs",
|
||||
),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
listDirContents(path: string, page: number, pageSize: number): Promise<DirectoryResponse> {
|
||||
listDirContents(
|
||||
path: string,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
ignoreFailure: boolean = false,
|
||||
): Promise<DirectoryResponse> {
|
||||
return this.retryWithAuth(async () => {
|
||||
const encodedPath = encodeURIComponent(path);
|
||||
return await useServersFetch(`/list?path=${encodedPath}&page=${page}&page_size=${pageSize}`, {
|
||||
override: this.auth,
|
||||
retry: false,
|
||||
});
|
||||
});
|
||||
}, ignoreFailure);
|
||||
}
|
||||
|
||||
createFileOrFolder(path: string, type: "file" | "directory"): Promise<void> {
|
||||
@@ -150,7 +173,7 @@ export class FSModule extends ServerModule {
|
||||
});
|
||||
}
|
||||
|
||||
downloadFile(path: string, raw?: boolean): Promise<any> {
|
||||
downloadFile(path: string, raw: boolean = false, ignoreFailure: boolean = false): Promise<any> {
|
||||
return this.retryWithAuth(async () => {
|
||||
const encodedPath = encodeURIComponent(path);
|
||||
const fileData = await useServersFetch(`/download?path=${encodedPath}`, {
|
||||
@@ -161,7 +184,7 @@ export class FSModule extends ServerModule {
|
||||
return raw ? fileData : await fileData.text();
|
||||
}
|
||||
return fileData;
|
||||
});
|
||||
}, ignoreFailure);
|
||||
}
|
||||
|
||||
extractFile(
|
||||
|
||||
@@ -46,13 +46,18 @@ export class GeneralModule extends ServerModule implements ServerGeneral {
|
||||
data.image = (await this.server.processImage(data.project?.icon_url)) ?? undefined;
|
||||
}
|
||||
|
||||
const motd = await this.getMotd();
|
||||
if (motd === "A Minecraft Server") {
|
||||
await this.setMotd(
|
||||
`§b${data.project?.title || data.loader + " " + data.mc_version} §f♦ §aModrinth Servers`,
|
||||
);
|
||||
try {
|
||||
const motd = await this.getMotd();
|
||||
if (motd === "A Minecraft Server") {
|
||||
await this.setMotd(
|
||||
`§b${data.project?.title || data.loader + " " + data.mc_version} §f♦ §aModrinth Servers`,
|
||||
);
|
||||
}
|
||||
data.motd = motd;
|
||||
} catch {
|
||||
console.error("[Modrinth Servers] [General] Failed to fetch MOTD.");
|
||||
data.motd = undefined;
|
||||
}
|
||||
data.motd = motd;
|
||||
|
||||
// Copy data to this module
|
||||
Object.assign(this, data);
|
||||
@@ -178,7 +183,7 @@ export class GeneralModule extends ServerModule implements ServerGeneral {
|
||||
|
||||
async getMotd(): Promise<string | undefined> {
|
||||
try {
|
||||
const props = await this.server.fs.downloadFile("/server.properties");
|
||||
const props = await this.server.fs.downloadFile("/server.properties", false, true);
|
||||
if (props) {
|
||||
const lines = props.split("\n");
|
||||
for (const line of lines) {
|
||||
|
||||
@@ -42,6 +42,23 @@ export async function useServersFetch<T>(
|
||||
retry = method === "GET" ? 3 : 0,
|
||||
} = options;
|
||||
|
||||
const circuitBreakerKey = `${module || "default"}_${path}`;
|
||||
const failureCount = useState<number>(`fetch_failures_${circuitBreakerKey}`, () => 0);
|
||||
const lastFailureTime = useState<number>(`last_failure_${circuitBreakerKey}`, () => 0);
|
||||
|
||||
const now = Date.now();
|
||||
if (failureCount.value >= 3 && now - lastFailureTime.value < 30000) {
|
||||
const error = new ModrinthServersFetchError(
|
||||
"[Modrinth Servers] Circuit breaker open - too many recent failures",
|
||||
503,
|
||||
);
|
||||
throw new ModrinthServerError("Service temporarily unavailable", 503, error, module);
|
||||
}
|
||||
|
||||
if (now - lastFailureTime.value > 30000) {
|
||||
failureCount.value = 0;
|
||||
}
|
||||
|
||||
const base = (import.meta.server ? config.pyroBaseUrl : config.public.pyroBaseUrl)?.replace(
|
||||
/\/$/,
|
||||
"",
|
||||
@@ -69,6 +86,7 @@ export async function useServersFetch<T>(
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"User-Agent": "Modrinth/1.0 (https://modrinth.com)",
|
||||
"X-Archon-Request": "true",
|
||||
Vary: "Accept, Origin",
|
||||
};
|
||||
|
||||
@@ -94,10 +112,12 @@ export async function useServersFetch<T>(
|
||||
const response = await $fetch<T>(fullUrl, {
|
||||
method,
|
||||
headers,
|
||||
body: body && contentType === "application/json" ? JSON.stringify(body) : body ?? undefined,
|
||||
body:
|
||||
body && contentType === "application/json" ? JSON.stringify(body) : (body ?? undefined),
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
failureCount.value = 0;
|
||||
return response;
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
@@ -107,6 +127,11 @@ export async function useServersFetch<T>(
|
||||
const statusCode = error.response?.status;
|
||||
const statusText = error.response?.statusText || "Unknown error";
|
||||
|
||||
if (statusCode && statusCode >= 500) {
|
||||
failureCount.value++;
|
||||
lastFailureTime.value = now;
|
||||
}
|
||||
|
||||
let v1Error: V1ErrorInfo | undefined;
|
||||
if (error.data?.error && error.data?.description) {
|
||||
v1Error = {
|
||||
@@ -134,9 +159,11 @@ export async function useServersFetch<T>(
|
||||
? errorMessages[statusCode]
|
||||
: `HTTP Error: ${statusCode || "unknown"} ${statusText}`;
|
||||
|
||||
const isRetryable = statusCode ? [408, 429, 500, 502, 504].includes(statusCode) : true;
|
||||
const isRetryable = statusCode ? [408, 429].includes(statusCode) : false;
|
||||
const is5xxRetryable =
|
||||
statusCode && statusCode >= 500 && statusCode < 600 && method === "GET" && attempts === 1;
|
||||
|
||||
if (!isRetryable || attempts >= maxAttempts) {
|
||||
if (!(isRetryable || is5xxRetryable) || attempts >= maxAttempts) {
|
||||
console.error("Fetch error:", error);
|
||||
|
||||
const fetchError = new ModrinthServersFetchError(
|
||||
@@ -147,7 +174,8 @@ export async function useServersFetch<T>(
|
||||
throw new ModrinthServerError(error.message, statusCode, fetchError, module, v1Error);
|
||||
}
|
||||
|
||||
const delay = Math.min(1000 * Math.pow(2, attempts - 1) + Math.random() * 1000, 10000);
|
||||
const baseDelay = statusCode && statusCode >= 500 ? 5000 : 1000;
|
||||
const delay = Math.min(baseDelay * Math.pow(2, attempts - 1) + Math.random() * 1000, 15000);
|
||||
console.warn(`Retrying request in ${delay}ms (attempt ${attempts}/${maxAttempts - 1})`);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
continue;
|
||||
|
||||
12
apps/frontend/src/composables/util.ts
Normal file
12
apps/frontend/src/composables/util.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export const useNotificationRightwards = () => {
|
||||
const isVisible = useState("moderation-checklist-notifications", () => false);
|
||||
|
||||
const setVisible = (visible: boolean) => {
|
||||
isVisible.value = visible;
|
||||
};
|
||||
|
||||
return {
|
||||
isVisible: readonly(isVisible),
|
||||
setVisible,
|
||||
};
|
||||
};
|
||||
@@ -700,7 +700,6 @@ import {
|
||||
PackageOpenIcon,
|
||||
DiscordIcon,
|
||||
BlueskyIcon,
|
||||
TumblrIcon,
|
||||
TwitterIcon,
|
||||
MastodonIcon,
|
||||
GithubIcon,
|
||||
@@ -1185,13 +1184,6 @@ const socialLinks = [
|
||||
icon: MastodonIcon,
|
||||
rel: "me",
|
||||
},
|
||||
{
|
||||
label: formatMessage(
|
||||
defineMessage({ id: "layout.footer.social.tumblr", defaultMessage: "Tumblr" }),
|
||||
),
|
||||
href: "https://tumblr.com/modrinth",
|
||||
icon: TumblrIcon,
|
||||
},
|
||||
{
|
||||
label: formatMessage(defineMessage({ id: "layout.footer.social.x", defaultMessage: "X" })),
|
||||
href: "https://x.com/modrinth",
|
||||
@@ -1346,6 +1338,15 @@ const footerLinks = [
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "/legal/copyright",
|
||||
label: formatMessage(
|
||||
defineMessage({
|
||||
id: "layout.footer.legal.copyright-policy",
|
||||
defaultMessage: "Copyright Policy and DMCA",
|
||||
}),
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -383,15 +383,15 @@
|
||||
"layout.footer.about": {
|
||||
"message": "About"
|
||||
},
|
||||
"layout.footer.about.news": {
|
||||
"message": "News"
|
||||
},
|
||||
"layout.footer.about.careers": {
|
||||
"message": "Careers"
|
||||
},
|
||||
"layout.footer.about.changelog": {
|
||||
"message": "Changelog"
|
||||
},
|
||||
"layout.footer.about.news": {
|
||||
"message": "News"
|
||||
},
|
||||
"layout.footer.about.rewards-program": {
|
||||
"message": "Rewards Program"
|
||||
},
|
||||
@@ -404,6 +404,9 @@
|
||||
"layout.footer.legal-disclaimer": {
|
||||
"message": "NOT AN OFFICIAL MINECRAFT SERVICE. NOT APPROVED BY OR ASSOCIATED WITH MOJANG OR MICROSOFT."
|
||||
},
|
||||
"layout.footer.legal.copyright-policy": {
|
||||
"message": "Copyright Policy and DMCA"
|
||||
},
|
||||
"layout.footer.legal.privacy-policy": {
|
||||
"message": "Privacy Policy"
|
||||
},
|
||||
@@ -458,9 +461,6 @@
|
||||
"layout.footer.social.mastodon": {
|
||||
"message": "Mastodon"
|
||||
},
|
||||
"layout.footer.social.tumblr": {
|
||||
"message": "Tumblr"
|
||||
},
|
||||
"layout.footer.social.x": {
|
||||
"message": "X"
|
||||
},
|
||||
|
||||
@@ -29,12 +29,11 @@
|
||||
class="settings-header__icon"
|
||||
/>
|
||||
<div class="settings-header__text">
|
||||
<h1 class="wrap-as-needed">
|
||||
{{ project.title }}
|
||||
</h1>
|
||||
<h1 class="wrap-as-needed">{{ project.title }}</h1>
|
||||
<ProjectStatusBadge :status="project.status" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Project settings</h2>
|
||||
<NavStack>
|
||||
<NavStackItem
|
||||
@@ -111,6 +110,7 @@
|
||||
</NavStack>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div class="normal-page__content">
|
||||
<ProjectMemberHeader
|
||||
v-if="currentMember"
|
||||
@@ -145,6 +145,7 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="experimental-styles-within">
|
||||
<NewModal ref="settingsModal">
|
||||
<template #title>
|
||||
@@ -174,9 +175,11 @@
|
||||
<div
|
||||
class="animation-ring-3 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-40"
|
||||
></div>
|
||||
|
||||
<div
|
||||
class="animation-ring-2 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-60"
|
||||
></div>
|
||||
|
||||
<div
|
||||
class="animation-ring-1 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight"
|
||||
>
|
||||
@@ -219,8 +222,7 @@
|
||||
:href="`modrinth://mod/${project.slug}`"
|
||||
@click="() => installWithApp()"
|
||||
>
|
||||
<ModrinthIcon aria-hidden="true" />
|
||||
Install with Modrinth App
|
||||
<ModrinthIcon aria-hidden="true" /> Install with Modrinth App
|
||||
<ExternalIcon aria-hidden="true" />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
@@ -240,6 +242,7 @@
|
||||
<div class="flex h-[2px] w-full rounded-2xl bg-button-bg"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto flex w-fit flex-col gap-2">
|
||||
<ButtonStyled v-if="project.game_versions.length === 1">
|
||||
<div class="disabled button-like">
|
||||
@@ -327,8 +330,7 @@
|
||||
}
|
||||
"
|
||||
>
|
||||
{{ gameVersion }}
|
||||
<CheckIcon v-if="userSelectedGameVersion === gameVersion" />
|
||||
{{ gameVersion }} <CheckIcon v-if="userSelectedGameVersion === gameVersion" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</ScrollablePanel>
|
||||
@@ -419,7 +421,6 @@
|
||||
</ScrollablePanel>
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
<AutomaticAccordion div class="flex flex-col gap-2">
|
||||
<VersionSummary
|
||||
v-if="filteredRelease"
|
||||
@@ -470,10 +471,14 @@
|
||||
class="new-page sidebar"
|
||||
:class="{
|
||||
'alt-layout': cosmetics.leftContentLayout,
|
||||
'ultimate-sidebar':
|
||||
'checklist-open':
|
||||
showModerationChecklist &&
|
||||
!collapsedModerationChecklist &&
|
||||
!flags.alwaysShowChecklistAsPopup,
|
||||
'checklist-collapsed':
|
||||
showModerationChecklist &&
|
||||
collapsedModerationChecklist &&
|
||||
!flags.alwaysShowChecklistAsPopup,
|
||||
}"
|
||||
>
|
||||
<div class="normal-page__header relative my-4">
|
||||
@@ -485,11 +490,11 @@
|
||||
:color="route.name === 'type-id-version-version' ? `standard` : `brand`"
|
||||
>
|
||||
<button @click="(event) => downloadModal.show(event)">
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
Download
|
||||
<DownloadIcon aria-hidden="true" /> Download
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div class="contents sm:hidden">
|
||||
<ButtonStyled
|
||||
size="large"
|
||||
@@ -554,9 +559,11 @@
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<p class="m-0 text-wrap text-sm font-medium leading-tight text-secondary">
|
||||
Modrinth Servers is the easiest way to play with your friends without hassle!
|
||||
</p>
|
||||
|
||||
<p class="m-0 text-wrap text-sm font-bold text-primary">
|
||||
Starting at $5<span class="text-xs"> / month</span>
|
||||
</p>
|
||||
@@ -621,6 +628,7 @@
|
||||
{{ option.name }}
|
||||
</Checkbox>
|
||||
</div>
|
||||
|
||||
<div v-else class="menu-text">
|
||||
<p class="popout-text">No collections found.</p>
|
||||
</div>
|
||||
@@ -628,8 +636,7 @@
|
||||
class="btn collection-button"
|
||||
@click="(event) => $refs.modal_collection.show(event)"
|
||||
>
|
||||
<PlusIcon aria-hidden="true" />
|
||||
Create new collection
|
||||
<PlusIcon aria-hidden="true" /> Create new collection
|
||||
</button>
|
||||
</template>
|
||||
</PopoutMenu>
|
||||
@@ -712,25 +719,14 @@
|
||||
:dropdown-id="`${baseId}-more-options`"
|
||||
>
|
||||
<MoreVerticalIcon aria-hidden="true" />
|
||||
<template #analytics>
|
||||
<ChartIcon aria-hidden="true" />
|
||||
Analytics
|
||||
</template>
|
||||
<template #analytics> <ChartIcon aria-hidden="true" /> Analytics </template>
|
||||
<template #moderation-checklist>
|
||||
<ScaleIcon aria-hidden="true" />
|
||||
Review project
|
||||
</template>
|
||||
<template #report>
|
||||
<ReportIcon aria-hidden="true" />
|
||||
Report
|
||||
</template>
|
||||
<template #copy-id>
|
||||
<ClipboardCopyIcon aria-hidden="true" />
|
||||
Copy ID
|
||||
<ScaleIcon aria-hidden="true" /> Review project
|
||||
</template>
|
||||
<template #report> <ReportIcon aria-hidden="true" /> Report </template>
|
||||
<template #copy-id> <ClipboardCopyIcon aria-hidden="true" /> Copy ID </template>
|
||||
<template #copy-permalink>
|
||||
<ClipboardCopyIcon aria-hidden="true" />
|
||||
Copy permanent link
|
||||
<ClipboardCopyIcon aria-hidden="true" /> Copy permanent link
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
@@ -756,6 +752,7 @@
|
||||
updates unless the author decides to unarchive the project.
|
||||
</MessageBanner>
|
||||
</div>
|
||||
|
||||
<div class="normal-page__sidebar">
|
||||
<ProjectSidebarCompatibility
|
||||
:project="project"
|
||||
@@ -785,6 +782,7 @@
|
||||
/>
|
||||
<div class="card flex-card experimental-styles-within">
|
||||
<h2>{{ formatMessage(detailsMessages.title) }}</h2>
|
||||
|
||||
<div class="details-list">
|
||||
<div class="details-list__item">
|
||||
<BookTextIcon aria-hidden="true" />
|
||||
@@ -813,53 +811,48 @@
|
||||
<span v-else>{{ licenseIdDisplay }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="project.approved"
|
||||
v-tooltip="$dayjs(project.approved).format('MMMM D, YYYY [at] h:mm A')"
|
||||
class="details-list__item"
|
||||
>
|
||||
<CalendarIcon aria-hidden="true" />
|
||||
<div>
|
||||
{{ formatMessage(detailsMessages.published, { date: publishedDate }) }}
|
||||
</div>
|
||||
<div>{{ formatMessage(detailsMessages.published, { date: publishedDate }) }}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
v-tooltip="$dayjs(project.published).format('MMMM D, YYYY [at] h:mm A')"
|
||||
class="details-list__item"
|
||||
>
|
||||
<CalendarIcon aria-hidden="true" />
|
||||
<div>
|
||||
{{ formatMessage(detailsMessages.created, { date: createdDate }) }}
|
||||
</div>
|
||||
<div>{{ formatMessage(detailsMessages.created, { date: createdDate }) }}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="project.status === 'processing' && project.queued"
|
||||
v-tooltip="$dayjs(project.queued).format('MMMM D, YYYY [at] h:mm A')"
|
||||
class="details-list__item"
|
||||
>
|
||||
<ScaleIcon aria-hidden="true" />
|
||||
<div>
|
||||
{{ formatMessage(detailsMessages.submitted, { date: submittedDate }) }}
|
||||
</div>
|
||||
<div>{{ formatMessage(detailsMessages.submitted, { date: submittedDate }) }}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="versions.length > 0 && project.updated"
|
||||
v-tooltip="$dayjs(project.updated).format('MMMM D, YYYY [at] h:mm A')"
|
||||
class="details-list__item"
|
||||
>
|
||||
<VersionIcon aria-hidden="true" />
|
||||
<div>
|
||||
{{ formatMessage(detailsMessages.updated, { date: updatedDate }) }}
|
||||
</div>
|
||||
<div>{{ formatMessage(detailsMessages.updated, { date: updatedDate }) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="normal-page__content">
|
||||
<div class="overflow-x-auto">
|
||||
<NavTabs :links="navLinks" class="mb-4" />
|
||||
</div>
|
||||
<div class="overflow-x-auto"><NavTabs :links="navLinks" class="mb-4" /></div>
|
||||
<NuxtPage
|
||||
v-model:project="project"
|
||||
v-model:versions="versions"
|
||||
@@ -877,8 +870,10 @@
|
||||
@delete-version="deleteVersion"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="normal-page__ultimate-sidebar">
|
||||
<ModerationChecklist
|
||||
<!-- Uncomment this to enable the old moderation checklist. -->
|
||||
<!-- <ModerationChecklist
|
||||
v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist"
|
||||
:project="project"
|
||||
:future-projects="futureProjects"
|
||||
@@ -886,11 +881,25 @@
|
||||
:collapsed="collapsedModerationChecklist"
|
||||
@exit="showModerationChecklist = false"
|
||||
@toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist"
|
||||
/>
|
||||
/> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist"
|
||||
class="moderation-checklist"
|
||||
>
|
||||
<NewModerationChecklist
|
||||
:project="project"
|
||||
:future-project-ids="futureProjectIds"
|
||||
:collapsed="collapsedModerationChecklist"
|
||||
@exit="showModerationChecklist = false"
|
||||
@toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
BookmarkIcon,
|
||||
@@ -950,16 +959,16 @@ import {
|
||||
isUnderReview,
|
||||
renderString,
|
||||
} from "@modrinth/utils";
|
||||
import { navigateTo } from "#app";
|
||||
import dayjs from "dayjs";
|
||||
import { Tooltip } from "floating-vue";
|
||||
import { useLocalStorage } from "@vueuse/core";
|
||||
import { navigateTo } from "#app";
|
||||
import Accordion from "~/components/ui/Accordion.vue";
|
||||
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
|
||||
import AutomaticAccordion from "~/components/ui/AutomaticAccordion.vue";
|
||||
import Breadcrumbs from "~/components/ui/Breadcrumbs.vue";
|
||||
import CollectionCreateModal from "~/components/ui/CollectionCreateModal.vue";
|
||||
import MessageBanner from "~/components/ui/MessageBanner.vue";
|
||||
import ModerationChecklist from "~/components/ui/ModerationChecklist.vue";
|
||||
import NavStack from "~/components/ui/NavStack.vue";
|
||||
import NavStackItem from "~/components/ui/NavStackItem.vue";
|
||||
import NavTabs from "~/components/ui/NavTabs.vue";
|
||||
@@ -967,6 +976,7 @@ import ProjectMemberHeader from "~/components/ui/ProjectMemberHeader.vue";
|
||||
import { userCollectProject } from "~/composables/user.js";
|
||||
import { reportProject } from "~/utils/report-helpers.ts";
|
||||
import { saveFeatureFlags } from "~/composables/featureFlags.ts";
|
||||
import NewModerationChecklist from "~/components/ui/moderation/NewModerationChecklist.vue";
|
||||
|
||||
const data = useNuxtApp();
|
||||
const route = useNativeRoute();
|
||||
@@ -980,6 +990,7 @@ const flags = useFeatureFlags();
|
||||
const cosmetics = useCosmetics();
|
||||
|
||||
const { formatMessage } = useVIntl();
|
||||
const { setVisible } = useNotificationRightwards();
|
||||
|
||||
const settingsModal = ref();
|
||||
const downloadModal = ref();
|
||||
@@ -1551,12 +1562,28 @@ async function copyPermalink() {
|
||||
|
||||
const collapsedChecklist = ref(false);
|
||||
|
||||
const showModerationChecklist = ref(false);
|
||||
const collapsedModerationChecklist = ref(false);
|
||||
const futureProjects = ref([]);
|
||||
const showModerationChecklist = useLocalStorage(
|
||||
`show-moderation-checklist-${project.value.id}`,
|
||||
false,
|
||||
);
|
||||
const collapsedModerationChecklist = useLocalStorage("collapsed-moderation-checklist", false);
|
||||
|
||||
const futureProjectIds = useLocalStorage("moderation-future-projects", []);
|
||||
|
||||
watch(futureProjectIds, (newValue) => {
|
||||
console.log("Future project IDs updated:", newValue);
|
||||
});
|
||||
|
||||
watch(
|
||||
showModerationChecklist,
|
||||
(newValue) => {
|
||||
setVisible(newValue);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
if (import.meta.client && history && history.state && history.state.showChecklist) {
|
||||
showModerationChecklist.value = true;
|
||||
futureProjects.value = history.state.projects;
|
||||
}
|
||||
|
||||
function closeDownloadModal(event) {
|
||||
@@ -1626,6 +1653,7 @@ const navLinks = computed(() => {
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.settings-header {
|
||||
display: flex;
|
||||
@@ -1781,4 +1809,16 @@ const navLinks = computed(() => {
|
||||
left: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.moderation-checklist {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
right: 1rem;
|
||||
overflow-y: auto;
|
||||
z-index: 50;
|
||||
|
||||
> div {
|
||||
box-shadow: 0 0 15px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -705,9 +705,9 @@ export default defineNuxtComponent({
|
||||
}
|
||||
|
||||
.gallery-body {
|
||||
flex-grow: 1;
|
||||
width: calc(100% - 2 * var(--spacing-card-md));
|
||||
padding: var(--spacing-card-sm) var(--spacing-card-md);
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
.gallery-info {
|
||||
h2 {
|
||||
|
||||
@@ -150,9 +150,26 @@
|
||||
</template>
|
||||
</span>
|
||||
<span class="text-sm text-secondary">
|
||||
<span
|
||||
v-if="charge.status === 'cancelled' && $dayjs(charge.due).isBefore($dayjs())"
|
||||
class="font-bold"
|
||||
>
|
||||
Ended:
|
||||
</span>
|
||||
<span v-else-if="charge.status === 'cancelled'" class="font-bold">Ends:</span>
|
||||
<span v-else-if="charge.type === 'refund'" class="font-bold">Issued:</span>
|
||||
<span v-else class="font-bold">Due:</span>
|
||||
{{ dayjs(charge.due).format("MMMM D, YYYY [at] h:mma") }}
|
||||
<span class="text-secondary">({{ formatRelativeTime(charge.due) }}) </span>
|
||||
</span>
|
||||
<span v-if="charge.last_attempt != null" class="text-sm text-secondary">
|
||||
<span v-if="charge.status === 'failed'" class="font-bold">Last attempt:</span>
|
||||
<span v-else class="font-bold">Charged:</span>
|
||||
{{ dayjs(charge.last_attempt).format("MMMM D, YYYY [at] h:mma") }}
|
||||
<span class="text-secondary"
|
||||
>({{ formatRelativeTime(charge.last_attempt) }})
|
||||
</span>
|
||||
</span>
|
||||
<div class="flex w-full items-center gap-1 text-xs text-secondary">
|
||||
{{ charge.status }}
|
||||
⋅
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
TrashIcon,
|
||||
SearchIcon,
|
||||
@@ -17,76 +17,134 @@ import LatestNewsRow from "~/components/ui/news/LatestNewsRow.vue";
|
||||
|
||||
import { homePageProjects } from "~/generated/state.json";
|
||||
|
||||
const os = ref(null);
|
||||
const downloadWindows = ref(null);
|
||||
const downloadLinux = ref(null);
|
||||
const downloadSection = ref(null);
|
||||
const windowsLink = ref(null);
|
||||
const linuxLinks = {
|
||||
appImage: null,
|
||||
deb: null,
|
||||
rpm: null,
|
||||
thirdParty: "https://support.modrinth.com/en/articles/9298760",
|
||||
};
|
||||
const macLinks = {
|
||||
universal: null,
|
||||
};
|
||||
interface LauncherPlatform {
|
||||
install_urls: string[];
|
||||
}
|
||||
|
||||
let downloadLauncher;
|
||||
interface LauncherUpdates {
|
||||
platforms: {
|
||||
"darwin-aarch64": LauncherPlatform;
|
||||
"windows-x86_64": LauncherPlatform;
|
||||
"linux-x86_64": LauncherPlatform;
|
||||
};
|
||||
}
|
||||
|
||||
type OSType = "Mac" | "Windows" | "Linux" | null;
|
||||
|
||||
const downloadWindows = ref<HTMLAnchorElement | null>(null);
|
||||
const downloadLinux = ref<HTMLAnchorElement | null>(null);
|
||||
const downloadSection = ref<HTMLElement | null>(null);
|
||||
const windowsLink = ref<string | null>(null);
|
||||
|
||||
const linuxLinks = reactive({
|
||||
appImage: null as string | null,
|
||||
deb: null as string | null,
|
||||
rpm: null as string | null,
|
||||
thirdParty: "https://support.modrinth.com/en/articles/9298760",
|
||||
});
|
||||
|
||||
const macLinks = reactive({
|
||||
universal: null as string | null,
|
||||
});
|
||||
|
||||
const newProjects = homePageProjects.slice(0, 40);
|
||||
const val = Math.ceil(newProjects.length / 6);
|
||||
const rows = ref([
|
||||
const rows = [
|
||||
newProjects.slice(0, val),
|
||||
newProjects.slice(val, val * 2),
|
||||
newProjects.slice(val * 2, val * 3),
|
||||
newProjects.slice(val * 3, val * 4),
|
||||
newProjects.slice(val * 4, val * 5),
|
||||
]);
|
||||
];
|
||||
|
||||
const [{ data: launcherUpdates }] = await Promise.all([
|
||||
await useAsyncData("launcherUpdates", () =>
|
||||
$fetch("https://launcher-files.modrinth.com/updates.json"),
|
||||
),
|
||||
]);
|
||||
const { data: launcherUpdates } = await useFetch<LauncherUpdates>(
|
||||
"https://launcher-files.modrinth.com/updates.json?new",
|
||||
{
|
||||
server: false,
|
||||
getCachedData(key, nuxtApp) {
|
||||
const cached = (nuxtApp.ssrContext?.cache as any)?.[key] || nuxtApp.payload.data[key];
|
||||
if (!cached) return;
|
||||
|
||||
macLinks.universal = launcherUpdates.value.platforms["darwin-aarch64"].install_urls[0];
|
||||
windowsLink.value = launcherUpdates.value.platforms["windows-x86_64"].install_urls[0];
|
||||
linuxLinks.appImage = launcherUpdates.value.platforms["linux-x86_64"].install_urls[1];
|
||||
linuxLinks.deb = launcherUpdates.value.platforms["linux-x86_64"].install_urls[0];
|
||||
linuxLinks.rpm = launcherUpdates.value.platforms["linux-x86_64"].install_urls[2];
|
||||
const now = Date.now();
|
||||
const cacheTime = cached._cacheTime || 0;
|
||||
const maxAge = 5 * 60 * 1000;
|
||||
|
||||
onMounted(() => {
|
||||
os.value = navigator?.platform.toString();
|
||||
os.value = os.value?.includes("Mac")
|
||||
? "Mac"
|
||||
: os.value?.includes("Win")
|
||||
? "Windows"
|
||||
: os.value?.includes("Linux")
|
||||
? "Linux"
|
||||
: null;
|
||||
if (now - cacheTime > maxAge) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return cached;
|
||||
},
|
||||
transform(data) {
|
||||
return {
|
||||
...data,
|
||||
_cacheTime: Date.now(),
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const platform = computed<string>(() => {
|
||||
if (import.meta.server) {
|
||||
const headers = useRequestHeaders();
|
||||
return headers["user-agent"] || "";
|
||||
} else {
|
||||
return navigator.userAgent || "";
|
||||
}
|
||||
});
|
||||
const os = computed<OSType>(() => {
|
||||
if (platform.value.includes("Mac")) {
|
||||
return "Mac";
|
||||
} else if (platform.value.includes("Win")) {
|
||||
return "Windows";
|
||||
} else if (platform.value.includes("Linux")) {
|
||||
return "Linux";
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const downloadLauncher = computed(() => {
|
||||
if (os.value === "Windows") {
|
||||
downloadLauncher = () => {
|
||||
downloadWindows.value.click();
|
||||
return () => {
|
||||
downloadWindows.value?.click();
|
||||
};
|
||||
} else if (os.value === "Linux") {
|
||||
downloadLauncher = () => {
|
||||
downloadLinux.value.click();
|
||||
return () => {
|
||||
downloadLinux.value?.click();
|
||||
};
|
||||
} else {
|
||||
downloadLauncher = () => {
|
||||
return () => {
|
||||
scrollToSection();
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const handleDownload = () => {
|
||||
downloadLauncher.value();
|
||||
};
|
||||
|
||||
watch(
|
||||
launcherUpdates,
|
||||
(newData) => {
|
||||
if (newData?.platforms) {
|
||||
macLinks.universal = newData.platforms["darwin-aarch64"]?.install_urls[0] || null;
|
||||
windowsLink.value = newData.platforms["windows-x86_64"]?.install_urls[0] || null;
|
||||
linuxLinks.appImage = newData.platforms["linux-x86_64"]?.install_urls[1] || null;
|
||||
linuxLinks.deb = newData.platforms["linux-x86_64"]?.install_urls[0] || null;
|
||||
linuxLinks.rpm = newData.platforms["linux-x86_64"]?.install_urls[2] || null;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const scrollToSection = () => {
|
||||
nextTick(() => {
|
||||
window.scrollTo({
|
||||
top: downloadSection.value.offsetTop,
|
||||
behavior: "smooth",
|
||||
});
|
||||
if (downloadSection.value) {
|
||||
window.scrollTo({
|
||||
top: downloadSection.value.offsetTop,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -119,7 +177,7 @@ useSeoMeta({
|
||||
v-if="os"
|
||||
class="iconified-button brand-button btn btn-large"
|
||||
rel="noopener nofollow"
|
||||
@click="downloadLauncher"
|
||||
@click="handleDownload"
|
||||
>
|
||||
<svg
|
||||
v-if="os === 'Linux'"
|
||||
@@ -485,7 +543,7 @@ useSeoMeta({
|
||||
class="project button-animation gradient-border"
|
||||
:to="`/${project.project_type}/${project.slug ? project.slug : project.id}`"
|
||||
>
|
||||
<Avatar :src="project.icon_url" :alt="project.title" size="sm" loading="lazy" />
|
||||
<Avatar :src="project.icon_url!" :alt="project.title" size="sm" />
|
||||
<div class="project-info">
|
||||
<span class="title">
|
||||
{{ project.title }}
|
||||
@@ -596,9 +654,7 @@ useSeoMeta({
|
||||
</div>
|
||||
<div class="description">
|
||||
Modrinth’s launcher is fully open source. You can view the source code on our
|
||||
<a href="https://github.com/modrinth/theseus" rel="noopener" :target="$external()"
|
||||
>GitHub</a
|
||||
>!
|
||||
<a href="https://github.com/modrinth/theseus" rel="noopener" target="_blank">GitHub</a>!
|
||||
</div>
|
||||
</div>
|
||||
<div class="point">
|
||||
@@ -788,7 +844,7 @@ useSeoMeta({
|
||||
Windows
|
||||
</div>
|
||||
<div class="description">
|
||||
<a ref="downloadWindows" :href="windowsLink" download="">
|
||||
<a ref="downloadWindows" :href="windowsLink || undefined" download="">
|
||||
<DownloadIcon />
|
||||
<span> Download the beta </span>
|
||||
</a>
|
||||
@@ -812,7 +868,7 @@ useSeoMeta({
|
||||
Mac
|
||||
</div>
|
||||
<div class="description apple">
|
||||
<a :href="macLinks.universal" download="">
|
||||
<a :href="macLinks.universal || undefined" download="">
|
||||
<DownloadIcon />
|
||||
<span> Download the beta </span>
|
||||
</a>
|
||||
@@ -849,19 +905,19 @@ useSeoMeta({
|
||||
Linux
|
||||
</div>
|
||||
<div class="description apple">
|
||||
<a ref="downloadLinux" :href="linuxLinks.appImage" download="">
|
||||
<a ref="downloadLinux" :href="linuxLinks.appImage || undefined" download="">
|
||||
<DownloadIcon />
|
||||
<span> Download the AppImage </span>
|
||||
</a>
|
||||
<a :href="linuxLinks.deb" download="">
|
||||
<a :href="linuxLinks.deb || undefined" download="">
|
||||
<DownloadIcon />
|
||||
<span> Download the DEB </span>
|
||||
</a>
|
||||
<a :href="linuxLinks.rpm" download="">
|
||||
<a :href="linuxLinks.rpm || undefined" download="">
|
||||
<DownloadIcon />
|
||||
<span> Download the RPM </span>
|
||||
</a>
|
||||
<a :href="linuxLinks.thirdParty" download="">
|
||||
<a :href="linuxLinks.thirdParty || undefined" download="">
|
||||
<LinkIcon />
|
||||
<span> Third-party packages </span>
|
||||
</a>
|
||||
@@ -1365,7 +1421,8 @@ useSeoMeta({
|
||||
width: 25rem;
|
||||
height: 25rem;
|
||||
opacity: 0.75;
|
||||
background: radial-gradient(
|
||||
background:
|
||||
radial-gradient(
|
||||
50% 50% at 50% 50%,
|
||||
rgba(5, 206, 69, 0.19) 0%,
|
||||
rgba(15, 19, 49, 0.25) 100%
|
||||
|
||||
@@ -266,12 +266,12 @@ const getRangeOfMethod = (method) => {
|
||||
|
||||
const maxWithdrawAmount = computed(() => {
|
||||
const interval = selectedMethod.value.interval;
|
||||
return interval?.standard ? interval.standard.max : interval?.fixed?.values.slice(-1)[0] ?? 0;
|
||||
return interval?.standard ? interval.standard.max : (interval?.fixed?.values.slice(-1)[0] ?? 0);
|
||||
});
|
||||
|
||||
const minWithdrawAmount = computed(() => {
|
||||
const interval = selectedMethod.value.interval;
|
||||
return interval?.standard ? interval.standard.min : interval?.fixed?.values?.[0] ?? fees.value;
|
||||
return interval?.standard ? interval.standard.min : (interval?.fixed?.values?.[0] ?? fees.value);
|
||||
});
|
||||
|
||||
const withdrawAccount = computed(() => {
|
||||
|
||||
@@ -212,6 +212,10 @@ if (projects.value) {
|
||||
|
||||
async function goToProjects() {
|
||||
const project = projectsFiltered.value[0];
|
||||
const remainingProjectIds = projectsFiltered.value.slice(1).map((p) => p.id);
|
||||
|
||||
localStorage.setItem("moderation-future-projects", JSON.stringify(remainingProjectIds));
|
||||
|
||||
await router.push({
|
||||
name: "type-id",
|
||||
params: {
|
||||
@@ -220,7 +224,6 @@ async function goToProjects() {
|
||||
},
|
||||
state: {
|
||||
showChecklist: true,
|
||||
projects: projectsFiltered.value.slice(1).map((x) => (x.slug ? x.slug : x.id)),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import { Avatar, ButtonStyled } from "@modrinth/ui";
|
||||
import { RssIcon, GitGraphIcon } from "@modrinth/assets";
|
||||
import dayjs from "dayjs";
|
||||
import { articles as rawArticles } from "@modrinth/blog";
|
||||
import { computed } from "vue";
|
||||
import type { User } from "@modrinth/utils";
|
||||
import ShareArticleButtons from "~/components/ui/ShareArticleButtons.vue";
|
||||
import NewsletterButton from "~/components/ui/NewsletterButton.vue";
|
||||
|
||||
@@ -20,7 +21,21 @@ if (!rawArticle) {
|
||||
});
|
||||
}
|
||||
|
||||
const html = await rawArticle.html();
|
||||
const authorsUrl = `users?ids=${JSON.stringify(rawArticle.authors)}`;
|
||||
|
||||
const [authors, html] = await Promise.all([
|
||||
rawArticle.authors
|
||||
? useAsyncData(authorsUrl, () => useBaseFetch(authorsUrl)).then((data) => {
|
||||
const users = data.data as Ref<User[]>;
|
||||
users.value.sort((a, b) => {
|
||||
return rawArticle.authors.indexOf(a.id) - rawArticle.authors.indexOf(b.id);
|
||||
});
|
||||
|
||||
return users;
|
||||
})
|
||||
: Promise.resolve(),
|
||||
rawArticle.html(),
|
||||
]);
|
||||
|
||||
const article = computed(() => ({
|
||||
...rawArticle,
|
||||
@@ -34,6 +49,8 @@ const article = computed(() => ({
|
||||
html,
|
||||
}));
|
||||
|
||||
const authorCount = computed(() => authors?.value?.length ?? 0);
|
||||
|
||||
const articleTitle = computed(() => article.value.title);
|
||||
const articleUrl = computed(() => `https://modrinth.com/news/article/${route.params.slug}`);
|
||||
|
||||
@@ -83,9 +100,35 @@ useSeoMeta({
|
||||
<article class="mt-6 flex flex-col gap-4 px-6">
|
||||
<h2 class="m-0 text-2xl font-extrabold leading-tight sm:text-4xl">{{ article.title }}</h2>
|
||||
<p class="m-0 text-base leading-tight sm:text-lg">{{ article.summary }}</p>
|
||||
<div class="mt-auto text-sm text-secondary sm:text-base">
|
||||
Posted on {{ dayjsDate.format("MMMM D, YYYY") }}
|
||||
<div class="mt-auto flex flex-wrap items-center gap-1 text-sm text-secondary sm:text-base">
|
||||
<template v-for="(author, index) in authors" :key="`author-${author.id}`">
|
||||
<span v-if="authorCount - 1 === index && authorCount > 1">and</span>
|
||||
<span class="flex items-center">
|
||||
<nuxt-link
|
||||
:to="`/user/${author.id}`"
|
||||
class="inline-flex items-center gap-1 font-semibold hover:underline hover:brightness-[--hover-brightness]"
|
||||
>
|
||||
<Avatar :src="author.avatar_url" circle size="24px" />
|
||||
{{ author.username }}
|
||||
</nuxt-link>
|
||||
<span v-if="(authors?.length ?? 0) > 2 && index !== authorCount - 1">,</span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="!authors || authorCount === 0">
|
||||
<nuxt-link
|
||||
to="/organization/modrinth"
|
||||
class="inline-flex items-center gap-1 font-semibold hover:underline hover:brightness-[--hover-brightness]"
|
||||
>
|
||||
<Avatar src="https://cdn-raw.modrinth.com/modrinth-icon-96.webp" size="24px" />
|
||||
Modrinth Team
|
||||
</nuxt-link>
|
||||
</template>
|
||||
<span class="hidden md:block">•</span>
|
||||
<span class="hidden md:block"> {{ dayjsDate.format("MMMM D, YYYY") }}</span>
|
||||
</div>
|
||||
<span class="text-sm text-secondary sm:text-base md:hidden">
|
||||
Posted on {{ dayjsDate.format("MMMM D, YYYY") }}</span
|
||||
>
|
||||
<ShareArticleButtons :title="article.title" :url="articleUrl" />
|
||||
<img
|
||||
:src="article.thumbnail"
|
||||
|
||||
@@ -149,7 +149,8 @@ onMounted(() => {
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.main-hero {
|
||||
background: linear-gradient(360deg, rgba(199, 138, 255, 0.2) 10.92%, var(--color-bg) 100%),
|
||||
background:
|
||||
linear-gradient(360deg, rgba(199, 138, 255, 0.2) 10.92%, var(--color-bg) 100%),
|
||||
var(--color-accent-contrast);
|
||||
margin-top: -5rem;
|
||||
padding: 11.25rem 1rem 8rem;
|
||||
|
||||
@@ -45,8 +45,9 @@
|
||||
<h2
|
||||
class="relative m-0 max-w-2xl text-base font-normal leading-[155%] text-secondary md:text-[1.2rem]"
|
||||
>
|
||||
Modrinth Servers is the easiest way to host your own Minecraft server. Seamlessly install
|
||||
and play your favorite mods and modpacks, all within the Modrinth platform.
|
||||
Modrinth Servers is the easiest way to host your own Minecraft: Java Edition server.
|
||||
Seamlessly install and play your favorite mods and modpacks, all within the Modrinth
|
||||
platform.
|
||||
</h2>
|
||||
<div class="relative flex w-full flex-wrap items-center gap-8 align-middle sm:w-fit">
|
||||
<div
|
||||
@@ -427,11 +428,8 @@
|
||||
Do Modrinth Servers have DDoS protection?
|
||||
</summary>
|
||||
<p class="m-0 ml-6 leading-[160%]">
|
||||
Yes. All Modrinth Servers come with DDoS protection powered by
|
||||
<a href="https://us.ovhcloud.com/security/anti-ddos/" target="_blank"
|
||||
>OVHcloud® Anti-DDoS infrastructure</a
|
||||
>
|
||||
which has over 17Tbps capacity. Your server is safe on Modrinth.
|
||||
Yes. All Modrinth Servers come with DDoS protection, with up to 17Tbps capacity in
|
||||
some locations.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
@@ -443,8 +441,9 @@
|
||||
Where are Modrinth Servers located? Can I choose a region?
|
||||
</summary>
|
||||
<p class="m-0 ml-6 leading-[160%]">
|
||||
We have servers in both North America in Vint Hill, Virginia, and Europe in Limburg,
|
||||
Germany. More regions to come in the future!
|
||||
We have servers available in North America and Europe at the moment that you can
|
||||
choose upon purchase. More regions to come in the future! If you'd like to switch
|
||||
your region, please contact support.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
@@ -461,7 +460,7 @@
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<details pyro-hash="players" class="group" :open="$route.hash === '#players'">
|
||||
<details pyro-hash="performance" class="group" :open="$route.hash === '#performance'">
|
||||
<summary class="flex cursor-pointer items-center py-3 font-medium text-contrast">
|
||||
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
|
||||
<RightArrowIcon />
|
||||
@@ -482,7 +481,7 @@
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<details pyro-hash="players" class="group" :open="$route.hash === '#prices'">
|
||||
<details pyro-hash="prices" class="group" :open="$route.hash === '#prices'">
|
||||
<summary class="flex cursor-pointer items-center py-3 font-medium text-contrast">
|
||||
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
|
||||
<RightArrowIcon />
|
||||
@@ -493,6 +492,24 @@
|
||||
All prices are listed in United States Dollars (USD).
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<details pyro-hash="versions" class="group" :open="$route.hash === '#versions'">
|
||||
<summary class="flex cursor-pointer items-center py-3 font-medium text-contrast">
|
||||
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
|
||||
<RightArrowIcon />
|
||||
</span>
|
||||
What Minecraft versions and loaders can be used?
|
||||
</summary>
|
||||
<p class="m-0 ml-6 leading-[160%]">
|
||||
Modrinth Servers can run any version of Minecraft: Java Edition going all the way
|
||||
back to version 1.2.5, including snapshot versions.
|
||||
</p>
|
||||
<p class="m-0 ml-6 mt-3 leading-[160%]">
|
||||
We also support a wide range of mod and plugin loaders, including Fabric, Quilt,
|
||||
Forge, and NeoForge for mods, as well as Paper and Purpur for plugins. Availability
|
||||
depends on whether the mod or plugin loader supports the selected Minecraft version.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -719,31 +736,32 @@ async function fetchCapacityStatuses(customProduct = null) {
|
||||
product.metadata.ram < min.metadata.ram ? product : min,
|
||||
),
|
||||
];
|
||||
const capacityChecks = productsToCheck.map((product) =>
|
||||
useServersFetch("stock", {
|
||||
method: "POST",
|
||||
body: {
|
||||
cpu: product.metadata.cpu,
|
||||
memory_mb: product.metadata.ram,
|
||||
swap_mb: product.metadata.swap,
|
||||
storage_mb: product.metadata.storage,
|
||||
},
|
||||
bypassAuth: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const results = await Promise.all(capacityChecks);
|
||||
const capacityChecks = [];
|
||||
for (const product of productsToCheck) {
|
||||
capacityChecks.push(
|
||||
useServersFetch("stock", {
|
||||
method: "POST",
|
||||
body: {
|
||||
cpu: product.metadata.cpu,
|
||||
memory_mb: product.metadata.ram,
|
||||
swap_mb: product.metadata.swap,
|
||||
storage_mb: product.metadata.storage,
|
||||
},
|
||||
bypassAuth: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (customProduct?.metadata) {
|
||||
return {
|
||||
custom: results[0],
|
||||
custom: await capacityChecks[0],
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
small: results[0],
|
||||
medium: results[1],
|
||||
large: results[2],
|
||||
custom: results[3],
|
||||
small: await capacityChecks[0],
|
||||
medium: await capacityChecks[1],
|
||||
large: await capacityChecks[2],
|
||||
custom: await capacityChecks[3],
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -760,6 +778,11 @@ async function fetchCapacityStatuses(customProduct = null) {
|
||||
const { data: capacityStatuses, refresh: refreshCapacity } = await useAsyncData(
|
||||
"ServerCapacityAll",
|
||||
fetchCapacityStatuses,
|
||||
{
|
||||
getCachedData() {
|
||||
return null; // Dont cache stock data.
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const isSmallAtCapacity = computed(() => capacityStatuses.value?.small?.available === 0);
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="server.moduleErrors?.general?.error.statusCode === 503"
|
||||
v-else-if="server.moduleErrors?.general?.error || !nodeAccessible"
|
||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||
>
|
||||
<ErrorInformationCard
|
||||
@@ -68,22 +68,22 @@
|
||||
<template #description>
|
||||
<div class="text-md space-y-4">
|
||||
<p class="leading-[170%] text-secondary">
|
||||
Your server's node, where your Modrinth Server is physically hosted, is experiencing
|
||||
issues. We are working with our datacenter to resolve the issue as quickly as possible.
|
||||
Your server's node, where your Modrinth Server is physically hosted, is not accessible
|
||||
at the moment. We are working to resolve the issue as quickly as possible.
|
||||
</p>
|
||||
<p class="leading-[170%] text-secondary">
|
||||
Your data is safe and will not be lost, and your server will be back online as soon as
|
||||
the issue is resolved.
|
||||
</p>
|
||||
<p class="leading-[170%] text-secondary">
|
||||
For updates, please join the Modrinth Discord or contact Modrinth Support via the chat
|
||||
If reloading does not work initially, please contact Modrinth Support via the chat
|
||||
bubble in the bottom right corner and we'll be happy to help.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</ErrorInformationCard>
|
||||
</div>
|
||||
<div
|
||||
<!-- <div
|
||||
v-else-if="server.moduleErrors?.general?.error"
|
||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||
>
|
||||
@@ -96,19 +96,14 @@
|
||||
>
|
||||
<template #description>
|
||||
<div class="space-y-4">
|
||||
<div class="text-center text-secondary">
|
||||
{{
|
||||
formattedTime == "00" ? "Reconnecting..." : `Retrying in ${formattedTime} seconds...`
|
||||
}}
|
||||
</div>
|
||||
<p class="text-lg text-secondary">
|
||||
Something went wrong, and we couldn't connect to your server. This is likely due to a
|
||||
temporary network issue. You'll be reconnected automatically.
|
||||
temporary network issue.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</ErrorInformationCard>
|
||||
</div>
|
||||
</div> -->
|
||||
<!-- SERVER START -->
|
||||
<div
|
||||
v-else-if="serverData"
|
||||
@@ -355,7 +350,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch, type Reactive } from "vue";
|
||||
import { ref, computed, onMounted, onUnmounted, type Reactive } from "vue";
|
||||
import {
|
||||
SettingsIcon,
|
||||
CopyIcon,
|
||||
@@ -371,15 +366,15 @@ import DOMPurify from "dompurify";
|
||||
import { ButtonStyled, ErrorInformationCard, ServerNotice } from "@modrinth/ui";
|
||||
import { Intercom, shutdown } from "@intercom/messenger-js-sdk";
|
||||
import type { MessageDescriptor } from "@vintl/vintl";
|
||||
import type {
|
||||
ServerState,
|
||||
Stats,
|
||||
WSEvent,
|
||||
WSInstallationResultEvent,
|
||||
Backup,
|
||||
PowerAction,
|
||||
import {
|
||||
type ServerState,
|
||||
type Stats,
|
||||
type WSEvent,
|
||||
type WSInstallationResultEvent,
|
||||
type Backup,
|
||||
type PowerAction,
|
||||
} from "@modrinth/utils";
|
||||
import { reloadNuxtApp, navigateTo } from "#app";
|
||||
import { reloadNuxtApp } from "#app";
|
||||
import { useModrinthServersConsole } from "~/store/console.ts";
|
||||
import { useServersFetch } from "~/composables/servers/servers-fetch.ts";
|
||||
import { ModrinthServer, useModrinthServers } from "~/composables/servers/modrinth-servers.ts";
|
||||
@@ -392,7 +387,6 @@ const socket = ref<WebSocket | null>(null);
|
||||
const isReconnecting = ref(false);
|
||||
const isLoading = ref(true);
|
||||
const reconnectInterval = ref<ReturnType<typeof setInterval> | null>(null);
|
||||
const isFirstMount = ref(true);
|
||||
const isMounted = ref(true);
|
||||
const flags = useFeatureFlags();
|
||||
|
||||
@@ -422,18 +416,6 @@ const loadModulesPromise = Promise.resolve().then(() => {
|
||||
|
||||
provide("modulesLoaded", loadModulesPromise);
|
||||
|
||||
watch(
|
||||
() => [server.moduleErrors?.general, server.moduleErrors?.ws],
|
||||
([generalError, wsError]) => {
|
||||
if (server.general?.status === "suspended") return;
|
||||
|
||||
const error = generalError?.error || wsError?.error;
|
||||
if (error && error.statusCode !== 403) {
|
||||
startPolling();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const errorTitle = ref("Error");
|
||||
const errorMessage = ref("An unexpected error occurred.");
|
||||
const errorLog = ref("");
|
||||
@@ -697,7 +679,6 @@ const startUptimeUpdates = () => {
|
||||
const stopUptimeUpdates = () => {
|
||||
if (uptimeIntervalId) {
|
||||
clearInterval(uptimeIntervalId);
|
||||
pollingIntervalId = null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -836,8 +817,6 @@ const handleInstallationResult = async (data: WSInstallationResultEvent) => {
|
||||
case "ok": {
|
||||
if (!serverData.value) break;
|
||||
|
||||
stopPolling();
|
||||
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
@@ -992,14 +971,6 @@ const notifyError = (title: string, text: string) => {
|
||||
});
|
||||
};
|
||||
|
||||
let pollingIntervalId: ReturnType<typeof setInterval> | null = null;
|
||||
const countdown = ref(15);
|
||||
|
||||
const formattedTime = computed(() => {
|
||||
const seconds = countdown.value % 60;
|
||||
return `${seconds.toString().padStart(2, "0")}`;
|
||||
});
|
||||
|
||||
export type BackupInProgressReason = {
|
||||
type: string;
|
||||
tooltip: MessageDescriptor;
|
||||
@@ -1035,54 +1006,6 @@ const backupInProgress = computed(() => {
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollingIntervalId) {
|
||||
clearTimeout(pollingIntervalId);
|
||||
pollingIntervalId = null;
|
||||
}
|
||||
};
|
||||
|
||||
const startPolling = () => {
|
||||
stopPolling();
|
||||
|
||||
let retryCount = 0;
|
||||
const maxRetries = 10;
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
await server.refresh(["general", "ws"]);
|
||||
|
||||
if (!server.moduleErrors?.general?.error) {
|
||||
stopPolling();
|
||||
connectWebSocket();
|
||||
return;
|
||||
}
|
||||
|
||||
retryCount++;
|
||||
if (retryCount >= maxRetries) {
|
||||
console.error("Max retries reached, stopping polling");
|
||||
stopPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
// Exponential backoff: 3s, 6s, 12s, 24s, etc.
|
||||
const delay = Math.min(3000 * Math.pow(2, retryCount - 1), 60000);
|
||||
|
||||
pollingIntervalId = setTimeout(poll, delay);
|
||||
} catch (error) {
|
||||
console.error("Polling failed:", error);
|
||||
retryCount++;
|
||||
|
||||
if (retryCount < maxRetries) {
|
||||
const delay = Math.min(3000 * Math.pow(2, retryCount - 1), 60000);
|
||||
pollingIntervalId = setTimeout(poll, delay);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
};
|
||||
|
||||
const nodeUnavailableDetails = computed(() => [
|
||||
{
|
||||
label: "Server ID",
|
||||
@@ -1091,9 +1014,16 @@ const nodeUnavailableDetails = computed(() => [
|
||||
},
|
||||
{
|
||||
label: "Node",
|
||||
value: server.general?.datacenter ?? "Unknown! Please contact support!",
|
||||
value: server.general?.datacenter ?? "Unknown",
|
||||
type: "inline" as const,
|
||||
},
|
||||
{
|
||||
label: "Error message",
|
||||
value: nodeAccessible.value
|
||||
? (server.moduleErrors?.general?.error.message ?? "Unknown")
|
||||
: "Unable to reach node. Ping test failed.",
|
||||
type: "block" as const,
|
||||
},
|
||||
]);
|
||||
|
||||
const suspendedDescription = computed(() => {
|
||||
@@ -1160,16 +1090,10 @@ const generalErrorAction = computed(() => ({
|
||||
}));
|
||||
|
||||
const nodeUnavailableAction = computed(() => ({
|
||||
label: "Join Modrinth Discord",
|
||||
onClick: () => navigateTo("https://discord.modrinth.com", { external: true }),
|
||||
color: "standard" as const,
|
||||
}));
|
||||
|
||||
const connectionLostAction = computed(() => ({
|
||||
label: "Reload",
|
||||
onClick: () => reloadNuxtApp(),
|
||||
color: "brand" as const,
|
||||
disabled: formattedTime.value !== "00",
|
||||
disabled: false,
|
||||
}));
|
||||
|
||||
const copyServerDebugInfo = () => {
|
||||
@@ -1193,7 +1117,6 @@ const cleanup = () => {
|
||||
|
||||
shutdown();
|
||||
|
||||
stopPolling();
|
||||
stopUptimeUpdates();
|
||||
if (reconnectInterval.value) {
|
||||
clearInterval(reconnectInterval.value);
|
||||
@@ -1236,16 +1159,31 @@ async function dismissNotice(noticeId: number) {
|
||||
await server.refresh(["general"]);
|
||||
}
|
||||
|
||||
const nodeAccessible = ref(true);
|
||||
|
||||
onMounted(() => {
|
||||
isMounted.value = true;
|
||||
if (server.general?.status === "suspended") {
|
||||
isLoading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
server
|
||||
.testNodeReachability()
|
||||
.then((result) => {
|
||||
nodeAccessible.value = result;
|
||||
if (!nodeAccessible.value) {
|
||||
isLoading.value = false;
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Error testing node reachability:", err);
|
||||
nodeAccessible.value = false;
|
||||
isLoading.value = false;
|
||||
});
|
||||
|
||||
if (server.moduleErrors.general?.error) {
|
||||
if (!server.moduleErrors.general?.error?.message?.includes("Forbidden")) {
|
||||
startPolling();
|
||||
}
|
||||
isLoading.value = false;
|
||||
} else {
|
||||
connectWebSocket();
|
||||
}
|
||||
@@ -1297,21 +1235,6 @@ onUnmounted(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => serverData.value?.status,
|
||||
(newStatus, oldStatus) => {
|
||||
if (isFirstMount.value) {
|
||||
isFirstMount.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (newStatus === "installing" && oldStatus !== "installing") {
|
||||
countdown.value = 15;
|
||||
startPolling();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
definePageMeta({
|
||||
middleware: "auth",
|
||||
});
|
||||
@@ -1354,7 +1277,8 @@ useHead({
|
||||
background-repeat: no-repeat;
|
||||
filter: blur(1rem);
|
||||
content: "";
|
||||
background-image: linear-gradient(
|
||||
background-image:
|
||||
linear-gradient(
|
||||
to bottom,
|
||||
rgba(from var(--color-raised-bg) r g b / 0.2),
|
||||
rgb(from var(--color-raised-bg) r g b / 0.8)
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
<span :class="{ invisible: 'current_file' in op && !op.current_file }">
|
||||
{{
|
||||
"current_file" in op
|
||||
? op.current_file?.split("/")?.pop() ?? "unknown"
|
||||
? (op.current_file?.split("/")?.pop() ?? "unknown")
|
||||
: "unknown"
|
||||
}}
|
||||
</span>
|
||||
|
||||
6
apps/frontend/src/public/.well-known/security.txt
Normal file
6
apps/frontend/src/public/.well-known/security.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
Contact: mailto:jai@modrinth.com
|
||||
Expires: 2025-12-31T00:00:00.000Z
|
||||
Preferred-Languages: en
|
||||
Canonical: https://modrinth.com/.well-known/security.txt
|
||||
Policy: https://modrinth.com/legal/security
|
||||
Hiring: https://careers.modrinth.com/
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 246 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
@@ -1,5 +1,12 @@
|
||||
{
|
||||
"articles": [
|
||||
{
|
||||
"title": "Skins — Now in Modrinth App!",
|
||||
"summary": "Customize your look, save your favorite skins, and swap them out in a flash, all within Modrinth App.",
|
||||
"thumbnail": "https://modrinth.com/news/article/skins-now-in-modrinth-app/thumbnail.webp",
|
||||
"date": "2025-07-06T23:45:00.000Z",
|
||||
"link": "https://modrinth.com/news/article/skins-now-in-modrinth-app"
|
||||
},
|
||||
{
|
||||
"title": "Creator Updates, July 2025",
|
||||
"summary": "Addressing recent growth and growing pains that have been affecting creators.",
|
||||
@@ -9,7 +16,7 @@
|
||||
},
|
||||
{
|
||||
"title": "A Pride Month Success: Over $8,400 Raised for The Trevor Project!",
|
||||
"summary": "A reflection on our Pride Month fundraiser campaign, which raised thousands for LGBTQ+ youth.",
|
||||
"summary": "Reflecting on our Pride Month fundraiser campaign for LGBTQ+ youth.",
|
||||
"thumbnail": "https://modrinth.com/news/article/pride-campaign-2025/thumbnail.webp",
|
||||
"date": "2025-07-01T18:00:00.000Z",
|
||||
"link": "https://modrinth.com/news/article/pride-campaign-2025"
|
||||
@@ -91,13 +98,6 @@
|
||||
"date": "2023-02-01T20:00:00.000Z",
|
||||
"link": "https://modrinth.com/news/article/accelerating-development"
|
||||
},
|
||||
{
|
||||
"title": "Two years of Modrinth: a retrospective",
|
||||
"summary": "The history of Modrinth as we know it from December 2020 to December 2022.",
|
||||
"thumbnail": "https://modrinth.com/news/default.webp",
|
||||
"date": "2023-01-07T00:00:00.000Z",
|
||||
"link": "https://modrinth.com/news/article/two-years-of-modrinth-history"
|
||||
},
|
||||
{
|
||||
"title": "Modrinth's Anniversary Update",
|
||||
"summary": "Marking two years of Modrinth and discussing our New Year's Resolutions for 2023.",
|
||||
@@ -105,16 +105,23 @@
|
||||
"date": "2023-01-07T00:00:00.000Z",
|
||||
"link": "https://modrinth.com/news/article/two-years-of-modrinth"
|
||||
},
|
||||
{
|
||||
"title": "Two years of Modrinth: a retrospective",
|
||||
"summary": "The history of Modrinth as we know it from December 2020 to December 2022.",
|
||||
"thumbnail": "https://modrinth.com/news/default.webp",
|
||||
"date": "2023-01-07T00:00:00.000Z",
|
||||
"link": "https://modrinth.com/news/article/two-years-of-modrinth-history"
|
||||
},
|
||||
{
|
||||
"title": "Creators can now make money on Modrinth!",
|
||||
"summary": "Yes, you read the title correctly: Modrinth's creator monetization program, also known as payouts, is now in an open beta phase. Read on for more information!",
|
||||
"summary": "Introducing the Creator Monetization Program allowing creators to earn revenue from their projects.",
|
||||
"thumbnail": "https://modrinth.com/news/article/creator-monetization/thumbnail.webp",
|
||||
"date": "2022-11-12T00:00:00.000Z",
|
||||
"link": "https://modrinth.com/news/article/creator-monetization"
|
||||
},
|
||||
{
|
||||
"title": "Modrinth's Carbon Ads experiment",
|
||||
"summary": "As a step towards implementing author payouts, we're experimenting with a couple different ad providers to see which one works the best for us.",
|
||||
"summary": "Experimenting with a different ad providers to find one which one works for us.",
|
||||
"thumbnail": "https://modrinth.com/news/article/carbon-ads/thumbnail.webp",
|
||||
"date": "2022-09-08T00:00:00.000Z",
|
||||
"link": "https://modrinth.com/news/article/carbon-ads"
|
||||
@@ -142,14 +149,14 @@
|
||||
},
|
||||
{
|
||||
"title": "This week in Modrinth development: Filters and Fixes",
|
||||
"summary": "After a great first week since Modrinth launched out of beta, we have continued to improve the user interface based on feedback.",
|
||||
"summary": "Continuing to improve the user interface after a great first week since Modrinth launched out of beta.",
|
||||
"thumbnail": "https://modrinth.com/news/article/knossos-v2.1.0/thumbnail.webp",
|
||||
"date": "2022-03-09T00:00:00.000Z",
|
||||
"link": "https://modrinth.com/news/article/knossos-v2.1.0"
|
||||
},
|
||||
{
|
||||
"title": "Now showing on Modrinth: A new look!",
|
||||
"summary": "After months of relatively quiet development, Modrinth has released many new features and improvements, including a redesign. Read on to learn more!",
|
||||
"summary": "Releasing many new features and improvements, including a redesign!",
|
||||
"thumbnail": "https://modrinth.com/news/article/redesign/thumbnail.webp",
|
||||
"date": "2022-02-27T00:00:00.000Z",
|
||||
"link": "https://modrinth.com/news/article/redesign"
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n WHERE subscription_id = $1 AND (status = 'open' OR status = 'cancelled')",
|
||||
"query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n WHERE subscription_id = $1 AND (status = 'open' OR status = 'cancelled' OR status = 'failed')",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -102,5 +102,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "99cca53fd3f35325e2da3b671532bf98b8c7ad8e7cb9158e4eb9c5bac66d20b2"
|
||||
"hash": "cf020daa52a1316e5f60d197196b880b72c0b2a576e470d9fd7182558103d055"
|
||||
}
|
||||
@@ -36,7 +36,7 @@ paste.workspace = true
|
||||
meilisearch-sdk = { workspace = true, features = ["reqwest"] }
|
||||
rust-s3.workspace = true
|
||||
reqwest = { workspace = true, features = ["http2", "rustls-tls-webpki-roots", "json", "multipart"] }
|
||||
hyper-tls.workspace = true
|
||||
hyper-rustls.workspace = true
|
||||
hyper-util.workspace = true
|
||||
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
FROM rust:1.88.0 AS build
|
||||
ENV PKG_CONFIG_ALLOW_CROSS=1
|
||||
|
||||
WORKDIR /usr/src/labrinth
|
||||
COPY . .
|
||||
COPY apps/labrinth/.sqlx/ .sqlx/
|
||||
RUN cargo build --release --package labrinth
|
||||
|
||||
RUN SQLX_OFFLINE=true cargo build --release --package labrinth
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
@@ -14,12 +11,9 @@ LABEL org.opencontainers.image.description="Modrinth API"
|
||||
LABEL org.opencontainers.image.licenses=AGPL-3.0
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates openssl dumb-init \
|
||||
&& apt-get clean \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates dumb-init curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN update-ca-certificates
|
||||
|
||||
COPY --from=build /usr/src/labrinth/target/release/labrinth /labrinth/labrinth
|
||||
COPY --from=build /usr/src/labrinth/apps/labrinth/migrations/* /labrinth/migrations/
|
||||
COPY --from=build /usr/src/labrinth/apps/labrinth/assets /labrinth/assets
|
||||
|
||||
@@ -43,7 +43,9 @@ pub enum AuthenticationError {
|
||||
InvalidAuthMethod,
|
||||
#[error("GitHub Token from incorrect Client ID")]
|
||||
InvalidClientId,
|
||||
#[error("User email/account is already registered on Modrinth")]
|
||||
#[error(
|
||||
"User email is already registered on Modrinth. Try 'Forgot password' to access your account."
|
||||
)]
|
||||
DuplicateUser,
|
||||
#[error("Invalid state sent, you probably need to get a new websocket")]
|
||||
SocketError,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use hyper_tls::{HttpsConnector, native_tls};
|
||||
use hyper_util::client::legacy::connect::HttpConnector;
|
||||
use hyper_rustls::HttpsConnectorBuilder;
|
||||
use hyper_util::rt::TokioExecutor;
|
||||
|
||||
mod fetch;
|
||||
@@ -15,13 +14,11 @@ pub async fn init_client_with_database(
|
||||
database: &str,
|
||||
) -> clickhouse::error::Result<clickhouse::Client> {
|
||||
let client = {
|
||||
let mut http_connector = HttpConnector::new();
|
||||
http_connector.enforce_http(false); // allow https URLs
|
||||
|
||||
let tls_connector =
|
||||
native_tls::TlsConnector::builder().build().unwrap().into();
|
||||
let https_connector =
|
||||
HttpsConnector::from((http_connector, tls_connector));
|
||||
let https_connector = HttpsConnectorBuilder::new()
|
||||
.with_native_roots()?
|
||||
.https_or_http()
|
||||
.enable_all_versions()
|
||||
.build();
|
||||
let hyper_client =
|
||||
hyper_util::client::legacy::Client::builder(TokioExecutor::new())
|
||||
.build(https_connector);
|
||||
|
||||
@@ -197,7 +197,7 @@ impl DBCharge {
|
||||
) -> Result<Option<DBCharge>, DatabaseError> {
|
||||
let user_subscription_id = user_subscription_id.0;
|
||||
let res = select_charges_with_predicate!(
|
||||
"WHERE subscription_id = $1 AND (status = 'open' OR status = 'cancelled')",
|
||||
"WHERE subscription_id = $1 AND (status = 'open' OR status = 'cancelled' OR status = 'failed')",
|
||||
user_subscription_id
|
||||
)
|
||||
.fetch_optional(exec)
|
||||
|
||||
@@ -223,8 +223,8 @@ impl TempUser {
|
||||
stripe_customer_id: None,
|
||||
totp_secret: None,
|
||||
username,
|
||||
email: self.email,
|
||||
email_verified: true,
|
||||
email: self.email.clone(),
|
||||
email_verified: self.email.is_some(),
|
||||
avatar_url,
|
||||
raw_avatar_url,
|
||||
bio: self.bio,
|
||||
@@ -1419,15 +1419,15 @@ pub async fn create_account_with_password(
|
||||
.hash_password(new_account.password.as_bytes(), &salt)?
|
||||
.to_string();
|
||||
|
||||
if crate::database::models::DBUser::get_by_email(
|
||||
if !crate::database::models::DBUser::get_by_case_insensitive_email(
|
||||
&new_account.email,
|
||||
&**pool,
|
||||
)
|
||||
.await?
|
||||
.is_some()
|
||||
.is_empty()
|
||||
{
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Email is already registered on Modrinth!".to_string(),
|
||||
"Email is already registered on Modrinth! Try 'Forgot password' to access your account.".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -2220,6 +2220,18 @@ pub async fn set_email(
|
||||
.await?
|
||||
.1;
|
||||
|
||||
if !crate::database::models::DBUser::get_by_case_insensitive_email(
|
||||
&email.email,
|
||||
&**pool,
|
||||
)
|
||||
.await?
|
||||
.is_empty()
|
||||
{
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Email is already registered on Modrinth! Try 'Forgot password' in incognito to access and delete your other account.".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
sqlx::query!(
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"app:build": "turbo run build --filter=@modrinth/app",
|
||||
"app:fix": "turbo run fix --filter=@modrinth/app",
|
||||
"app:intl:extract": "pnpm run --filter=@modrinth/app-frontend intl:extract",
|
||||
"blog:fix": "turbo run fix --filter=@modrinth/blog",
|
||||
"pages:build": "NITRO_PRESET=cloudflare-pages pnpm --filter frontend run build",
|
||||
"moderation:fix": "turbo run fix --filter=@modrinth/moderation",
|
||||
"build": "turbo run build --continue",
|
||||
"lint": "turbo run lint --continue",
|
||||
"test": "turbo run test --continue",
|
||||
|
||||
@@ -1,2 +1,10 @@
|
||||
# SQLite database file location
|
||||
DATABASE_URL=sqlite:///tmp/Modrinth/code/packages/app-lib/.sqlx/generated/state.db
|
||||
MODRINTH_URL=http://localhost:3000/
|
||||
MODRINTH_API_URL=http://127.0.0.1:8000/v2/
|
||||
MODRINTH_API_URL_V3=http://127.0.0.1:8000/v3/
|
||||
MODRINTH_SOCKET_URL=ws://127.0.0.1:8000/
|
||||
MODRINTH_LAUNCHER_META_URL=https://launcher-meta.modrinth.com/
|
||||
|
||||
# SQLite database file used by sqlx for type checking. Uncomment this to a valid path
|
||||
# in your system and run `cargo sqlx database setup` to generate an empty database that
|
||||
# can be used for developing the app DB schema
|
||||
#DATABASE_URL=sqlite:///tmp/Modrinth/code/packages/app-lib/.sqlx/generated/state.db
|
||||
|
||||
10
packages/app-lib/.env.prod
Normal file
10
packages/app-lib/.env.prod
Normal file
@@ -0,0 +1,10 @@
|
||||
MODRINTH_URL=https://modrinth.com/
|
||||
MODRINTH_API_URL=https://api.modrinth.com/v2/
|
||||
MODRINTH_API_URL_V3=https://api.modrinth.com/v3/
|
||||
MODRINTH_SOCKET_URL=wss://api.modrinth.com/
|
||||
MODRINTH_LAUNCHER_META_URL=https://launcher-meta.modrinth.com/
|
||||
|
||||
# SQLite database file used by sqlx for type checking. Uncomment this to a valid path
|
||||
# in your system and run `cargo sqlx database setup` to generate an empty database that
|
||||
# can be used for developing the app DB schema
|
||||
#DATABASE_URL=sqlite:///tmp/Modrinth/code/packages/app-lib/.sqlx/generated/state.db
|
||||
10
packages/app-lib/.env.staging
Normal file
10
packages/app-lib/.env.staging
Normal file
@@ -0,0 +1,10 @@
|
||||
MODRINTH_URL=https://staging.modrinth.com/
|
||||
MODRINTH_API_URL=https://staging-api.modrinth.com/v2/
|
||||
MODRINTH_API_URL_V3=https://staging-api.modrinth.com/v3/
|
||||
MODRINTH_SOCKET_URL=wss://staging-api.modrinth.com/
|
||||
MODRINTH_LAUNCHER_META_URL=https://launcher-meta.modrinth.com/
|
||||
|
||||
# SQLite database file used by sqlx for type checking. Uncomment this to a valid path
|
||||
# in your system and run `cargo sqlx database setup` to generate an empty database that
|
||||
# can be used for developing the app DB schema
|
||||
#DATABASE_URL=sqlite:///tmp/Modrinth/code/packages/app-lib/.sqlx/generated/state.db
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n theme, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,\n discord_rpc, developer_mode, telemetry, personalized_ads,\n onboarded,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar\n FROM settings\n ",
|
||||
"query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n theme, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,\n discord_rpc, developer_mode, telemetry, personalized_ads,\n onboarded,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar,\n skipped_update, pending_update_toast_for_version\n FROM settings\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -142,6 +142,16 @@
|
||||
"name": "toggle_sidebar",
|
||||
"ordinal": 27,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "skipped_update",
|
||||
"ordinal": 28,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "pending_update_toast_for_version",
|
||||
"ordinal": 29,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -175,8 +185,10 @@
|
||||
true,
|
||||
false,
|
||||
null,
|
||||
false
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "5193f519f021b2e7013cdb67a6e1a31ae4bd7532d02f8b00b43d5645351941ca"
|
||||
"hash": "30fecc13c6ea4da1e99e35d307aa8e7c4e7f15ea99527e7974619ef8ed946abe"
|
||||
}
|
||||
@@ -41,7 +41,7 @@
|
||||
{
|
||||
"name": "display_claims!: serde_json::Value",
|
||||
"ordinal": 7,
|
||||
"type_info": "Null"
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n UPDATE settings\n SET\n max_concurrent_writes = $1,\n max_concurrent_downloads = $2,\n\n theme = $3,\n default_page = $4,\n collapsed_navigation = $5,\n advanced_rendering = $6,\n native_decorations = $7,\n\n discord_rpc = $8,\n developer_mode = $9,\n telemetry = $10,\n personalized_ads = $11,\n\n onboarded = $12,\n\n extra_launch_args = jsonb($13),\n custom_env_vars = jsonb($14),\n mc_memory_max = $15,\n mc_force_fullscreen = $16,\n mc_game_resolution_x = $17,\n mc_game_resolution_y = $18,\n hide_on_process_start = $19,\n\n hook_pre_launch = $20,\n hook_wrapper = $21,\n hook_post_exit = $22,\n\n custom_dir = $23,\n prev_custom_dir = $24,\n migrated = $25,\n\n toggle_sidebar = $26,\n feature_flags = $27,\n hide_nametag_skins_page = $28\n ",
|
||||
"query": "\n UPDATE settings\n SET\n max_concurrent_writes = $1,\n max_concurrent_downloads = $2,\n\n theme = $3,\n default_page = $4,\n collapsed_navigation = $5,\n advanced_rendering = $6,\n native_decorations = $7,\n\n discord_rpc = $8,\n developer_mode = $9,\n telemetry = $10,\n personalized_ads = $11,\n\n onboarded = $12,\n\n extra_launch_args = jsonb($13),\n custom_env_vars = jsonb($14),\n mc_memory_max = $15,\n mc_force_fullscreen = $16,\n mc_game_resolution_x = $17,\n mc_game_resolution_y = $18,\n hide_on_process_start = $19,\n\n hook_pre_launch = $20,\n hook_wrapper = $21,\n hook_post_exit = $22,\n\n custom_dir = $23,\n prev_custom_dir = $24,\n migrated = $25,\n\n toggle_sidebar = $26,\n feature_flags = $27,\n hide_nametag_skins_page = $28,\n\n skipped_update = $29,\n pending_update_toast_for_version = $30\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 28
|
||||
"Right": 30
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3613473fb4d836ee0fb3c292e6bf5e50912064c29ebf1a1e5ead79c44c37e64c"
|
||||
"hash": "cd7c9f394e4f0ec8fd0043352f7382ac2570aaa096cd2ebb4de990f4d42cc5c9"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "theseus"
|
||||
version = "0.10.1"
|
||||
version = "1.0.0-local" # The actual version is set by the theseus-build workflow on tagging
|
||||
authors = ["Jai A <jaiagr+gpg@pm.me>"]
|
||||
edition.workspace = true
|
||||
|
||||
@@ -82,6 +82,7 @@ ariadne.workspace = true
|
||||
winreg.workspace = true
|
||||
|
||||
[build-dependencies]
|
||||
dotenvy.workspace = true
|
||||
dunce.workspace = true
|
||||
|
||||
[features]
|
||||
|
||||
@@ -4,12 +4,31 @@ use std::process::{Command, exit};
|
||||
use std::{env, fs};
|
||||
|
||||
fn main() {
|
||||
println!("cargo::rerun-if-changed=.env");
|
||||
println!("cargo::rerun-if-changed=java/gradle");
|
||||
println!("cargo::rerun-if-changed=java/src");
|
||||
println!("cargo::rerun-if-changed=java/build.gradle.kts");
|
||||
println!("cargo::rerun-if-changed=java/settings.gradle.kts");
|
||||
println!("cargo::rerun-if-changed=java/gradle.properties");
|
||||
|
||||
set_env();
|
||||
build_java_jars();
|
||||
}
|
||||
|
||||
fn set_env() {
|
||||
for (var_name, var_value) in
|
||||
dotenvy::dotenv_iter().into_iter().flatten().flatten()
|
||||
{
|
||||
if var_name == "DATABASE_URL" {
|
||||
// The sqlx database URL is a build-time detail that should not be exposed to the crate
|
||||
continue;
|
||||
}
|
||||
|
||||
println!("cargo::rustc-env={var_name}={var_value}");
|
||||
}
|
||||
}
|
||||
|
||||
fn build_java_jars() {
|
||||
let out_dir =
|
||||
dunce::canonicalize(PathBuf::from(env::var_os("OUT_DIR").unwrap()))
|
||||
.unwrap();
|
||||
@@ -37,6 +56,7 @@ fn main() {
|
||||
.current_dir(dunce::canonicalize("java").unwrap())
|
||||
.status()
|
||||
.expect("Failed to wait on Gradle build");
|
||||
|
||||
if !exit_status.success() {
|
||||
println!("cargo::error=Gradle build failed with {exit_status}");
|
||||
exit(exit_status.code().unwrap_or(1));
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.modrinth.theseus;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.AccessibleObject;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.util.Arrays;
|
||||
@@ -76,14 +77,14 @@ public final class MinecraftLaunch {
|
||||
|
||||
Object thisObject = null;
|
||||
if (!Modifier.isStatic(mainMethod.getModifiers())) {
|
||||
thisObject = mainClass.getDeclaredConstructor().newInstance();
|
||||
thisObject = forceAccessible(mainClass.getDeclaredConstructor()).newInstance();
|
||||
}
|
||||
|
||||
final Object[] parameters = mainMethod.getParameterCount() > 0 ? new Object[] {args} : new Object[] {};
|
||||
|
||||
mainMethod.invoke(thisObject, parameters);
|
||||
} else {
|
||||
findSimpleMainMethod(mainClass).invoke(null, new Object[] {args});
|
||||
forceAccessible(findSimpleMainMethod(mainClass)).invoke(null, new Object[] {args});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,4 +116,15 @@ public final class MinecraftLaunch {
|
||||
private static Method findSimpleMainMethod(Class<?> mainClass) throws NoSuchMethodException {
|
||||
return mainClass.getMethod("main", String[].class);
|
||||
}
|
||||
|
||||
private static <T extends AccessibleObject> T forceAccessible(T object) throws ReflectiveOperationException {
|
||||
try {
|
||||
final Method setAccessible0 = AccessibleObject.class.getDeclaredMethod("setAccessible0", boolean.class);
|
||||
setAccessible0.setAccessible(true);
|
||||
setAccessible0.invoke(object, true);
|
||||
} catch (NoSuchMethodException e) {
|
||||
object.setAccessible(true);
|
||||
}
|
||||
return object;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE settings
|
||||
ADD COLUMN skipped_update TEXT NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE settings
|
||||
ADD COLUMN pending_update_toast_for_version TEXT NULL;
|
||||
@@ -166,10 +166,18 @@ pub async fn test_jre(
|
||||
path: PathBuf,
|
||||
major_version: u32,
|
||||
) -> crate::Result<bool> {
|
||||
let Ok(jre) = jre::check_java_at_filepath(&path).await else {
|
||||
return Ok(false);
|
||||
let jre = match jre::check_java_at_filepath(&path).await {
|
||||
Ok(jre) => jre,
|
||||
Err(e) => {
|
||||
tracing::warn!("Invalid Java at {}: {e}", path.display());
|
||||
return Ok(false);
|
||||
}
|
||||
};
|
||||
let version = extract_java_version(&jre.version)?;
|
||||
tracing::info!(
|
||||
"Expected Java version {major_version}, and found {version} at {}",
|
||||
path.display()
|
||||
);
|
||||
Ok(version == major_version)
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user