diff --git a/.github/workflows/build-on-tag.yml b/.github/workflows/build-on-tag.yml index 0d190a4..bae9f35 100644 --- a/.github/workflows/build-on-tag.yml +++ b/.github/workflows/build-on-tag.yml @@ -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 }} @@ -272,4 +337,98 @@ jobs: gh release edit "${GITHUB_REF_NAME}" --draft=false --prerelease else gh release edit "${GITHUB_REF_NAME}" --draft=false --latest - fi \ No newline at end of file + 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 diff --git a/.github/workflows/sync-cnb-source.yml b/.github/workflows/sync-cnb-source.yml new file mode 100644 index 0000000..71cf563 --- /dev/null +++ b/.github/workflows/sync-cnb-source.yml @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 8a8a4c1..edfb4d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/apps/app-frontend/package.json b/apps/app-frontend/package.json index ce8bc27..d0a30b2 100644 --- a/apps/app-frontend/package.json +++ b/apps/app-frontend/package.json @@ -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", diff --git a/apps/app-frontend/src/components/RowDisplay.vue b/apps/app-frontend/src/components/RowDisplay.vue index b55fb5c..723204d 100644 --- a/apps/app-frontend/src/components/RowDisplay.vue +++ b/apps/app-frontend/src/components/RowDisplay.vue @@ -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" />
-
+
{{ row.label }} @@ -307,7 +313,7 @@ onUnmounted(() => {
- + diff --git a/apps/app-frontend/src/components/home/HomeMinimal.vue b/apps/app-frontend/src/components/home/HomeMinimal.vue index 865498f..b913783 100644 --- a/apps/app-frontend/src/components/home/HomeMinimal.vue +++ b/apps/app-frontend/src/components/home/HomeMinimal.vue @@ -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(null) +const installingId = ref() 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(() => {
- - - + diff --git a/apps/app-frontend/src/components/ui/AppActionBar.vue b/apps/app-frontend/src/components/ui/AppActionBar.vue index 9b17eef..392b7cc 100644 --- a/apps/app-frontend/src/components/ui/AppActionBar.vue +++ b/apps/app-frontend/src/components/ui/AppActionBar.vue @@ -137,6 +137,11 @@ {{ process.instance.name }} + {{ + formatMessage(messages.windowStarted, { + time: new Date(process.start_time), + }) + }} @@ -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() diff --git a/apps/app-frontend/src/components/ui/Instance.vue b/apps/app-frontend/src/components/ui/Instance.vue index 56b5ff3..893ca10 100644 --- a/apps/app-frontend/src/components/ui/Instance.vue +++ b/apps/app-frontend/src/components/ui/Instance.vue @@ -1,12 +1,5 @@