Merge branch 'main' of https://github.com/ApTx2012/Starlight_Lancher
# Conflicts: # .github/workflows/build-on-tag.yml # .github/workflows/sync-cnb-source.yml
This commit is contained in:
commit
6ce39f3a42
43 files changed
+1476
-715
No files matched your search
@@ -1,7 +1,4 @@
|
||||
name: Starlight build & release
|
||||
|
||||
# 打 tag(v*)时自动编译并发布 GitHub Release,产物包含各平台安装包。
|
||||
# 不含更新清单 / Update Server 通知 / CNB 镜像等步骤(自动更新流程单独处理)。
|
||||
name: Starlight release
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -12,14 +9,12 @@ permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: starlight-build-${{ github.ref }}
|
||||
group: axolotl-release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# 先生成该版本的更新说明(来自 announcement catalog)并创建带说明的 draft
|
||||
# release;随后各平台 build 只负责上传产物,最后由 publish 转正。
|
||||
preflight:
|
||||
name: Prepare release
|
||||
name: Release preflight
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
SQLX_OFFLINE: true
|
||||
@@ -39,17 +34,40 @@ jobs:
|
||||
node-version-file: .nvmrc
|
||||
cache: pnpm
|
||||
|
||||
- name: Set up Rust
|
||||
uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Generate release notes
|
||||
run: node scripts/axolotl/create-release-notes.mjs "${GITHUB_REF_NAME}" "${RUNNER_TEMP}/release-notes.md"
|
||||
- name: Install Blockbench skin editor dependencies
|
||||
run: npm ci
|
||||
working-directory: third-party/blockbench
|
||||
|
||||
- name: Create draft release with notes
|
||||
- name: Run release guardrails
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
release_notes="${RUNNER_TEMP}/release-notes.md"
|
||||
node scripts/axolotl/check-migrations.mjs --release
|
||||
node scripts/axolotl/brand-guard.mjs
|
||||
node scripts/axolotl/i18n-check.mjs
|
||||
cargo fmt --all --check
|
||||
|
||||
- name: Check updater signing configuration
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
run: |
|
||||
if [[ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]]; then
|
||||
echo '::error::Configure TAURI_SIGNING_PRIVATE_KEY with the private key matching apps/app/tauri-release.conf.json before publishing updates.'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Prepare draft release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
release_notes="${RUNNER_TEMP}/axolotl-release-notes.md"
|
||||
node scripts/axolotl/create-release-notes.mjs "${GITHUB_REF_NAME}" "$release_notes"
|
||||
existing_release="$(
|
||||
gh release list --limit 100 --json tagName \
|
||||
--jq ".[] | select(.tagName == \"${GITHUB_REF_NAME}\") | .tagName"
|
||||
@@ -74,7 +92,7 @@ jobs:
|
||||
name: Build (${{ matrix.name }})
|
||||
needs: preflight
|
||||
strategy:
|
||||
fail-fast: false
|
||||
fail-fast: true
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
include:
|
||||
@@ -153,7 +171,7 @@ jobs:
|
||||
- name: Set release version
|
||||
run: node scripts/axolotl/set-version.mjs "${{ github.ref_name }}"
|
||||
|
||||
- name: Build and publish release assets
|
||||
- name: Build and upload release assets
|
||||
uses: tauri-apps/tauri-action@1ddc7b49b13c3038297360482fcb3e058764d7c9 # v0.6.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -166,6 +184,7 @@ jobs:
|
||||
tauriScript: pnpm tauri
|
||||
args: ${{ matrix.args }}
|
||||
tagName: ${{ github.ref_name }}
|
||||
releaseName: Starlight Launcher ${{ github.ref_name }}
|
||||
releaseDraft: true
|
||||
prerelease: ${{ contains(github.ref_name, '-') }}
|
||||
generateReleaseNotes: false
|
||||
@@ -200,7 +219,7 @@ jobs:
|
||||
throw "Expected one modern installer, found $($modernInstallers.Count)"
|
||||
}
|
||||
|
||||
$modernName = "Starlight_Launcher_${version}_x64_modern-setup.exe"
|
||||
$modernName = "Axolotl_Launcher_${version}_x64_modern-setup.exe"
|
||||
$modernPath = Join-Path $publishDir $modernName
|
||||
Copy-Item -Force $modernInstallers[0].FullName $modernPath
|
||||
gh release upload "${{ github.ref_name }}" $modernPath --clobber
|
||||
@@ -221,7 +240,7 @@ jobs:
|
||||
throw "Expected one native NSIS installer, found $($nativeInstallers.Count)"
|
||||
}
|
||||
|
||||
$nativeName = "Starlight_Launcher_${version}_x64_nsis-setup.exe"
|
||||
$nativeName = "Axolotl_Launcher_${version}_x64_nsis-setup.exe"
|
||||
$nativePath = Join-Path $publishDir $nativeName
|
||||
Copy-Item -Force $nativeInstallers[0].FullName $nativePath
|
||||
gh release upload "${{ github.ref_name }}" $nativePath --clobber
|
||||
@@ -233,10 +252,11 @@ jobs:
|
||||
gh release upload "${{ github.ref_name }}" $nativeSignaturePath --clobber
|
||||
}
|
||||
|
||||
# Reuse the binary already built by tauri-action
|
||||
$appExe = "target/release/${appName}.exe"
|
||||
if (Test-Path $appExe) {
|
||||
New-Item -ItemType Directory -Force -Path "$buildDir\Starlight" | Out-Null
|
||||
Copy-Item -Force $appExe "$buildDir\Starlight\"
|
||||
New-Item -ItemType Directory -Force -Path "$buildDir\Axolotl" | Out-Null
|
||||
Copy-Item -Force $appExe "$buildDir\Axolotl\"
|
||||
|
||||
$editorResources = "target/release/resources/blockbench-skin"
|
||||
foreach ($requiredFile in @('index.html', 'css/setup.css', 'dist/skin.bundle.js.gz')) {
|
||||
@@ -244,26 +264,71 @@ jobs:
|
||||
throw "Missing skin editor resource: $requiredFile"
|
||||
}
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path "$buildDir\Starlight\resources" | Out-Null
|
||||
Copy-Item -LiteralPath $editorResources -Destination "$buildDir\Starlight\resources" -Recurse -Force
|
||||
New-Item -ItemType Directory -Force -Path "$buildDir\Axolotl\resources" | Out-Null
|
||||
Copy-Item -LiteralPath $editorResources -Destination "$buildDir\Axolotl\resources" -Recurse -Force
|
||||
|
||||
New-Item -ItemType Directory -Force -Path "$buildDir\Starlight\.Starlight" | Out-Null
|
||||
# Create empty .Axolotl folder to trigger portable mode
|
||||
New-Item -ItemType Directory -Force -Path "$buildDir\Axolotl\.Axolotl" | Out-Null
|
||||
|
||||
$zipName = "Starlight_Launcher_${version}_x64_portable.zip"
|
||||
# Create portable zip (for manual download)
|
||||
$zipName = "Axolotl_Launcher_${version}_x64_portable.zip"
|
||||
$zipPath = Join-Path $publishDir $zipName
|
||||
Compress-Archive -Path "$buildDir\Starlight" -DestinationPath $zipPath -Force
|
||||
Compress-Archive -Path "$buildDir\Axolotl" -DestinationPath $zipPath -Force
|
||||
Write-Output "Created portable zip: $zipPath"
|
||||
|
||||
# Upload portable zip (for manual download only)
|
||||
gh release upload "${{ github.ref_name }}" "$zipPath" --clobber
|
||||
} else {
|
||||
throw "Binary not found at $appExe"
|
||||
}
|
||||
# 所有平台构建完成后,把 preflight 创建的 draft release 转为正式发布。
|
||||
publish:
|
||||
name: Publish release
|
||||
|
||||
verify-and-publish:
|
||||
name: Verify and publish
|
||||
needs: build
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
|
||||
UPDATE_SERVER_WEBHOOK_SECRET: ${{ secrets.UPDATE_SERVER_WEBHOOK_SECRET }}
|
||||
CNB_TOKEN: ${{ secrets.CNB_TOKEN }}
|
||||
CNB_REPO_URL_HTTPS: ${{ secrets.CNB_REPO_URL_HTTPS }}
|
||||
steps:
|
||||
- name: Check out source
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- name: Download updater signatures
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh release download "${GITHUB_REF_NAME}" --pattern '*.sig' --dir release-check/signatures
|
||||
|
||||
- name: Download release metadata
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
release_id="$(
|
||||
gh api "repos/${GITHUB_REPOSITORY}/releases" \
|
||||
--paginate \
|
||||
--jq ".[] | select(.tag_name == \"${GITHUB_REF_NAME}\") | .id"
|
||||
)"
|
||||
if [[ -z "$release_id" ]]; then
|
||||
echo "Release ${GITHUB_REF_NAME} was not found"
|
||||
exit 1
|
||||
fi
|
||||
gh api "repos/${GITHUB_REPOSITORY}/releases/${release_id}" > release-check/release.json
|
||||
|
||||
- name: Generate update manifest
|
||||
run: node scripts/axolotl/create-update-manifest.mjs release-check/release.json release-check/signatures "${GITHUB_REF_NAME}" release-check/latest.json
|
||||
|
||||
- name: Verify update manifest
|
||||
run: node scripts/axolotl/verify-update-manifest.mjs release-check/latest.json "${GITHUB_REF_NAME}"
|
||||
|
||||
- name: Upload update manifest
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh release upload "${GITHUB_REF_NAME}" release-check/latest.json --clobber
|
||||
|
||||
- name: Publish release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -273,3 +338,97 @@ jobs:
|
||||
else
|
||||
gh release edit "${GITHUB_REF_NAME}" --draft=false --latest
|
||||
fi
|
||||
|
||||
- name: Refresh published GitHub release metadata
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
release_id="$(
|
||||
gh api "repos/${GITHUB_REPOSITORY}/releases" \
|
||||
--paginate \
|
||||
--jq ".[] | select(.tag_name == \"${GITHUB_REF_NAME}\") | .id"
|
||||
)"
|
||||
if [[ -z "$release_id" ]]; then
|
||||
echo "Published release ${GITHUB_REF_NAME} was not found"
|
||||
exit 1
|
||||
fi
|
||||
gh api "repos/${GITHUB_REPOSITORY}/releases/${release_id}" > release-check/release.json
|
||||
|
||||
- name: Build Update Server artifact catalog
|
||||
run: |
|
||||
node scripts/axolotl/create-update-server-catalog.mjs \
|
||||
release-check/release.json \
|
||||
"${GITHUB_REF_NAME}" \
|
||||
release-check/update-server-catalog.json
|
||||
node scripts/axolotl/create-update-server-catalog.test.mjs
|
||||
|
||||
- name: Notify Update Server to publish release assets
|
||||
if: ${{ env.UPDATE_SERVER_URL != '' && env.UPDATE_SERVER_WEBHOOK_SECRET != '' }}
|
||||
env:
|
||||
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
|
||||
UPDATE_SERVER_WEBHOOK_SECRET: ${{ secrets.UPDATE_SERVER_WEBHOOK_SECRET }}
|
||||
UPDATE_SERVER_FORCE_UPDATE: 'false'
|
||||
run: |
|
||||
node scripts/axolotl/publish-update-server.mjs \
|
||||
release-check/update-server-catalog.json \
|
||||
release-check/release.json \
|
||||
"${GITHUB_REF_NAME}"
|
||||
|
||||
- name: Check out website branch
|
||||
if: ${{ !contains(github.ref_name, '-') }}
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
|
||||
with:
|
||||
ref: main
|
||||
clean: false
|
||||
|
||||
- name: Publish website download metadata
|
||||
if: ${{ !contains(github.ref_name, '-') }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
node scripts/axolotl/create-website-release-metadata.mjs \
|
||||
release-check/release.json \
|
||||
apps/website/releases/latest.json
|
||||
node scripts/axolotl/create-website-release-catalog.mjs \
|
||||
apps/website/releases/catalog.json
|
||||
|
||||
git config user.name "Axolotl Release Bot"
|
||||
git config user.email "release@axolotl.local"
|
||||
git add apps/website/releases/latest.json apps/website/releases/catalog.json
|
||||
if git diff --cached --quiet; then
|
||||
echo "Website metadata is unchanged"
|
||||
else
|
||||
git commit -m "chore(website): update download metadata to ${GITHUB_REF_NAME} [skip ci]"
|
||||
git push origin main
|
||||
fi
|
||||
|
||||
- name: Mirror published release to CNB
|
||||
if: ${{ env.CNB_TOKEN != '' && env.CNB_REPO_URL_HTTPS != '' }}
|
||||
env:
|
||||
CNB_REPO_URL_HTTPS: ${{ secrets.CNB_REPO_URL_HTTPS }}
|
||||
CNB_TOKEN: ${{ secrets.CNB_TOKEN }}
|
||||
CNB_TOKEN_USER_NAME: ${{ secrets.CNB_TOKEN_USER_NAME }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_API_BASE_URL: https://api.github.com/repos/${{ github.repository }}
|
||||
GITHUB_RELEASE_BASE_URL: https://github.com/${{ github.repository }}/releases/download
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${CNB_TOKEN:-}" ]]; then
|
||||
echo "CNB_TOKEN is not configured"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cnb_url="${CNB_REPO_URL_HTTPS:-https://cnb.cool/axlmc/Axolotl.git}"
|
||||
token_user="${CNB_TOKEN_USER_NAME:-cnb}"
|
||||
auth="$(printf '%s:%s' "$token_user" "$CNB_TOKEN" | base64 -w0)"
|
||||
git_cnb() {
|
||||
git -c "http.extraHeader=Authorization: Basic $auth" "$@"
|
||||
}
|
||||
|
||||
git fetch origin "refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}"
|
||||
git_cnb push --force "$cnb_url" \
|
||||
"refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}"
|
||||
|
||||
node scripts/axolotl/cnb-release.mjs finalize "${GITHUB_REF_NAME}" cnb-release/final
|
||||
node scripts/axolotl/verify-update-manifest.mjs \
|
||||
cnb-release/final/latest.json "${GITHUB_REF_NAME}" cnb
|
||||
@@ -0,0 +1,70 @@
|
||||
name: Sync source to CNB
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
tags:
|
||||
- '**'
|
||||
delete:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: sync-cnb-source-${{ github.event_name == 'delete' && format('{0}/{1}', github.event.ref_type, github.event.ref) || github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
name: Sync Git ref
|
||||
if: ${{ vars.ENABLE_CNB_SYNC == 'true' }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check out source
|
||||
if: ${{ github.event_name != 'delete' }}
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
filter: blob:none
|
||||
|
||||
- name: Push Git ref to CNB
|
||||
env:
|
||||
CNB_REPO_URL_HTTPS: ${{ secrets.CNB_REPO_URL_HTTPS }}
|
||||
CNB_TOKEN: ${{ secrets.CNB_TOKEN }}
|
||||
CNB_TOKEN_USER_NAME: ${{ secrets.CNB_TOKEN_USER_NAME }}
|
||||
DELETED_REF: ${{ github.event.ref }}
|
||||
DELETED_REF_TYPE: ${{ github.event.ref_type }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${CNB_TOKEN:-}" || -z "${CNB_REPO_URL_HTTPS:-}" ]]; then
|
||||
echo "CNB_TOKEN and CNB_REPO_URL_HTTPS must be configured"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cnb_url="${CNB_REPO_URL_HTTPS:-https://cnb.cool/axlmc/Axolotl.git}"
|
||||
token_user="${CNB_TOKEN_USER_NAME:-cnb}"
|
||||
auth="$(printf '%s:%s' "$token_user" "$CNB_TOKEN" | base64 -w0)"
|
||||
git_cnb() {
|
||||
git -c "http.extraHeader=Authorization: Basic $auth" "$@"
|
||||
}
|
||||
|
||||
if [[ "$GITHUB_EVENT_NAME" == "delete" ]]; then
|
||||
git init
|
||||
if [[ "$DELETED_REF_TYPE" == "tag" ]]; then
|
||||
ref="refs/tags/$DELETED_REF"
|
||||
else
|
||||
ref="refs/heads/$DELETED_REF"
|
||||
fi
|
||||
git_cnb push "$cnb_url" --delete "$ref"
|
||||
else
|
||||
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
|
||||
git fetch --force origin "$GITHUB_REF:$GITHUB_REF"
|
||||
source_ref="$GITHUB_REF"
|
||||
else
|
||||
source_ref="HEAD"
|
||||
fi
|
||||
git_cnb push --force "$cnb_url" "$source_ref:$GITHUB_REF"
|
||||
fi
|
||||
Generated
+2
-2
@@ -6795,7 +6795,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "theseus"
|
||||
version = "1.9.6-beta.3"
|
||||
version = "1.0.0-beta12"
|
||||
dependencies = [
|
||||
"ariadne",
|
||||
"async-minecraft-ping",
|
||||
@@ -6899,7 +6899,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "theseus_gui"
|
||||
version = "1.9.6-beta.3"
|
||||
version = "1.0.0-beta12"
|
||||
dependencies = [
|
||||
"ashpd",
|
||||
"async_zip",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@modrinth/app-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0-beta1",
|
||||
"version": "1.0.0-beta12",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -42,6 +42,7 @@ const { offline } = useNetworkStatus()
|
||||
const handleMinecraftLaunchError = useMinecraftLaunchError()
|
||||
|
||||
const messages = defineMessages({
|
||||
stopAll: { id: 'app.instance.stop-all', defaultMessage: 'Stop all windows of this instance' },
|
||||
addContent: { id: 'app.instances.add-content', defaultMessage: 'Add content' },
|
||||
viewInstance: { id: 'app.instances.view-instance', defaultMessage: 'View instance' },
|
||||
duplicateInstance: {
|
||||
@@ -265,7 +266,12 @@ onUnmounted(() => {
|
||||
@delete="deleteInstance"
|
||||
/>
|
||||
<div ref="rowContainer" class="flex flex-col gap-4">
|
||||
<div v-for="row in actualInstances" ref="rows" :key="row.label" class="row flex flex-col items-start overflow-hidden w-full min-w-full">
|
||||
<div
|
||||
v-for="row in actualInstances"
|
||||
ref="rows"
|
||||
:key="row.label"
|
||||
class="row flex flex-col items-start overflow-hidden w-full min-w-full"
|
||||
>
|
||||
<HeadingLink class="mt-1" :to="row.route">
|
||||
{{ row.label }}
|
||||
</HeadingLink>
|
||||
@@ -307,7 +313,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<ContextMenu ref="instanceOptions" @option-clicked="handleOptionsClick">
|
||||
<template #play> <PlayIcon /> {{ formatMessage(commonMessages.playButton) }} </template>
|
||||
<template #stop> <StopCircleIcon /> {{ formatMessage(commonMessages.stopButton) }} </template>
|
||||
<template #stop> <StopCircleIcon /> {{ formatMessage(messages.stopAll) }} </template>
|
||||
<template #add_content> <PlusIcon /> {{ formatMessage(messages.addContent) }} </template>
|
||||
<template #edit> <EyeIcon /> {{ formatMessage(messages.viewInstance) }} </template>
|
||||
<template #delete> <TrashIcon /> {{ formatMessage(commonMessages.deleteLabel) }} </template>
|
||||
|
||||
@@ -29,6 +29,7 @@ import { process_listener } from '@/helpers/events'
|
||||
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
|
||||
import { kill, run } from '@/helpers/instance'
|
||||
import { get_by_instance_id } from '@/helpers/process'
|
||||
import { isInstanceLaunching } from '@/helpers/instance-launch-state'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { handleSevereError } from '@/store/error'
|
||||
|
||||
@@ -50,6 +51,8 @@ const handleMinecraftLaunchError = useMinecraftLaunchError()
|
||||
const { offline } = useNetworkStatus()
|
||||
|
||||
const messages = defineMessages({
|
||||
launchAnother: { id: 'app.instance.launch-another', defaultMessage: 'Launch another window' },
|
||||
stopAll: { id: 'app.instance.stop-all', defaultMessage: 'Stop all windows of this instance' },
|
||||
chooseInstance: {
|
||||
id: 'app.home.minimal.choose-instance',
|
||||
defaultMessage: 'Choose instance',
|
||||
@@ -88,14 +91,17 @@ const selectedInstance = computed(() =>
|
||||
props.instances.find((instance) => instance.id === props.selectedInstanceId),
|
||||
)
|
||||
const running = ref(false)
|
||||
const loading = ref(false)
|
||||
const currentEvent = ref<string | null>(null)
|
||||
const installingId = ref<string>()
|
||||
const installed = computed(() => selectedInstance.value?.install_stage === 'installed')
|
||||
const installing = computed(
|
||||
() => selectedInstance.value?.install_stage.includes('installing') ?? false,
|
||||
)
|
||||
const busy = computed(
|
||||
() => loading.value || installing.value || (currentEvent.value === 'launched' && !running.value),
|
||||
() =>
|
||||
!!selectedInstance.value &&
|
||||
(isInstanceLaunching(selectedInstance.value.id) ||
|
||||
installingId.value === selectedInstance.value.id ||
|
||||
installing.value),
|
||||
)
|
||||
|
||||
const lastPlayed = computed(() => {
|
||||
@@ -111,18 +117,17 @@ async function refreshProcessState() {
|
||||
return
|
||||
}
|
||||
|
||||
const processes = await get_by_instance_id(selectedInstance.value.id).catch((error) => {
|
||||
const id = selectedInstance.value.id
|
||||
const processes = await get_by_instance_id(id).catch((error) => {
|
||||
handleError(error)
|
||||
return []
|
||||
})
|
||||
running.value = processes.length > 0
|
||||
if (selectedInstance.value?.id === id) running.value = processes.length > 0
|
||||
}
|
||||
|
||||
async function playInstance() {
|
||||
const instance = selectedInstance.value
|
||||
if (!instance) return
|
||||
|
||||
loading.value = true
|
||||
if (!instance || busy.value) return
|
||||
try {
|
||||
await run(instance.id)
|
||||
} catch (error) {
|
||||
@@ -132,7 +137,6 @@ async function playInstance() {
|
||||
})
|
||||
if (!handled) handleSevereError(error, { instanceId: instance.id })
|
||||
} finally {
|
||||
loading.value = false
|
||||
await refreshProcessState()
|
||||
}
|
||||
}
|
||||
@@ -142,14 +146,14 @@ async function stopInstance() {
|
||||
if (!instance) return
|
||||
|
||||
await kill(instance.id).catch(handleError)
|
||||
running.value = false
|
||||
await refreshProcessState()
|
||||
}
|
||||
|
||||
async function installInstance() {
|
||||
const instance = selectedInstance.value
|
||||
if (!instance) return
|
||||
|
||||
loading.value = true
|
||||
installingId.value = instance.id
|
||||
try {
|
||||
if (
|
||||
instance.install_stage !== 'pack_installed' &&
|
||||
@@ -168,14 +172,14 @@ async function installInstance() {
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (installingId.value === instance.id) installingId.value = undefined
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.selectedInstanceId,
|
||||
() => {
|
||||
currentEvent.value = null
|
||||
running.value = false
|
||||
void refreshProcessState()
|
||||
},
|
||||
)
|
||||
@@ -184,9 +188,7 @@ await refreshProcessState()
|
||||
|
||||
const unlistenProcess = await process_listener((event: { instance_id: string; event: string }) => {
|
||||
if (event.instance_id !== selectedInstance.value?.id) return
|
||||
currentEvent.value = event.event
|
||||
if (event.event === 'finished') running.value = false
|
||||
else void refreshProcessState()
|
||||
void refreshProcessState()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -237,13 +239,17 @@ onUnmounted(() => {
|
||||
</router-link>
|
||||
|
||||
<div class="flex min-h-11 shrink-0 items-center justify-end gap-2">
|
||||
<ButtonStyled v-if="running" color="red" size="large">
|
||||
<button class="w-36 justify-center" @click="stopInstance">
|
||||
<ButtonStyled v-if="running" color="red" size="large" circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.stopAll)"
|
||||
:aria-label="formatMessage(messages.stopAll)"
|
||||
:disabled="busy"
|
||||
@click="stopInstance"
|
||||
>
|
||||
<StopCircleIcon aria-hidden="true" />
|
||||
<span class="truncate">{{ formatMessage(commonMessages.stopButton) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="busy" size="large">
|
||||
<ButtonStyled v-if="busy" size="large">
|
||||
<button class="w-36 justify-center" disabled>
|
||||
<SpinnerIcon class="animate-spin" aria-hidden="true" />
|
||||
<span class="truncate">
|
||||
@@ -256,7 +262,9 @@ onUnmounted(() => {
|
||||
<ButtonStyled v-else-if="installed" color="brand" size="large">
|
||||
<button class="w-36 justify-center" @click="playInstance">
|
||||
<PlayIcon class="translate-x-px" aria-hidden="true" />
|
||||
<span class="truncate">{{ formatMessage(commonMessages.playButton) }}</span>
|
||||
<span class="truncate">{{
|
||||
formatMessage(running ? messages.launchAnother : commonMessages.playButton)
|
||||
}}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else color="brand" size="large">
|
||||
|
||||
@@ -137,6 +137,11 @@
|
||||
<OnlineIndicatorIcon />
|
||||
<span class="mr-auto text-contrast flex items-center gap-2">
|
||||
{{ process.instance.name }}
|
||||
<span class="text-xs text-secondary">{{
|
||||
formatMessage(messages.windowStarted, {
|
||||
time: new Date(process.start_time),
|
||||
})
|
||||
}}</span>
|
||||
<StarIcon v-if="process.uuid === selectedProcess.uuid" class="text-orange" />
|
||||
</span>
|
||||
</button>
|
||||
@@ -307,6 +312,7 @@ const notificationCenterShown = ref(false)
|
||||
|
||||
interface RunningProcess {
|
||||
uuid: string
|
||||
start_time: string
|
||||
instance_id: string
|
||||
instance: GameInstance
|
||||
}
|
||||
@@ -320,6 +326,10 @@ interface LoadingEventPayload {
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
windowStarted: {
|
||||
id: 'app.action-bar.window-started',
|
||||
defaultMessage: 'Started {time, time, medium}',
|
||||
},
|
||||
offline: {
|
||||
id: 'app.action-bar.offline',
|
||||
defaultMessage: 'Offline',
|
||||
@@ -346,7 +356,7 @@ const messages = defineMessages({
|
||||
},
|
||||
stopInstance: {
|
||||
id: 'app.action-bar.stop-instance',
|
||||
defaultMessage: 'Stop instance',
|
||||
defaultMessage: 'Stop window',
|
||||
},
|
||||
viewLogs: {
|
||||
id: 'app.action-bar.view-logs',
|
||||
@@ -401,8 +411,9 @@ const refresh = async () => {
|
||||
const processes = ((await getRunningProcesses().catch((error) => {
|
||||
handleError(error)
|
||||
return []
|
||||
})) ?? []) as Array<{ uuid: string; instance_id: string }>
|
||||
const instanceIds = processes.map((process) => process.instance_id)
|
||||
})) ?? []) as Array<{ uuid: string; instance_id: string; start_time: string }>
|
||||
processes.sort((a, b) => a.start_time.localeCompare(b.start_time))
|
||||
const instanceIds = [...new Set(processes.map((process) => process.instance_id))]
|
||||
const instances: GameInstance[] = await getInstances(instanceIds).catch((error) => {
|
||||
handleError(error)
|
||||
return []
|
||||
@@ -420,9 +431,9 @@ const refresh = async () => {
|
||||
}
|
||||
})
|
||||
.filter((process): process is RunningProcess => process !== null)
|
||||
if (!selectedProcess.value || !currentProcesses.value.includes(selectedProcess.value)) {
|
||||
selectedProcess.value = currentProcesses.value[0]
|
||||
}
|
||||
selectedProcess.value =
|
||||
currentProcesses.value.find((process) => process.uuid === selectedProcess.value?.uuid) ??
|
||||
currentProcesses.value[0]
|
||||
}
|
||||
|
||||
await refresh()
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
<script setup>
|
||||
import {
|
||||
DownloadIcon,
|
||||
GameIcon,
|
||||
PlayIcon,
|
||||
SpinnerIcon,
|
||||
StopCircleIcon,
|
||||
TimerIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { DownloadIcon, GameIcon, PlayIcon, SpinnerIcon, TimerIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
@@ -28,6 +21,7 @@ import { install_existing_instance, install_pack_to_existing_instance } from '@/
|
||||
import { kill, run } from '@/helpers/instance'
|
||||
import { getDisplayInstanceIcon } from '@/helpers/instance-icons'
|
||||
import { get_by_instance_id } from '@/helpers/process'
|
||||
import { isInstanceLaunching } from '@/helpers/instance-launch-state'
|
||||
import { showInstanceInFolder } from '@/helpers/utils.js'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
|
||||
@@ -36,6 +30,7 @@ const formatRelativeTime = useRelativeTime()
|
||||
const { formatMessage } = useVIntl()
|
||||
const handleMinecraftLaunchError = useMinecraftLaunchError()
|
||||
const messages = defineMessages({
|
||||
launchAnother: { id: 'app.instance.launch-another', defaultMessage: 'Launch another window' },
|
||||
loading: { id: 'app.instance.loading', defaultMessage: 'Instance is loading...' },
|
||||
played: { id: 'app.instance.played', defaultMessage: 'Played {time}' },
|
||||
neverPlayed: { id: 'app.instance.never-played', defaultMessage: 'Never played' },
|
||||
@@ -85,13 +80,8 @@ const isPlaying = computed(() => props.playing ?? internalPlaying.value)
|
||||
const displayIcon = computed(() =>
|
||||
getDisplayInstanceIcon(props.instance.icon_path, props.instance.loader),
|
||||
)
|
||||
const loading = ref(false)
|
||||
const modLoading = computed(
|
||||
() =>
|
||||
loading.value ||
|
||||
currentEvent.value === 'installing' ||
|
||||
(currentEvent.value === 'launched' && !isPlaying.value),
|
||||
)
|
||||
const loading = computed(() => isInstanceLaunching(props.instance.id))
|
||||
const modLoading = computed(() => loading.value || installing.value)
|
||||
const installing = computed(() => props.instance.install_stage.includes('installing'))
|
||||
const installed = computed(() => props.instance.install_stage === 'installed')
|
||||
|
||||
@@ -105,31 +95,26 @@ const checkProcess = async () => {
|
||||
if (props.playing !== undefined) return
|
||||
const runningProcesses = await get_by_instance_id(props.instance.id).catch(handleError)
|
||||
|
||||
internalPlaying.value = runningProcesses.length > 0
|
||||
if (Array.isArray(runningProcesses)) internalPlaying.value = runningProcesses.length > 0
|
||||
}
|
||||
|
||||
const play = async (e, context) => {
|
||||
e?.stopPropagation()
|
||||
loading.value = true
|
||||
await run(props.instance.id)
|
||||
.catch(async (err) => {
|
||||
const handled = await handleMinecraftLaunchError(err, {
|
||||
instance_id: props.instance.id,
|
||||
instance_name: props.instance.name,
|
||||
})
|
||||
if (!handled) handleSevereError(err, { instanceId: props.instance.id })
|
||||
if (loading.value) return
|
||||
await run(props.instance.id).catch(async (err) => {
|
||||
const handled = await handleMinecraftLaunchError(err, {
|
||||
instance_id: props.instance.id,
|
||||
instance_name: props.instance.name,
|
||||
})
|
||||
.finally(() => {
|
||||
})
|
||||
loading.value = false
|
||||
if (!handled) handleSevereError(err, { instanceId: props.instance.id })
|
||||
})
|
||||
await checkProcess()
|
||||
}
|
||||
|
||||
const stop = async (e, context) => {
|
||||
e?.stopPropagation()
|
||||
internalPlaying.value = false
|
||||
|
||||
await kill(props.instance.id).catch(handleError)
|
||||
|
||||
await checkProcess()
|
||||
}
|
||||
|
||||
const repair = async (e) => {
|
||||
@@ -171,16 +156,11 @@ defineExpose({
|
||||
instance: props.instance,
|
||||
})
|
||||
|
||||
const currentEvent = ref(null)
|
||||
|
||||
const unlisten =
|
||||
props.playing === undefined
|
||||
? await process_listener((e) => {
|
||||
if (e.instance_id === props.instance.id) {
|
||||
currentEvent.value = e.event
|
||||
if (e.event === 'finished') {
|
||||
internalPlaying.value = false
|
||||
}
|
||||
void checkProcess()
|
||||
}
|
||||
})
|
||||
: () => undefined
|
||||
@@ -212,15 +192,7 @@ onUnmounted(() => unlisten())
|
||||
<span class="line-clamp-2">{{ instance.name }}</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<ButtonStyled v-if="isPlaying" color="red" circular @mousehover="checkProcess">
|
||||
<button
|
||||
v-tooltip="formatMessage(commonMessages.stopButton)"
|
||||
@click="(e) => stop(e, 'InstanceCard')"
|
||||
>
|
||||
<StopCircleIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="modLoading" color="standard" circular>
|
||||
<ButtonStyled v-if="modLoading" color="standard" circular>
|
||||
<button v-tooltip="formatMessage(messages.loading)" disabled>
|
||||
<SpinnerIcon class="animate-spin" />
|
||||
</button>
|
||||
@@ -230,7 +202,7 @@ onUnmounted(() => unlisten())
|
||||
v-tooltip="
|
||||
offline && !installed
|
||||
? formatMessage(messages.offlineInstalledOnly)
|
||||
: formatMessage(commonMessages.playButton)
|
||||
: formatMessage(isPlaying ? messages.launchAnother : commonMessages.playButton)
|
||||
"
|
||||
:disabled="offline && !installed"
|
||||
@click="(e) => play(e, 'InstanceCard')"
|
||||
@@ -290,17 +262,8 @@ onUnmounted(() => unlisten())
|
||||
/>
|
||||
</div>
|
||||
<div class="absolute bottom-1.5 right-1.5 flex size-12 items-center justify-center">
|
||||
<ButtonStyled v-if="isPlaying" size="large" color="red" circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(commonMessages.stopButton)"
|
||||
@click="(e) => stop(e, 'InstanceCard')"
|
||||
@mousehover="checkProcess"
|
||||
>
|
||||
<StopCircleIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-else-if="!modLoading && !installing && !installed"
|
||||
v-if="!modLoading && !installing && !installed"
|
||||
size="large"
|
||||
color="brand"
|
||||
circular
|
||||
@@ -324,7 +287,9 @@ onUnmounted(() => unlisten())
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="!modLoading && !installing" size="large" color="brand" circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(commonMessages.playButton)"
|
||||
v-tooltip="
|
||||
formatMessage(isPlaying ? messages.launchAnother : commonMessages.playButton)
|
||||
"
|
||||
:class="{
|
||||
'pointer-events-none scale-75 opacity-0': disabled,
|
||||
'scale-75 opacity-0 transition-all group-hover:scale-100 group-hover:opacity-100':
|
||||
@@ -369,19 +334,8 @@ onUnmounted(() => unlisten())
|
||||
:class="`transition-all ${modLoading || installing ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
|
||||
/>
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<ButtonStyled v-if="isPlaying" size="large" color="red" circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(commonMessages.stopButton)"
|
||||
:class="{ 'scale-100 opacity-100': isPlaying }"
|
||||
class="transition-all origin-bottom opacity-0 card-shadow"
|
||||
@click="(e) => stop(e, 'InstanceCard')"
|
||||
@mousehover="checkProcess"
|
||||
>
|
||||
<StopCircleIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<SpinnerIcon
|
||||
v-else-if="modLoading || installing"
|
||||
v-if="modLoading || installing"
|
||||
v-tooltip="
|
||||
modLoading
|
||||
? formatMessage(messages.loading)
|
||||
@@ -406,7 +360,9 @@ onUnmounted(() => unlisten())
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else size="large" color="brand" circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(commonMessages.playButton)"
|
||||
v-tooltip="
|
||||
formatMessage(isPlaying ? messages.launchAnother : commonMessages.playButton)
|
||||
"
|
||||
:class="`transition-all scale-75 origin-bottom card-shadow ${disabled ? 'opacity-0 scale-75' : 'opacity-0 group-hover:scale-100 group-hover:opacity-100 group-focus-within:scale-100 group-focus-within:opacity-100'}`"
|
||||
@click="(e) => play(e, 'InstanceCard')"
|
||||
@mousehover="checkProcess"
|
||||
|
||||
@@ -242,7 +242,7 @@ export const onboardingMessages = defineMessages({
|
||||
instanceActionsDescription: {
|
||||
id: 'app.onboarding.instance-actions.description',
|
||||
defaultMessage:
|
||||
'Launch or configure this instance here. Choose a player on first launch; the instance remembers your choice until you switch it in settings.',
|
||||
'Launch or configure this instance here. You can launch another window while it is running; use the top bar to stop an individual window. Choose a player on first launch; the instance remembers your choice until you switch it in settings.',
|
||||
},
|
||||
instanceTabsTitle: {
|
||||
id: 'app.onboarding.instance-tabs.title',
|
||||
|
||||
@@ -30,6 +30,7 @@ import { get_project } from '@/helpers/cache'
|
||||
import { process_listener } from '@/helpers/events'
|
||||
import { kill, run } from '@/helpers/instance'
|
||||
import { get_by_instance_id } from '@/helpers/process'
|
||||
import { isInstanceLaunching } from '@/helpers/instance-launch-state'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { showInstanceInFolder } from '@/helpers/utils'
|
||||
import { handleSevereError } from '@/store/error'
|
||||
@@ -46,10 +47,8 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Loading modpack...',
|
||||
},
|
||||
viewInstance: { id: 'app.instance-item.view-instance', defaultMessage: 'View instance' },
|
||||
alreadyOpen: {
|
||||
id: 'app.instance-item.already-open',
|
||||
defaultMessage: 'Instance is already open',
|
||||
},
|
||||
launchAnother: { id: 'app.instance.launch-another', defaultMessage: 'Launch another window' },
|
||||
stopAll: { id: 'app.instance.stop-all', defaultMessage: 'Stop all windows of this instance' },
|
||||
})
|
||||
const formatDateTime = useFormatDateTime({
|
||||
timeStyle: 'short',
|
||||
@@ -91,33 +90,32 @@ const loader = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const stopping = ref(false)
|
||||
const loading = computed(() => stopping.value || isInstanceLaunching(props.instance.id))
|
||||
const internalPlaying = ref(false)
|
||||
const isPlaying = computed(() => props.playing ?? internalPlaying.value)
|
||||
|
||||
const play = async (event: MouseEvent) => {
|
||||
event?.stopPropagation()
|
||||
loading.value = true
|
||||
await run(props.instance.id)
|
||||
.catch(async (err) => {
|
||||
const handled = await handleMinecraftLaunchError(err, {
|
||||
instance_id: props.instance.id,
|
||||
instance_name: props.instance.name,
|
||||
})
|
||||
if (!handled) handleSevereError(err, { instanceId: props.instance.id })
|
||||
})
|
||||
.finally(() => {
|
||||
if (loading.value) return
|
||||
await run(props.instance.id).catch(async (err) => {
|
||||
const handled = await handleMinecraftLaunchError(err, {
|
||||
instance_id: props.instance.id,
|
||||
instance_name: props.instance.name,
|
||||
})
|
||||
if (!handled) handleSevereError(err, { instanceId: props.instance.id })
|
||||
})
|
||||
emit('play')
|
||||
loading.value = false
|
||||
await checkProcess()
|
||||
}
|
||||
|
||||
const stop = async (event: MouseEvent) => {
|
||||
event?.stopPropagation()
|
||||
loading.value = true
|
||||
stopping.value = true
|
||||
await kill(props.instance.id).catch(handleError)
|
||||
emit('stop')
|
||||
loading.value = false
|
||||
stopping.value = false
|
||||
await checkProcess()
|
||||
}
|
||||
|
||||
const unlistenProcesses =
|
||||
@@ -131,7 +129,7 @@ const checkProcess = async () => {
|
||||
if (props.playing !== undefined) return
|
||||
const runningProcesses = await get_by_instance_id(props.instance.id).catch(handleError)
|
||||
|
||||
internalPlaying.value = runningProcesses.length > 0
|
||||
if (Array.isArray(runningProcesses)) internalPlaying.value = runningProcesses.length > 0
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -155,7 +153,8 @@ onUnmounted(() => {
|
||||
:class="[
|
||||
flat ? 'px-2 py-2 hover:bg-button-bg' : 'card-shadow bg-bg-raised p-3',
|
||||
{
|
||||
'instance-item-dashboard-compact grid grid-cols-[auto_minmax(0,1fr)_auto] gap-2 p-1.5': dashboardDensity === 'compact',
|
||||
'instance-item-dashboard-compact grid grid-cols-[auto_minmax(0,1fr)_auto] gap-2 p-1.5':
|
||||
dashboardDensity === 'compact',
|
||||
'instance-item-dashboard-comfortable p-2': dashboardDensity === 'comfortable',
|
||||
},
|
||||
]"
|
||||
@@ -214,23 +213,25 @@ onUnmounted(() => {
|
||||
color="red"
|
||||
:circular="dashboardDensity === 'compact'"
|
||||
>
|
||||
<button @click="stop">
|
||||
<button v-tooltip="formatMessage(messages.stopAll)" @click="stop">
|
||||
<StopCircleIcon aria-hidden="true" />
|
||||
<span v-if="dashboardDensity !== 'compact'">
|
||||
{{ formatMessage(commonMessages.stopButton) }}
|
||||
{{ formatMessage(messages.stopAll) }}
|
||||
</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else :circular="dashboardDensity === 'compact'">
|
||||
<ButtonStyled :circular="dashboardDensity === 'compact'">
|
||||
<button
|
||||
v-tooltip="isPlaying ? formatMessage(messages.alreadyOpen) : null"
|
||||
:disabled="isPlaying || loading"
|
||||
v-tooltip="
|
||||
formatMessage(isPlaying ? messages.launchAnother : commonMessages.playButton)
|
||||
"
|
||||
:disabled="loading"
|
||||
@click="play"
|
||||
>
|
||||
<SpinnerIcon v-if="loading" class="animate-spin" />
|
||||
<PlayIcon v-else aria-hidden="true" />
|
||||
<span v-if="dashboardDensity !== 'compact'">
|
||||
{{ formatMessage(commonMessages.playButton) }}
|
||||
{{ formatMessage(isPlaying ? messages.launchAnother : commonMessages.playButton) }}
|
||||
</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
@@ -280,5 +281,4 @@ onUnmounted(() => {
|
||||
.instance-item-dashboard-compact > :last-child {
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { isInstanceLaunching, launchOnce } from './instance-launch-state.ts'
|
||||
|
||||
test('launches different instances concurrently and coalesces repeated pending clicks', async () => {
|
||||
let finishA!: (value: string) => void
|
||||
let starts = 0
|
||||
const first = launchOnce('a', () => {
|
||||
starts++
|
||||
return new Promise<string>((resolve) => {
|
||||
finishA = resolve
|
||||
})
|
||||
})
|
||||
assert.equal(
|
||||
launchOnce('a', async () => 'unexpected duplicate'),
|
||||
first,
|
||||
)
|
||||
assert.equal(isInstanceLaunching('a'), true)
|
||||
assert.equal(isInstanceLaunching('b'), false)
|
||||
assert.equal(await launchOnce('b', async () => 'b started'), 'b started')
|
||||
assert.equal(isInstanceLaunching('a'), true)
|
||||
finishA('a started')
|
||||
assert.equal(await first, 'a started')
|
||||
assert.equal(starts, 1)
|
||||
assert.equal(isInstanceLaunching('a'), false)
|
||||
assert.equal(await launchOnce('a', async () => 'second window'), 'second window')
|
||||
})
|
||||
|
||||
test('failed launch releases only its own pending state and can retry', async () => {
|
||||
await assert.rejects(
|
||||
launchOnce('failed', async () => {
|
||||
throw new Error('launch failed')
|
||||
}),
|
||||
/launch failed/,
|
||||
)
|
||||
assert.equal(isInstanceLaunching('failed'), false)
|
||||
assert.equal(await launchOnce('failed', async () => 'retry'), 'retry')
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
const pending = reactive(new Map<string, Promise<unknown>>())
|
||||
|
||||
export function isInstanceLaunching(instanceId: string) {
|
||||
return pending.has(instanceId)
|
||||
}
|
||||
|
||||
/** Coalesce repeated clicks for one launch without blocking other instances. */
|
||||
export function launchOnce<T>(instanceId: string, launch: () => Promise<T>): Promise<T> {
|
||||
const existing = pending.get(instanceId)
|
||||
if (existing) return existing as Promise<T>
|
||||
const task = Promise.resolve()
|
||||
.then(launch)
|
||||
.finally(() => {
|
||||
pending.delete(instanceId)
|
||||
})
|
||||
pending.set(instanceId, task)
|
||||
return task
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import type { GcContext, ResolvedGcStrategyId } from '@/helpers/gc/types'
|
||||
import { setLastGcLaunchReport } from '@/helpers/gc-notice'
|
||||
import { getInstanceMode, prepareHostedSession } from '@/helpers/hosted-packs'
|
||||
import { prepareInstancePlayer } from '@/helpers/instance-player'
|
||||
import { launchOnce } from '@/helpers/instance-launch-state'
|
||||
import { AUTO_GC_PRESET_ARG } from '@/helpers/java-arguments'
|
||||
import { get_jre, get_memory_status } from '@/helpers/jre.js'
|
||||
import { get as getSettings } from '@/helpers/settings'
|
||||
@@ -1071,12 +1072,19 @@ export async function resolveGcLaunchIntent(
|
||||
|
||||
// Run Minecraft using an instance
|
||||
// Returns the process metadata plus an optional GC fallback report.
|
||||
export async function run(
|
||||
export function run(
|
||||
instanceId: string,
|
||||
serverAddress: string | null = null,
|
||||
): Promise<InstanceRunResult> {
|
||||
return launchOnce(instanceId, () => launchInstance(instanceId, serverAddress))
|
||||
}
|
||||
|
||||
async function launchInstance(
|
||||
instanceId: string,
|
||||
serverAddress: string | null,
|
||||
): Promise<InstanceRunResult> {
|
||||
await prepareInstancePlayer(instanceId)
|
||||
if (await getInstanceMode(instanceId) === 'starlight') await prepareHostedSession()
|
||||
if ((await getInstanceMode(instanceId)) === 'starlight') await prepareHostedSession()
|
||||
const { args, gcIntent } = await resolveGcLaunchIntent(instanceId)
|
||||
const result = await invoke<InstanceRunResult>('plugin:instance|instance_run', {
|
||||
instanceId,
|
||||
|
||||
@@ -348,7 +348,7 @@
|
||||
"message": "Show more running instances"
|
||||
},
|
||||
"app.action-bar.stop-instance": {
|
||||
"message": "Stop instance"
|
||||
"message": "Stop window"
|
||||
},
|
||||
"app.action-bar.update": {
|
||||
"message": "Update"
|
||||
@@ -2963,6 +2963,15 @@
|
||||
"app.instance.launch-instance": {
|
||||
"message": "Launch instance"
|
||||
},
|
||||
"app.instance.launch-another": {
|
||||
"message": "Launch another window"
|
||||
},
|
||||
"app.action-bar.window-started": {
|
||||
"message": "Started {time, time, medium}"
|
||||
},
|
||||
"app.instance.stop-all": {
|
||||
"message": "Stop all windows of this instance"
|
||||
},
|
||||
"app.instance.linked-to": {
|
||||
"message": "Linked to"
|
||||
},
|
||||
@@ -5889,7 +5898,7 @@
|
||||
"message": "Switch your home"
|
||||
},
|
||||
"app.onboarding.instance-actions.description": {
|
||||
"message": "Launch or configure this instance here. Choose a player on first launch; the instance remembers your choice until you switch it in settings."
|
||||
"message": "Launch or configure this instance here. You can launch another window while it is running; use the top bar to stop an individual window. Choose a player on first launch; the instance remembers your choice until you switch it in settings."
|
||||
},
|
||||
"app.onboarding.instance-actions.title": {
|
||||
"message": "The main controls"
|
||||
|
||||
@@ -380,7 +380,7 @@
|
||||
"message": "显示更多运行中的实例"
|
||||
},
|
||||
"app.action-bar.stop-instance": {
|
||||
"message": "停止实例"
|
||||
"message": "停止当前窗口"
|
||||
},
|
||||
"app.action-bar.update": {
|
||||
"message": "更新"
|
||||
@@ -3049,6 +3049,15 @@
|
||||
"app.instance.launch-instance": {
|
||||
"message": "启动实例"
|
||||
},
|
||||
"app.instance.launch-another": {
|
||||
"message": "再开一个窗口"
|
||||
},
|
||||
"app.action-bar.window-started": {
|
||||
"message": "{time, time, medium} 启动"
|
||||
},
|
||||
"app.instance.stop-all": {
|
||||
"message": "停止此实例全部窗口"
|
||||
},
|
||||
"app.instance.linked-to": {
|
||||
"message": "关联到"
|
||||
},
|
||||
@@ -5975,7 +5984,7 @@
|
||||
"message": "切换主页样式"
|
||||
},
|
||||
"app.onboarding.instance-actions.description": {
|
||||
"message": "从这里启动或配置实例。首次启动选择玩家后会自动记住,之后可在实例设置中切换。"
|
||||
"message": "从这里启动或配置实例。运行中可再开一个窗口,顶部运行列表可单独停止某个窗口。首次启动选择玩家后会自动记住,之后可在实例设置中切换。"
|
||||
},
|
||||
"app.onboarding.instance-actions.title": {
|
||||
"message": "主操作台"
|
||||
@@ -10288,6 +10297,9 @@
|
||||
"minecraft-account.third-party-modal.description": {
|
||||
"message": "使用 StarLight 皮肤站账号登录。"
|
||||
},
|
||||
"minecraft-account.third-party-modal.littleskin": {
|
||||
"message": "使用 LittleSkin"
|
||||
},
|
||||
"minecraft-account.third-party-modal.password": {
|
||||
"message": "密码"
|
||||
},
|
||||
|
||||
@@ -156,34 +156,23 @@
|
||||
{{ formatMessage(commonMessages.repairButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="playing === true" color="red" size="large">
|
||||
<button :disabled="stopping" @click="stopInstance('InstancePage')">
|
||||
<StopCircleIcon />
|
||||
{{
|
||||
stopping
|
||||
? formatMessage(messages.stopping)
|
||||
: formatMessage(commonMessages.stopButton)
|
||||
}}
|
||||
<ButtonStyled v-else-if="loading" color="brand" size="large">
|
||||
<button disabled>
|
||||
<SpinnerIcon class="animate-spin" />
|
||||
{{ formatMessage(messages.starting, { seconds: launchElapsedSeconds }) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-else-if="playing === false && loading === false && !isServerInstance"
|
||||
color="brand"
|
||||
size="large"
|
||||
>
|
||||
<ButtonStyled v-else-if="!isServerInstance" color="brand" size="large">
|
||||
<button @click="startInstance('InstancePage')">
|
||||
<PlayIcon />
|
||||
{{ formatMessage(commonMessages.playButton) }}
|
||||
{{ formatMessage(playing ? messages.launchAnother : commonMessages.playButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div
|
||||
v-else-if="playing === false && loading === false && isServerInstance"
|
||||
class="joined-buttons"
|
||||
>
|
||||
<div v-else class="joined-buttons">
|
||||
<ButtonStyled color="brand" size="large">
|
||||
<button @click="handlePlayServer()">
|
||||
<PlayIcon />
|
||||
{{ formatMessage(commonMessages.playButton) }}
|
||||
{{ formatMessage(playing ? messages.launchAnother : commonMessages.playButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand" size="large">
|
||||
@@ -214,14 +203,10 @@
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<ButtonStyled
|
||||
v-else-if="loading === true && playing === false"
|
||||
color="brand"
|
||||
size="large"
|
||||
>
|
||||
<button disabled>
|
||||
<SpinnerIcon class="animate-spin" />
|
||||
{{ formatMessage(messages.starting, { seconds: launchElapsedSeconds }) }}
|
||||
<ButtonStyled v-if="playing" color="red" size="large">
|
||||
<button :disabled="stopping || loading" @click="stopInstance('InstancePage')">
|
||||
<StopCircleIcon />
|
||||
{{ formatMessage(stopping ? messages.stopping : messages.stopAll) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular size="large">
|
||||
@@ -317,7 +302,7 @@
|
||||
</div>
|
||||
<ContextMenu ref="options" @option-clicked="handleOptionsClick">
|
||||
<template #play> <PlayIcon /> {{ formatMessage(commonMessages.playButton) }} </template>
|
||||
<template #stop> <StopCircleIcon /> {{ formatMessage(commonMessages.stopButton) }} </template>
|
||||
<template #stop> <StopCircleIcon /> {{ formatMessage(messages.stopAll) }} </template>
|
||||
<template #add_content> <PlusIcon /> {{ formatMessage(messages.addContent) }} </template>
|
||||
<template #edit> <EditIcon /> {{ formatMessage(commonMessages.editButton) }} </template>
|
||||
<template #copy_path> <ClipboardCopyIcon /> {{ formatMessage(messages.copyPath) }} </template>
|
||||
@@ -437,6 +422,7 @@ import {
|
||||
} from '@/helpers/instance'
|
||||
import { getDisplayInstanceIcon } from '@/helpers/instance-icons'
|
||||
import { get_by_instance_id } from '@/helpers/process'
|
||||
import { isInstanceLaunching } from '@/helpers/instance-launch-state'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { createInstanceShortcut, showInstanceInFolder } from '@/helpers/utils.js'
|
||||
import { refreshWorlds, type ServerStatus } from '@/helpers/worlds'
|
||||
@@ -459,6 +445,8 @@ const props = defineProps<{
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
launchAnother: { id: 'app.instance.launch-another', defaultMessage: 'Launch another window' },
|
||||
stopAll: { id: 'app.instance.stop-all', defaultMessage: 'Stop all windows of this instance' },
|
||||
neverPlayed: { id: 'app.instance.never-played', defaultMessage: 'Never played' },
|
||||
upgradedTo: {
|
||||
id: 'app.instance.post-upgrade-status',
|
||||
@@ -520,7 +508,10 @@ const postUpgradeNoticeQuery = usePostUpgradeNotice(() => instance.value?.id ??
|
||||
const postUpgradeNotice = computed(() => postUpgradeNoticeQuery.data.value ?? null)
|
||||
const symlinkWarning = useSymlinkWarningDismiss(instanceId)
|
||||
const playing = ref(false)
|
||||
const loading = ref(false)
|
||||
const serverLaunchingId = ref<string>()
|
||||
const loading = computed(
|
||||
() => isInstanceLaunching(props.id) || serverLaunchingId.value === props.id,
|
||||
)
|
||||
const launchElapsedSeconds = ref(0)
|
||||
const subpagePending = ref(false)
|
||||
const stopping = ref(false)
|
||||
@@ -655,9 +646,10 @@ function fetchDeferredData(instanceId?: string) {
|
||||
}
|
||||
|
||||
async function updatePlayState() {
|
||||
const runningProcesses = await get_by_instance_id(props.id).catch(handleError)
|
||||
|
||||
playing.value = Array.isArray(runningProcesses) && runningProcesses.length > 0
|
||||
const id = props.id
|
||||
const runningProcesses = await get_by_instance_id(id).catch(handleError)
|
||||
if (props.id === id && Array.isArray(runningProcesses))
|
||||
playing.value = runningProcesses.length > 0
|
||||
}
|
||||
|
||||
await fetchInstance()
|
||||
@@ -665,6 +657,7 @@ watch(
|
||||
() => props.id,
|
||||
async () => {
|
||||
if (route.path.startsWith('/instance')) {
|
||||
playing.value = false
|
||||
await fetchInstance()
|
||||
}
|
||||
},
|
||||
@@ -741,19 +734,16 @@ watch(
|
||||
const options = ref<InstanceType<typeof ContextMenu> | null>(null)
|
||||
|
||||
const startInstance = async (context: string) => {
|
||||
if (!instance.value) return
|
||||
if (!offline.value && updateToPlayModal.value?.hasUpdate) {
|
||||
if (!instance.value || loading.value) return
|
||||
const id = props.id
|
||||
const name = instance.value.name
|
||||
if (!playing.value && !offline.value && updateToPlayModal.value?.hasUpdate) {
|
||||
updateToPlayModal.value.show(instance.value)
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
launchElapsedSeconds.value = 0
|
||||
launchElapsedTimer = setInterval(() => {
|
||||
launchElapsedSeconds.value += 1
|
||||
}, 1000)
|
||||
try {
|
||||
const result = await run(props.id)
|
||||
const result = await run(id)
|
||||
const gcNotice = result.gc_notice
|
||||
if (gcNotice && gcReportFellBack(gcNotice)) {
|
||||
addNotification({
|
||||
@@ -764,39 +754,50 @@ const startInstance = async (context: string) => {
|
||||
}),
|
||||
})
|
||||
}
|
||||
playing.value = true
|
||||
await updatePlayState()
|
||||
} catch (err) {
|
||||
const handled = await handleMinecraftLaunchError(err, {
|
||||
instance_id: props.id,
|
||||
instance_name: instance.value.name,
|
||||
instance_id: id,
|
||||
instance_name: name,
|
||||
})
|
||||
if (!handled) {
|
||||
handleSevereError(err, { instanceId: props.id })
|
||||
handleSevereError(err, { instanceId: id })
|
||||
}
|
||||
} finally {
|
||||
clearInterval(launchElapsedTimer)
|
||||
launchElapsedTimer = undefined
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
[loading, () => props.id],
|
||||
([busy]) => {
|
||||
clearInterval(launchElapsedTimer)
|
||||
launchElapsedSeconds.value = 0
|
||||
launchElapsedTimer = busy
|
||||
? setInterval(() => {
|
||||
launchElapsedSeconds.value += 1
|
||||
}, 1000)
|
||||
: undefined
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const stopInstance = async (context: string) => {
|
||||
stopping.value = true
|
||||
await kill(props.id).catch(handleError)
|
||||
stopping.value = false
|
||||
playing.value = false
|
||||
await updatePlayState()
|
||||
|
||||
if (!instance.value) return
|
||||
}
|
||||
|
||||
const handlePlayServer = async () => {
|
||||
if (!instance.value?.link?.project_id) return
|
||||
loading.value = true
|
||||
if (!instance.value?.link?.project_id || loading.value) return
|
||||
const id = props.id
|
||||
serverLaunchingId.value = id
|
||||
try {
|
||||
await playServerProject(instance.value.link.project_id)
|
||||
} finally {
|
||||
await updatePlayState()
|
||||
loading.value = false
|
||||
if (serverLaunchingId.value === id) serverLaunchingId.value = undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -933,8 +934,8 @@ const unlistenInstances = await instance_listener(
|
||||
)
|
||||
|
||||
const unlistenProcesses = await process_listener((e: { event: string; instance_id: string }) => {
|
||||
if (e.event === 'finished' && e.instance_id === props.id) {
|
||||
playing.value = false
|
||||
if (e.instance_id === props.id) {
|
||||
void updatePlayState()
|
||||
void queryClient.invalidateQueries({ queryKey: postUpgradeNoticeQueryKey(props.id) })
|
||||
}
|
||||
})
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "theseus_gui"
|
||||
# The actual version is set by the theseus-build workflow on tagging
|
||||
version = "1.9.6-beta.3"
|
||||
version = "1.0.0-beta12"
|
||||
edition.workspace = true
|
||||
description = "Axolotl Launcher is a cross-platform Minecraft launcher"
|
||||
homepage = "https://www.ghs.red"
|
||||
|
||||
@@ -66,9 +66,7 @@ pub async fn hosted_default() -> Result<theseus::pack::hosted::Publication> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn hosted_create(
|
||||
game_dir_root: Option<String>,
|
||||
) -> Result<String> {
|
||||
pub async fn hosted_create(game_dir_root: Option<String>) -> Result<String> {
|
||||
Ok(theseus::pack::hosted::create(game_dir_root).await?)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,14 +3,14 @@ use futures::StreamExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tauri::http::header::ACCEPT;
|
||||
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, Update, UpdaterExt};
|
||||
use theseus::{
|
||||
emit_loading, init_loading, launcher_user_agent, LoadingBarType,
|
||||
LoadingBarType, emit_loading, init_loading, launcher_user_agent,
|
||||
};
|
||||
use tokio::time::Instant;
|
||||
use url::Url;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "theseus"
|
||||
# The actual version is set by the theseus-build workflow on tagging
|
||||
version = "1.9.6-beta.3"
|
||||
version = "1.0.0-beta12"
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
|
||||
File mode changed.
@@ -810,11 +810,85 @@ pub async fn prepare_launch(
|
||||
"StarLight 实例必须登录并联网检查更新后才能启动;离线游玩请使用本地实例",
|
||||
));
|
||||
}
|
||||
synchronize_locked(instance_id).await?;
|
||||
if crate::process::get_by_instance_id(instance_id)
|
||||
.await?
|
||||
.is_empty()
|
||||
{
|
||||
synchronize_locked(instance_id).await?;
|
||||
} else {
|
||||
check_running_pack(instance_id).await?;
|
||||
}
|
||||
}
|
||||
Ok(guard)
|
||||
}
|
||||
|
||||
async fn check_running_pack(instance_id: &str) -> crate::Result<()> {
|
||||
let auth = authorization().await?;
|
||||
let root = crate::instance::get_full_path(instance_id).await?;
|
||||
let previous: Binding = read_json(&root, BINDING)
|
||||
.await?
|
||||
.ok_or_else(|| invalid("请先关闭该实例的所有游戏窗口,再同步整合包"))?;
|
||||
let remote = request_authorized::<Option<SyncState>>("/sync-state", &auth)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
invalid("管理员尚未指定已发布的默认整合包,请联系服务器管理员")
|
||||
})?;
|
||||
if !marker_matches(&previous, &remote) {
|
||||
return Err(invalid(
|
||||
"整合包有更新,请先关闭该实例的所有游戏窗口,更新完成后再多开",
|
||||
));
|
||||
}
|
||||
let tagged = request_authorized::<tagged::TaggedManifest>(
|
||||
&format!("/tagged-mods/{}", previous.publication.release_id),
|
||||
&auth,
|
||||
)
|
||||
.await?;
|
||||
tagged.validate()?;
|
||||
if !running_pack_matches(&root, &previous, &tagged).await? {
|
||||
return Err(invalid(
|
||||
"整合包 Mod 有变更,请先关闭该实例的所有游戏窗口,更新完成后再多开",
|
||||
));
|
||||
}
|
||||
ensure_session(&auth).await
|
||||
}
|
||||
|
||||
async fn running_pack_matches(
|
||||
root: &Path,
|
||||
previous: &Binding,
|
||||
tagged: &tagged::TaggedManifest,
|
||||
) -> crate::Result<bool> {
|
||||
let mut desired = previous.publication.manifest.files.clone();
|
||||
desired.extend(previous.resolved_external.iter().cloned());
|
||||
let duplicates = tagged::merge(
|
||||
root,
|
||||
&target(root, ".starlight-pack-cache")?,
|
||||
&mut desired,
|
||||
&mut BTreeMap::new(),
|
||||
tagged,
|
||||
)
|
||||
.await?;
|
||||
validate(&desired)?;
|
||||
let inventory = |files: &[PackFile]| {
|
||||
files
|
||||
.iter()
|
||||
.map(|file| (file.path.clone(), file.sha256.clone()))
|
||||
.collect::<BTreeMap<_, _>>()
|
||||
};
|
||||
if !duplicates.is_empty()
|
||||
|| inventory(&desired) != inventory(&previous.files)
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
for file in desired.iter().filter(|file| tagged.includes_content(file)) {
|
||||
if hash(&target(root, &file.path)?).await?.as_deref()
|
||||
!= Some(&file.sha256)
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn synchronize_locked(instance_id: &str) -> crate::Result<SyncResult> {
|
||||
ensure_idle(instance_id).await?;
|
||||
recover(instance_id).await?;
|
||||
@@ -1418,6 +1492,62 @@ async fn synchronize_with_progress(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[tokio::test]
|
||||
async fn running_pack_checks_tag_changes_without_writing_live_files() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
tokio::fs::create_dir(root.path().join("mods"))
|
||||
.await
|
||||
.unwrap();
|
||||
let path = root.path().join("mods/example.starlight.jar");
|
||||
tokio::fs::write(&path, b"installed").await.unwrap();
|
||||
let sha = hash(&path).await.unwrap().unwrap();
|
||||
let file = serde_json::json!({"path":"mods/example.starlight.jar", "sha256":sha, "size":9,
|
||||
"modIds":["example"], "force":true});
|
||||
let previous: Binding = serde_json::from_value(serde_json::json!({
|
||||
"publication":{"packId":"pack", "releaseId":1, "manifest":{
|
||||
"schemaVersion":1, "name":"Pack", "version":"1", "format":"multimc",
|
||||
"runtime":{"gameVersion":"1.21.1", "loader":"vanilla"}, "files":[]
|
||||
}}, "files":[file], "syncMarker":"pack:1"
|
||||
})).unwrap();
|
||||
let manifest = |sha: &str| {
|
||||
serde_json::from_value::<tagged::TaggedManifest>(serde_json::json!({
|
||||
"files":[{"modId":"example", "modIds":["example"], "path":"mods/example.starlight.jar", "sha256":sha, "size":9}],
|
||||
"replaces":[]
|
||||
})).unwrap()
|
||||
};
|
||||
assert!(
|
||||
running_pack_matches(root.path(), &previous, &manifest(&sha))
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
!running_pack_matches(
|
||||
root.path(),
|
||||
&previous,
|
||||
&manifest(&"a".repeat(64))
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
let empty = serde_json::from_value(
|
||||
serde_json::json!({"files":[], "replaces":[]}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!running_pack_matches(root.path(), &previous, &empty)
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(tokio::fs::read(&path).await.unwrap(), b"installed");
|
||||
assert!(!root.path().join(JOURNAL).exists());
|
||||
tokio::fs::write(&path, b"modified").await.unwrap();
|
||||
assert!(
|
||||
!running_pack_matches(root.path(), &previous, &manifest(&sha))
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_marker_requires_an_installed_publication_and_changes_on_default_or_release()
|
||||
{
|
||||
|
||||
@@ -23,6 +23,10 @@ pub(super) struct TaggedManifest {
|
||||
}
|
||||
|
||||
impl TaggedManifest {
|
||||
pub fn includes_content(&self, file: &PackFile) -> bool {
|
||||
self.files.iter().any(|tagged| tagged.sha256 == file.sha256)
|
||||
}
|
||||
|
||||
pub fn contains(&self, path: &str) -> bool {
|
||||
self.files.iter().any(|file| file.path == path)
|
||||
}
|
||||
|
||||
@@ -51,8 +51,7 @@ pub async fn import_generic(
|
||||
|
||||
let info = detect_instance_info(&layout.json_source, overrides).await?;
|
||||
register_instance(instance_id, &layout.name, &info).await?;
|
||||
copy_instance_files(instance_id, &layout, reporter, details, symlink)
|
||||
.await
|
||||
copy_instance_files(instance_id, &layout, reporter, details, symlink).await
|
||||
}
|
||||
|
||||
/// The resolved source layout for a generic import.
|
||||
|
||||
@@ -589,8 +589,8 @@ fn try_extract_version_from_needle(
|
||||
) -> Option<String> {
|
||||
let pos = content.find(needle)?;
|
||||
let after = &content[pos + needle.len()..];
|
||||
let end = after
|
||||
.find(&['"', ',', '\n', '}', ']', '[', ':', ' '] as &[char])?;
|
||||
let end =
|
||||
after.find(&['"', ',', '\n', '}', ']', '[', ':', ' '] as &[char])?;
|
||||
let ver = &after[..end];
|
||||
if let Some(ch) = split_at
|
||||
&& let Some(pos) = ver.rfind(ch)
|
||||
@@ -609,7 +609,11 @@ fn sanitize_loader_version(raw: &str) -> String {
|
||||
// Cut at the first character that cannot appear in a loader version id.
|
||||
let end = trimmed
|
||||
.find(|ch: char| {
|
||||
!(ch.is_ascii_alphanumeric() || ch == '.' || ch == '-' || ch == '_' || ch == '+')
|
||||
!(ch.is_ascii_alphanumeric()
|
||||
|| ch == '.'
|
||||
|| ch == '-'
|
||||
|| ch == '_'
|
||||
|| ch == '+')
|
||||
})
|
||||
.unwrap_or(trimmed.len());
|
||||
trimmed[..end].trim().to_string()
|
||||
|
||||
@@ -1234,10 +1234,13 @@ fn is_other_version_entry(rel: &Path, keep: &Path) -> bool {
|
||||
let mut keep_components = keep.components();
|
||||
|
||||
// Both must start with the literal `versions` component.
|
||||
if rel_components.next().map(|c| c.as_os_str()) != Some("versions".as_ref()) {
|
||||
if rel_components.next().map(|c| c.as_os_str()) != Some("versions".as_ref())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if keep_components.next().map(|c| c.as_os_str()) != Some("versions".as_ref()) {
|
||||
if keep_components.next().map(|c| c.as_os_str())
|
||||
!= Some("versions".as_ref())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1253,7 +1256,9 @@ fn is_other_version_entry(rel: &Path, keep: &Path) -> bool {
|
||||
let rel_is_inside_version_dir = rel_components.next().is_some();
|
||||
|
||||
match (rel_version, keep_version) {
|
||||
(Some(rel_v), Some(keep_v)) if rel_is_inside_version_dir => rel_v != keep_v,
|
||||
(Some(rel_v), Some(keep_v)) if rel_is_inside_version_dir => {
|
||||
rel_v != keep_v
|
||||
}
|
||||
// A file directly under `versions/`, shared metadata: keep it.
|
||||
_ => false,
|
||||
}
|
||||
|
||||
@@ -338,22 +338,6 @@ impl DirectLinkedLaunch {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn natives_cache_key(&self) -> String {
|
||||
let identity = format!(
|
||||
"{}\0{}\0{}",
|
||||
self.dot_minecraft.display(),
|
||||
self.version_id,
|
||||
self.version_json
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| self.dot_minecraft.as_path())
|
||||
.display()
|
||||
);
|
||||
format!(
|
||||
"linked-{}",
|
||||
sha1_smol::Sha1::from(identity.as_bytes()).hexdigest()
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn library_path(
|
||||
&self,
|
||||
library: &LinkedLibrary,
|
||||
|
||||
@@ -2045,17 +2045,6 @@ pub async fn launch_minecraft(
|
||||
|
||||
let env_args = Vec::from(env_args);
|
||||
|
||||
// Check if instance has a running process, and reject running the command if it does
|
||||
// Done late so a quick double call doesn't launch two instances
|
||||
let existing_processes = process::get_by_instance_id(&instance.id).await?;
|
||||
if let Some(process) = existing_processes.first() {
|
||||
return Err(crate::ErrorKind::LauncherError(format!(
|
||||
"Instance {} is already running as process {}",
|
||||
instance.id, process.uuid
|
||||
))
|
||||
.as_error());
|
||||
}
|
||||
|
||||
// Ensure phase (HMCL/PCL parity): before anything reads from the linked
|
||||
// installation, complete its missing libraries, assets, and logging
|
||||
// config in the standard shared locations. Version JSONs, launcher
|
||||
@@ -2076,26 +2065,15 @@ pub async fn launch_minecraft(
|
||||
.await?;
|
||||
}
|
||||
|
||||
let natives_dir = if let Some(direct) = &direct_launch {
|
||||
state
|
||||
.directories
|
||||
.version_natives_dir(&direct.natives_cache_key())
|
||||
} else {
|
||||
state.directories.version_natives_dir(&version_jar)
|
||||
};
|
||||
// Linked native archives can change outside Axolotl, so rebuild only the
|
||||
// Axolotl-owned linked cache on every launch. Never mutate linked folders.
|
||||
if direct_launch.is_some() && natives_dir.exists() {
|
||||
io::remove_dir_all(&natives_dir).await?;
|
||||
}
|
||||
if !natives_dir.exists() {
|
||||
io::create_dir_all(&natives_dir).await?;
|
||||
}
|
||||
let (main_class_keep_alive, main_class_path) =
|
||||
get_resource_file!(env "JAVA_JARS_DIR" / "theseus.jar")?;
|
||||
let natives_dir = main_class_keep_alive.path().join("natives");
|
||||
io::create_dir_all(&natives_dir).await?;
|
||||
if let (Some(direct), Some(libraries)) =
|
||||
(direct_launch.clone(), linked_libraries.clone())
|
||||
{
|
||||
// Linked instances rebuild the Axolotl-owned natives cache on every
|
||||
// launch; the managed restore path below must never touch them.
|
||||
// Each process owns its native DLLs until it exits. Rebuilding a
|
||||
// shared directory fails on Windows while another JVM holds a DLL.
|
||||
let target = natives_dir.clone();
|
||||
let java_arch = java_version.architecture.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
@@ -2109,18 +2087,7 @@ pub async fn launch_minecraft(
|
||||
})
|
||||
.await??;
|
||||
} else if direct_launch.is_none() {
|
||||
if offline_mode {
|
||||
natives::prepare_native_libraries(
|
||||
&state.directories.natives_dir(),
|
||||
&state.directories.libraries_dir(),
|
||||
&state.directories.caches_dir(),
|
||||
version_info.libraries.as_slice(),
|
||||
&version_jar,
|
||||
&java_version.architecture,
|
||||
minecraft_updated,
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
if !offline_mode {
|
||||
download::download_libraries(
|
||||
&state,
|
||||
None,
|
||||
@@ -2135,6 +2102,16 @@ pub async fn launch_minecraft(
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
natives::prepare_native_libraries(
|
||||
main_class_keep_alive.path(),
|
||||
&state.directories.libraries_dir(),
|
||||
&state.directories.caches_dir(),
|
||||
version_info.libraries.as_slice(),
|
||||
"natives",
|
||||
&java_version.architecture,
|
||||
minecraft_updated,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
@@ -2172,8 +2149,6 @@ pub async fn launch_minecraft(
|
||||
address.resolve().await?;
|
||||
}
|
||||
|
||||
let (main_class_keep_alive, main_class_path) =
|
||||
get_resource_file!(env "JAVA_JARS_DIR" / "theseus.jar")?;
|
||||
let yggdrasil_agent = if credentials.is_yggdrasil() {
|
||||
let account = credentials.yggdrasil.as_ref().ok_or_else(|| {
|
||||
crate::ErrorKind::LauncherError(
|
||||
@@ -2323,6 +2298,7 @@ pub async fn launch_minecraft(
|
||||
let options_existed = options_path.exists();
|
||||
|
||||
if direct_launch.is_none()
|
||||
&& process::get_by_instance_id(&instance.id).await?.is_empty()
|
||||
&& (!mc_set_options.is_empty()
|
||||
|| offline_skin_pack.enabled_pack_id.is_some()
|
||||
|| options_existed
|
||||
|
||||
@@ -486,6 +486,47 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_launch_native_directories_do_not_replace_each_other() {
|
||||
let archives = tempfile::tempdir().unwrap();
|
||||
let first = tempfile::tempdir().unwrap();
|
||||
let second = tempfile::tempdir().unwrap();
|
||||
let source = archives.path().join("natives.jar");
|
||||
write_archive(&source, &[("lwjgl.dll", b"first runtime")]);
|
||||
materialize_native_directory(
|
||||
&[archive(source.clone())],
|
||||
first.path(),
|
||||
"natives",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let first_dll = first.path().join("natives/lwjgl.dll");
|
||||
#[cfg(windows)]
|
||||
let _running_dll = {
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.share_mode(1)
|
||||
.open(&first_dll)
|
||||
.unwrap()
|
||||
};
|
||||
write_archive(&source, &[("lwjgl.dll", b"second runtime")]);
|
||||
materialize_native_directory(
|
||||
&[archive(source)],
|
||||
second.path(),
|
||||
"natives",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(std::fs::read(&first_dll).unwrap(), b"first runtime");
|
||||
assert_eq!(
|
||||
std::fs::read(second.path().join("natives/lwjgl.dll")).unwrap(),
|
||||
b"second runtime"
|
||||
);
|
||||
drop(second);
|
||||
assert!(first_dll.exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repairs_missing_truncated_same_size_and_directory_entries() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -310,13 +310,20 @@ impl ProcessManager {
|
||||
|
||||
let log_path = logs_folder.join(LAUNCHER_LOG_PATH);
|
||||
|
||||
clear_log_buffer(instance_id);
|
||||
let already_running = self
|
||||
.get_all()
|
||||
.iter()
|
||||
.any(|process| process.instance_id == instance_id);
|
||||
if !already_running {
|
||||
clear_log_buffer(instance_id);
|
||||
}
|
||||
|
||||
{
|
||||
let mut log_file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.truncate(!already_running)
|
||||
.append(already_running)
|
||||
.open(&log_path)
|
||||
.map_err(|e| IOError::with_path(e, &log_path))?;
|
||||
|
||||
@@ -490,8 +497,8 @@ impl ProcessManager {
|
||||
}
|
||||
|
||||
pub async fn wait_for(&self, id: Uuid) -> crate::Result<()> {
|
||||
if let Some(mut process) = self.processes.get_mut(&id) {
|
||||
process.child.wait().await?;
|
||||
while matches!(self.try_wait(id)?, Some(None)) {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -499,7 +506,7 @@ impl ProcessManager {
|
||||
pub async fn kill(&self, id: Uuid) -> crate::Result<()> {
|
||||
if let Some(mut process) = self.processes.get_mut(&id) {
|
||||
process.manually_killed = true;
|
||||
if let Err(error) = process.child.kill().await {
|
||||
if let Err(error) = process.child.start_kill() {
|
||||
process.manually_killed = false;
|
||||
return Err(error.into());
|
||||
}
|
||||
@@ -631,7 +638,12 @@ impl Process {
|
||||
let mut chunk = [0u8; 8192];
|
||||
|
||||
loop {
|
||||
let read = match tokio::io::AsyncReadExt::read(&mut buf_reader, &mut chunk).await {
|
||||
let read = match tokio::io::AsyncReadExt::read(
|
||||
&mut buf_reader,
|
||||
&mut chunk,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(0) => break,
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
@@ -772,12 +784,12 @@ impl Process {
|
||||
}
|
||||
b"log4j:Throwable" => {
|
||||
in_throwable = false;
|
||||
current_event.throwable =
|
||||
if current_content.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(current_content.clone())
|
||||
};
|
||||
current_event.throwable = if current_content.is_empty()
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(current_content.clone())
|
||||
};
|
||||
}
|
||||
b"log4j:Event" => {
|
||||
if let Some(formatted) =
|
||||
@@ -792,8 +804,7 @@ impl Process {
|
||||
}
|
||||
if let Some(ref throwable) = current_event.throwable
|
||||
&& let Err(e) = Self::append_to_log_file(
|
||||
log_path,
|
||||
throwable,
|
||||
log_path, throwable,
|
||||
)
|
||||
{
|
||||
tracing::error!(
|
||||
@@ -1176,7 +1187,15 @@ impl Process {
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::api::multiplayer::minecraft_process_finished(&instance_id).await;
|
||||
if !state
|
||||
.process_manager
|
||||
.get_all()
|
||||
.iter()
|
||||
.any(|process| process.instance_id == instance_id)
|
||||
{
|
||||
crate::api::multiplayer::minecraft_process_finished(&instance_id)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Now fully complete- update playtime one last time
|
||||
update_playtime(&mut last_updated_playtime, &instance_id, true).await;
|
||||
@@ -1293,6 +1312,58 @@ fn take_next_log4j_frame(buffer: &mut String) -> Option<String> {
|
||||
mod post_upgrade_tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(windows)]
|
||||
#[tokio::test]
|
||||
async fn waiting_for_one_process_leaves_registry_available() {
|
||||
let manager = ProcessManager::new();
|
||||
let mut child = Command::new("powershell.exe");
|
||||
child
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
"Start-Sleep -Seconds 30",
|
||||
])
|
||||
.creation_flags(0x08000000)
|
||||
.kill_on_drop(true);
|
||||
let child = child.spawn().unwrap();
|
||||
let uuid = Uuid::new_v4();
|
||||
let rpc = crate::util::rpc::RpcServerBuilder::new()
|
||||
.launch()
|
||||
.await
|
||||
.unwrap();
|
||||
manager.processes.insert(
|
||||
uuid,
|
||||
Process {
|
||||
metadata: ProcessMetadata {
|
||||
uuid,
|
||||
pid: child.id().unwrap(),
|
||||
maximize_window: false,
|
||||
instance_id: "first".into(),
|
||||
instance_path: "first".into(),
|
||||
instance_name: "First".into(),
|
||||
start_time: Utc::now(),
|
||||
},
|
||||
child,
|
||||
manually_killed: false,
|
||||
output_tasks: Vec::new(),
|
||||
_main_class_keep_alive: tempfile::tempdir().unwrap(),
|
||||
rpc_server: rpc,
|
||||
},
|
||||
);
|
||||
let waiting = manager.wait_for(uuid);
|
||||
tokio::pin!(waiting);
|
||||
assert!(futures::poll!(&mut waiting).is_pending());
|
||||
assert!(!manager.processes.try_get(&uuid).is_locked());
|
||||
assert_eq!(manager.get_all().len(), 1);
|
||||
manager.kill(uuid).await.unwrap();
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), waiting)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(manager.was_manually_killed(uuid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_log_lines_are_truncated_on_character_boundaries() {
|
||||
let line = format!("prefix{}suffix", "你".repeat(40_000));
|
||||
|
||||
@@ -38,4 +38,5 @@ export function canToggleContentItem(item: ContentItem): boolean {
|
||||
export interface ContentFilterOption {
|
||||
id: string
|
||||
label: string
|
||||
tooltip?: string
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { registerHooks } from 'node:module'
|
||||
import test, { type TestContext } from 'node:test'
|
||||
|
||||
import { effectScope, nextTick, ref } from 'vue'
|
||||
|
||||
import type { ContentItem } from '../types.ts'
|
||||
|
||||
// Keep the real Vue state, search, and filter pipeline; replace only the UI's
|
||||
// translation provider, which normally requires an application context.
|
||||
const hooks = registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (specifier === '#ui/composables/i18n' || specifier === '../composables/i18n') {
|
||||
return {
|
||||
url: `data:text/javascript,${encodeURIComponent('export const defineMessage=x=>x, defineMessages=x=>x, useVIntl=()=>({formatMessage:x=>x.defaultMessage})')}`,
|
||||
shortCircuit: true,
|
||||
}
|
||||
}
|
||||
if (specifier.startsWith('#ui/')) {
|
||||
return nextResolve(
|
||||
new URL(`../../../../${specifier.slice(4)}.ts`, import.meta.url).href,
|
||||
context,
|
||||
)
|
||||
}
|
||||
if (specifier.startsWith('./') && !specifier.endsWith('.ts')) {
|
||||
return nextResolve(`${specifier}.ts`, context)
|
||||
}
|
||||
return nextResolve(specifier, context)
|
||||
},
|
||||
})
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window')
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: {} })
|
||||
const { useContentPipeline } = await import('./content-pipeline.ts')
|
||||
hooks.deregister()
|
||||
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow)
|
||||
else Reflect.deleteProperty(globalThis, 'window')
|
||||
|
||||
function item(id: string, overrides: Partial<ContentItem> = {}): ContentItem {
|
||||
return {
|
||||
id,
|
||||
project: { id, title: id, slug: id, icon_url: null },
|
||||
file_name: `${id}.jar`,
|
||||
project_type: 'mod',
|
||||
update: null,
|
||||
origin_provider: null,
|
||||
provider_refs: [],
|
||||
enabled: true,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function fixture(t: TestContext) {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] })
|
||||
const update = {
|
||||
provider: 'modrinth' as const,
|
||||
project_id: 'project',
|
||||
current_version_id: 'v1',
|
||||
target_version_id: 'v2',
|
||||
}
|
||||
const items = ref([
|
||||
item('ordinary'),
|
||||
item('update', { update }),
|
||||
item('warning', { environment: 'client_only' }),
|
||||
item('both', { update, pack_client_depends: true }),
|
||||
item('disabled', { enabled: false }),
|
||||
])
|
||||
const scope = effectScope()
|
||||
t.after(() => scope.stop())
|
||||
const pipeline = scope.run(() =>
|
||||
useContentPipeline({
|
||||
items,
|
||||
duplicateItems: ref([]),
|
||||
sortItems: (items) => items,
|
||||
getItemId: (item) => item.id,
|
||||
showUpdateFilter: true,
|
||||
showWarningsFilter: true,
|
||||
}),
|
||||
)!
|
||||
return {
|
||||
...pipeline,
|
||||
ids: () => pipeline.filteredItems.value.map((item) => item.id),
|
||||
async flush() {
|
||||
await nextTick()
|
||||
t.mock.timers.tick(100)
|
||||
await nextTick()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test('status filters update the visible list on every click without reloading items', async (t) => {
|
||||
const p = fixture(t)
|
||||
await p.flush()
|
||||
assert.equal(p.ids().length, 5)
|
||||
p.toggleStatusFilter('updates')
|
||||
await p.flush()
|
||||
assert.deepEqual(p.ids(), ['update', 'both'])
|
||||
p.toggleStatusFilter('warnings')
|
||||
await p.flush()
|
||||
assert.deepEqual(p.ids(), ['both'])
|
||||
p.toggleStatusFilter('updates')
|
||||
await p.flush()
|
||||
assert.deepEqual(p.ids(), ['warning', 'both'])
|
||||
p.toggleStatusFilter('warnings')
|
||||
await p.flush()
|
||||
assert.equal(p.ids().length, 5)
|
||||
})
|
||||
|
||||
test('All clears status filters and restores the visible list without a refresh', async (t) => {
|
||||
const p = fixture(t)
|
||||
await p.flush()
|
||||
p.toggleStatusFilter('warnings')
|
||||
await p.flush()
|
||||
assert.deepEqual(p.ids(), ['warning', 'both'])
|
||||
p.selectedStatusFilters.value = []
|
||||
await p.flush()
|
||||
assert.equal(p.ids().length, 5)
|
||||
})
|
||||
|
||||
test('enabled and disabled remain exclusive and can each be deselected', async (t) => {
|
||||
const p = fixture(t)
|
||||
await p.flush()
|
||||
p.toggleStatusFilter('enabled')
|
||||
await p.flush()
|
||||
assert.deepEqual(p.ids(), ['ordinary', 'update', 'warning', 'both'])
|
||||
p.toggleStatusFilter('enabled')
|
||||
await p.flush()
|
||||
assert.equal(p.ids().length, 5)
|
||||
p.toggleStatusFilter('enabled')
|
||||
p.toggleStatusFilter('disabled')
|
||||
await p.flush()
|
||||
assert.deepEqual(p.selectedStatusFilters.value, ['disabled'])
|
||||
assert.deepEqual(p.ids(), ['disabled'])
|
||||
p.toggleStatusFilter('disabled')
|
||||
await p.flush()
|
||||
assert.equal(p.ids().length, 5)
|
||||
})
|
||||
@@ -62,9 +62,14 @@ const filterMessages = defineMessages({
|
||||
id: 'content.filter.updates',
|
||||
defaultMessage: 'Update available',
|
||||
},
|
||||
warnings: {
|
||||
id: 'content.filter.warnings',
|
||||
defaultMessage: 'Warnings',
|
||||
clientOnly: {
|
||||
id: 'content.filter.client-only',
|
||||
defaultMessage: 'Client only',
|
||||
},
|
||||
clientOnlyDescription: {
|
||||
id: 'content.filter.client-only-description',
|
||||
defaultMessage:
|
||||
'Includes client-only or singleplayer-only mods and mods that depend on them. These may be incompatible with dedicated servers.',
|
||||
},
|
||||
duplicates: {
|
||||
id: 'content.filter.duplicates',
|
||||
@@ -329,7 +334,11 @@ export function useContentPipeline(config: ContentPipelineConfig) {
|
||||
row2.push({ id: 'updates', label: formatMessage(filterMessages.updates) })
|
||||
}
|
||||
if (showWarningsFilter && typeFiltered.some((m) => getClientWarningType(m) !== null)) {
|
||||
row2.push({ id: 'warnings', label: formatMessage(filterMessages.warnings) })
|
||||
row2.push({
|
||||
id: 'warnings',
|
||||
label: formatMessage(filterMessages.clientOnly),
|
||||
tooltip: formatMessage(filterMessages.clientOnlyDescription),
|
||||
})
|
||||
}
|
||||
|
||||
if (hasEnabled && hasDisabled) {
|
||||
@@ -472,26 +481,20 @@ export function useContentPipeline(config: ContentPipelineConfig) {
|
||||
}
|
||||
|
||||
function toggleStatusFilter(filterId: string) {
|
||||
if (filterId === 'enabled' || filterId === 'disabled') {
|
||||
const index = selectedStatusFilters.value.indexOf(filterId)
|
||||
const otherStatusFilter = filterId === 'enabled' ? 'disabled' : 'enabled'
|
||||
if (index === -1) {
|
||||
selectedStatusFilters.value = [
|
||||
...selectedStatusFilters.value.filter((f) => f !== otherStatusFilter),
|
||||
filterId,
|
||||
]
|
||||
} else {
|
||||
selectedStatusFilters.value.splice(index, 1)
|
||||
}
|
||||
const filters = selectedStatusFilters.value
|
||||
// Replace the array so both the pipeline and saved-filter watchers are notified.
|
||||
if (filters.includes(filterId)) {
|
||||
selectedStatusFilters.value = filters.filter((id) => id !== filterId)
|
||||
return
|
||||
}
|
||||
|
||||
const index = selectedStatusFilters.value.indexOf(filterId)
|
||||
if (index === -1) {
|
||||
selectedStatusFilters.value.push(filterId)
|
||||
} else {
|
||||
selectedStatusFilters.value.splice(index, 1)
|
||||
if (filterId === 'enabled' || filterId === 'disabled') {
|
||||
const otherStatusFilter = filterId === 'enabled' ? 'disabled' : 'enabled'
|
||||
selectedStatusFilters.value = [...filters.filter((id) => id !== otherStatusFilter), filterId]
|
||||
return
|
||||
}
|
||||
|
||||
selectedStatusFilters.value = [...filters, filterId]
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1110,6 +1110,7 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
|
||||
<button
|
||||
v-for="option in row2FilterOptions"
|
||||
:key="option.id"
|
||||
v-tooltip="option.tooltip"
|
||||
class="relative pb-1 text-base font-semibold transition-colors"
|
||||
:class="
|
||||
selectedStatusFilters.includes(option.id)
|
||||
|
||||
@@ -872,6 +872,12 @@
|
||||
"content.filter.updates": {
|
||||
"defaultMessage": "Update available"
|
||||
},
|
||||
"content.filter.client-only": {
|
||||
"defaultMessage": "Client only"
|
||||
},
|
||||
"content.filter.client-only-description": {
|
||||
"defaultMessage": "Includes client-only or singleplayer-only mods and mods that depend on them. These may be incompatible with dedicated servers."
|
||||
},
|
||||
"content.filter.warnings": {
|
||||
"defaultMessage": "Warnings"
|
||||
},
|
||||
|
||||
@@ -848,6 +848,12 @@
|
||||
"content.filter.updates": {
|
||||
"defaultMessage": "可更新"
|
||||
},
|
||||
"content.filter.client-only": {
|
||||
"defaultMessage": "仅客户端"
|
||||
},
|
||||
"content.filter.client-only-description": {
|
||||
"defaultMessage": "包含仅供客户端或单人游戏使用的模组,以及依赖它们的模组。这些模组可能不兼容独立服务器。"
|
||||
},
|
||||
"content.filter.warnings": {
|
||||
"defaultMessage": "警告"
|
||||
},
|
||||
|
||||
@@ -698,6 +698,12 @@
|
||||
"content.filter.updates": {
|
||||
"defaultMessage": "更新"
|
||||
},
|
||||
"content.filter.client-only": {
|
||||
"defaultMessage": "僅用戶端"
|
||||
},
|
||||
"content.filter.client-only-description": {
|
||||
"defaultMessage": "包含僅供用戶端或單人遊戲使用的模組,以及依賴它們的模組。這些模組可能不相容於獨立伺服器。"
|
||||
},
|
||||
"content.filter.warnings": {
|
||||
"defaultMessage": "警告"
|
||||
},
|
||||
|
||||
Generated
+392
-383
File diff suppressed because it is too large.
Load diff
@@ -2,11 +2,11 @@ import { execFileSync } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
const migrationsDirectory = 'packages/app-lib/migrations'
|
||||
const repository = process.env.GITHUB_REPOSITORY ?? 'Mystic-Stars/Axolotl'
|
||||
// v1.7.5 is the canonical snapshot immediately before the v1.7.6 incident.
|
||||
const repository = process.env.GITHUB_REPOSITORY ?? 'ApTx2012/Starlight_Lancher'
|
||||
// Starlight's first release anchors the immutable migration history in this fork.
|
||||
const canonicalBootstrap = {
|
||||
ref: '7ddbfb8e57db4b0044a04cf28f25fb29e08c3279',
|
||||
tag: 'v1.7.5',
|
||||
ref: '71a8e65085c60eeb4154dd3fc39c0d84ed6fd18c',
|
||||
tag: 'v1.0.0-beta1',
|
||||
}
|
||||
// Published divergences remain evidence of an incident and never replace the canonical bytes.
|
||||
const knownPublishedDivergences = new Set([
|
||||
|
||||
@@ -62,7 +62,11 @@ try {
|
||||
const verify = spawnSync(
|
||||
process.execPath,
|
||||
['scripts/axolotl/verify-update-manifest.mjs', outputPath, tag],
|
||||
{ cwd: root, encoding: 'utf8' },
|
||||
{
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, GITHUB_REPOSITORY: 'Mystic-Stars/Axolotl' },
|
||||
},
|
||||
)
|
||||
assert.equal(verify.status, 0, verify.stderr)
|
||||
} finally {
|
||||
|
||||
@@ -17,6 +17,7 @@ const allowedUntranslatedMessages = new Set([
|
||||
'.minecraft',
|
||||
'Striding Hero',
|
||||
'Axolotl Launcher',
|
||||
'Starlight Launcher',
|
||||
'Explore high-quality Minecraft content on Modrinth.',
|
||||
'example.modrinth.gg',
|
||||
'{title} - {count}',
|
||||
|
||||
@@ -2,6 +2,8 @@ import fs from 'node:fs'
|
||||
|
||||
const [manifestPath, tag, source = 'github'] = process.argv.slice(2)
|
||||
const expectedVersion = tag?.replace(/^v/, '')
|
||||
const githubRepository = process.env.GITHUB_REPOSITORY || 'ApTx2012/Starlight_Lancher'
|
||||
const cnbRepository = process.env.CNB_REPO_SLUG || 'axlmc/Axolotl'
|
||||
|
||||
if (!manifestPath || !expectedVersion || !['github', 'cnb'].includes(source)) {
|
||||
throw new Error('Usage: node verify-update-manifest.mjs <latest.json> <version-tag> [github|cnb]')
|
||||
@@ -34,9 +36,13 @@ for (const platform of requiredPlatforms) {
|
||||
url.protocol === 'https:' &&
|
||||
(source === 'github'
|
||||
? url.hostname === 'github.com' &&
|
||||
pathname.includes('/mystic-stars/axolotl/releases/download/')
|
||||
pathname.startsWith(
|
||||
`/${githubRepository.toLowerCase()}/releases/download/${tag.toLowerCase()}/`,
|
||||
)
|
||||
: url.hostname === 'cnb.cool' &&
|
||||
pathname.includes(`/axlmc/axolotl/-/releases/download/${tag.toLowerCase()}/`))
|
||||
pathname.startsWith(
|
||||
`/${cnbRepository.toLowerCase()}/-/releases/download/${tag.toLowerCase()}/`,
|
||||
))
|
||||
if (!isExpectedUrl) {
|
||||
throw new Error(`Unexpected ${source} update URL for ${platform}: ${update.url}`)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { test } from 'node:test'
|
||||
|
||||
test('update manifests must point to the configured repository and release tag', () => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'starlight-manifest-'))
|
||||
const manifestPath = path.join(directory, 'latest.json')
|
||||
const tag = 'v1.0.0-beta12'
|
||||
const repository = 'ApTx2012/Starlight_Lancher'
|
||||
const platforms = [
|
||||
'darwin-aarch64',
|
||||
'darwin-x86_64',
|
||||
'linux-aarch64',
|
||||
'linux-x86_64',
|
||||
'windows-x86_64',
|
||||
]
|
||||
const verify = (url) => {
|
||||
fs.writeFileSync(
|
||||
manifestPath,
|
||||
JSON.stringify({
|
||||
version: tag.slice(1),
|
||||
platforms: Object.fromEntries(
|
||||
platforms.map((platform) => [
|
||||
platform,
|
||||
{
|
||||
url,
|
||||
signature: 'x'.repeat(64),
|
||||
},
|
||||
]),
|
||||
),
|
||||
apt: Object.fromEntries(
|
||||
['linux-aarch64', 'linux-x86_64'].map((platform) => [
|
||||
platform,
|
||||
{
|
||||
url,
|
||||
sha256: 'a'.repeat(64),
|
||||
size: 123,
|
||||
},
|
||||
]),
|
||||
),
|
||||
}),
|
||||
)
|
||||
return spawnSync(
|
||||
process.execPath,
|
||||
['scripts/axolotl/verify-update-manifest.mjs', manifestPath, tag],
|
||||
{
|
||||
env: { ...process.env, GITHUB_REPOSITORY: repository },
|
||||
encoding: 'utf8',
|
||||
},
|
||||
)
|
||||
}
|
||||
try {
|
||||
const success = verify(
|
||||
`https://github.com/${repository}/releases/download/${tag}/installer.zip`,
|
||||
)
|
||||
assert.equal(success.status, 0, success.stderr)
|
||||
for (const url of [
|
||||
`https://github.com/Mystic-Stars/Axolotl/releases/download/${tag}/installer.zip`,
|
||||
`https://github.com/${repository}/releases/download/v1.0.0-beta1/installer.zip`,
|
||||
`https://example.com/${repository}/releases/download/${tag}/installer.zip`,
|
||||
]) {
|
||||
const result = verify(url)
|
||||
assert.notEqual(result.status, 0)
|
||||
assert.match(result.stderr, /Unexpected github update URL/)
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user