Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
544611738d | ||
|
|
403a883e0b | ||
|
|
53c749aaea | ||
|
|
addc31c7c1 | ||
|
|
25978bcd8f | ||
|
|
117eaad9ff | ||
|
|
91e2c369f5 | ||
|
|
ae2992d7f8 | ||
|
|
20d5d2baab |
No files matched your search
@@ -22,5 +22,5 @@ jobs:
|
|||||||
node-version-file: .nvmrc
|
node-version-file: .nvmrc
|
||||||
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
||||||
- uses: cargo-bins/cargo-binstall@dc19f1e48450eefe5a29b8da6c6b00a87d730b37 # v1.18.1
|
- uses: cargo-bins/cargo-binstall@dc19f1e48450eefe5a29b8da6c6b00a87d730b37 # v1.18.1
|
||||||
- run: cargo binstall --no-confirm cargo-shear
|
- run: cargo binstall --no-confirm cargo-shear@1.13.4
|
||||||
- run: cargo shear
|
- run: cargo shear
|
||||||
@@ -6841,7 +6841,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "theseus"
|
name = "theseus"
|
||||||
version = "1.0.0-beta15"
|
version = "1.0.2-beta4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ariadne",
|
"ariadne",
|
||||||
"async-minecraft-ping",
|
"async-minecraft-ping",
|
||||||
@@ -6945,7 +6945,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "theseus_gui"
|
name = "theseus_gui"
|
||||||
version = "1.0.0-beta15"
|
version = "1.0.2-beta4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ashpd",
|
"ashpd",
|
||||||
"async_zip",
|
"async_zip",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@modrinth/app-frontend",
|
"name": "@modrinth/app-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0-beta15",
|
"version": "1.0.2-beta4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -32,6 +32,43 @@ export const ANNOUNCEMENT_CHANGE_TYPES: readonly AnnouncementChangeType[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
|
export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
|
||||||
|
{
|
||||||
|
id: 'launcher-1.0.2-beta4',
|
||||||
|
version: '1.0.2-beta4',
|
||||||
|
publishedAt: '2026-10-08',
|
||||||
|
title: {
|
||||||
|
'en-US': 'Starlight Launcher 1.0.2-beta4',
|
||||||
|
'zh-CN': 'Starlight Launcher 1.0.2-beta4',
|
||||||
|
},
|
||||||
|
changes: {
|
||||||
|
added: [
|
||||||
|
{
|
||||||
|
'en-US': 'Added armor and armor-trim rendering to the skin preview, letting you try on armor pieces and trim patterns directly in the skin editor.',
|
||||||
|
'zh-CN': '皮肤预览新增盔甲与盔甲纹饰渲染,可在皮肤编辑器内直接试穿各部位盔甲并调整纹饰图案。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'en-US': 'Armor preview selections in the skin editor are now saved locally, so they persist across sessions.',
|
||||||
|
'zh-CN': '皮肤编辑器中的盔甲预览选择现在会保存在本地,跨会话保留。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
changed: [
|
||||||
|
{
|
||||||
|
'en-US': 'Improved download scheduling to reduce concurrency automatically when tasks are waiting for capacity, making large Minecraft installations more reliable on constrained connections.',
|
||||||
|
'zh-CN': '优化下载任务调度:任务因容量不足排队时会自动降低并发,提升大型 Minecraft 版本在受限网络环境下的安装稳定性。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
fixed: [
|
||||||
|
{
|
||||||
|
'en-US': 'Fixed downloads timing out while waiting for internal connection capacity, which could cause Minecraft installations to fail even when the network was available.',
|
||||||
|
'zh-CN': '修复下载任务等待内部连接容量时过早超时,导致网络正常的情况下 Minecraft 安装仍可能失败的问题。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'en-US': 'Fixed instance group changes not saving for some instances.',
|
||||||
|
'zh-CN': '修复部分实例的分组修改无法保存的问题。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'launcher-1.0.2-beta3',
|
id: 'launcher-1.0.2-beta3',
|
||||||
version: '1.0.2-beta3',
|
version: '1.0.2-beta3',
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { CheckIcon, RotateCounterClockwiseIcon, XIcon } from '@modrinth/assets'
|
||||||
|
import {
|
||||||
|
type ArmorPreviewConfig,
|
||||||
|
ArmorTrimPanel,
|
||||||
|
Button,
|
||||||
|
defineMessages,
|
||||||
|
useVIntl,
|
||||||
|
} from '@modrinth/ui'
|
||||||
|
import { computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
title: { id: 'app.skins.armor.title', defaultMessage: 'Armour and trims' },
|
||||||
|
description: {
|
||||||
|
id: 'app.skins.armor.description',
|
||||||
|
defaultMessage: 'Try armour and trims on your skin. Nothing is stored until you save it.',
|
||||||
|
},
|
||||||
|
unsaved: { id: 'app.skins.armor.unsaved', defaultMessage: 'Unsaved changes' },
|
||||||
|
resetToSaved: { id: 'app.skins.armor.reset-to-saved', defaultMessage: 'Reset to saved' },
|
||||||
|
restoreDefaults: {
|
||||||
|
id: 'app.skins.armor.restore-defaults',
|
||||||
|
defaultMessage: 'Restore defaults',
|
||||||
|
},
|
||||||
|
save: { id: 'app.skins.armor.save', defaultMessage: 'Save' },
|
||||||
|
close: { id: 'app.skins.armor.close', defaultMessage: 'Close' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const draft = defineModel<ArmorPreviewConfig>({ required: true })
|
||||||
|
const props = defineProps<{
|
||||||
|
saved: ArmorPreviewConfig
|
||||||
|
/** Stable id shared with the toggle so it can point `aria-controls` here. */
|
||||||
|
panelId: string
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
save: []
|
||||||
|
reset: []
|
||||||
|
defaults: []
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tab is mounted only while it is showing, so this listens exactly for that
|
||||||
|
* window. It listens in the capture phase because the app's own global
|
||||||
|
* shortcuts listen on `window` the same way, and a bubble-phase listener here
|
||||||
|
* would be skipped as soon as anything closer stopped the event. A modal on top
|
||||||
|
* of the page owns Escape while it is up, and focus sits inside it, so the key
|
||||||
|
* is left to it.
|
||||||
|
*/
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key !== 'Escape') return
|
||||||
|
if (
|
||||||
|
event.target instanceof Element &&
|
||||||
|
event.target.closest('[role="dialog"], [role="alertdialog"], [aria-modal="true"]')
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => window.addEventListener('keydown', handleKeydown, true))
|
||||||
|
onUnmounted(() => window.removeEventListener('keydown', handleKeydown, true))
|
||||||
|
|
||||||
|
const isDirty = computed(() =>
|
||||||
|
Object.entries(draft.value).some(([slot, piece]) => {
|
||||||
|
const applied = props.saved[slot as keyof ArmorPreviewConfig]
|
||||||
|
return (
|
||||||
|
!applied ||
|
||||||
|
applied.material !== piece.material ||
|
||||||
|
applied.trimPattern !== piece.trimPattern ||
|
||||||
|
applied.trimMaterial !== piece.trimMaterial
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:id="panelId"
|
||||||
|
role="region"
|
||||||
|
:aria-label="formatMessage(messages.title)"
|
||||||
|
class="flex min-h-[24rem] flex-col gap-4 rounded-2xl border border-solid border-surface-5 bg-surface-2 p-4"
|
||||||
|
>
|
||||||
|
<header class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h2 class="m-0 text-lg font-bold text-[var(--color-text-primary)]">
|
||||||
|
{{ formatMessage(messages.title) }}
|
||||||
|
</h2>
|
||||||
|
<p class="m-0 mt-1 text-sm leading-6 text-[var(--color-text-tertiary)]">
|
||||||
|
{{ formatMessage(messages.description) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 flex-wrap items-center gap-2">
|
||||||
|
<span v-if="isDirty" class="text-sm font-semibold text-[var(--color-text-tertiary)]">
|
||||||
|
{{ formatMessage(messages.unsaved) }}
|
||||||
|
</span>
|
||||||
|
<Button type="outlined" @click="emit('close')"
|
||||||
|
><XIcon />{{ formatMessage(messages.close) }}</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ArmorTrimPanel v-model="draft" />
|
||||||
|
|
||||||
|
<footer
|
||||||
|
class="mt-auto flex flex-wrap items-center justify-between gap-2 border-0 border-t border-solid border-surface-5 pt-3"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<Button type="outlined" :disabled="!isDirty" @click="emit('reset')"
|
||||||
|
><RotateCounterClockwiseIcon />{{ formatMessage(messages.resetToSaved) }}</Button
|
||||||
|
>
|
||||||
|
<Button type="outlined" @click="emit('defaults')">{{
|
||||||
|
formatMessage(messages.restoreDefaults)
|
||||||
|
}}</Button>
|
||||||
|
</div>
|
||||||
|
<Button type="colored" color="brand" :disabled="!isDirty" @click="emit('save')"
|
||||||
|
><CheckIcon />{{ formatMessage(messages.save) }}</Button
|
||||||
|
>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const { sections = 2, cards = 12 } = defineProps<{
|
||||||
|
sections?: number
|
||||||
|
cards?: number
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!--
|
||||||
|
Shaped after VirtualSkinSectionList: the same grid breakpoints and card
|
||||||
|
ratio, so the swap to real content does not move anything. Decorative, so
|
||||||
|
it stays out of the accessibility tree.
|
||||||
|
-->
|
||||||
|
<div class="flex flex-col gap-8" aria-hidden="true">
|
||||||
|
<div v-for="section in sections" :key="section" class="flex flex-col gap-4">
|
||||||
|
<div class="h-7 w-44 animate-pulse rounded-lg bg-surface-4" />
|
||||||
|
<div
|
||||||
|
class="grid w-full grid-cols-3 gap-3 min-[1300px]:grid-cols-4 min-[1750px]:grid-cols-5 min-[2050px]:grid-cols-6"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="card in cards"
|
||||||
|
:key="card"
|
||||||
|
class="aspect-[31/40] w-full animate-pulse rounded-[20px] bg-surface-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import { loadSkinArmorPreview, saveSkinArmorPreview } from './skin-armor-preview.ts'
|
||||||
|
|
||||||
|
const originalStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage')
|
||||||
|
|
||||||
|
function withStorage(run: (values: Map<string, string>) => void): void {
|
||||||
|
const values = new Map<string, string>()
|
||||||
|
Object.defineProperty(globalThis, 'localStorage', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
getItem: (key: string) => values.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => values.set(key, value),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
run(values)
|
||||||
|
} finally {
|
||||||
|
if (originalStorage) Object.defineProperty(globalThis, 'localStorage', originalStorage)
|
||||||
|
else delete (globalThis as { localStorage?: Storage }).localStorage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('armor preview selections survive storage round trips', () => {
|
||||||
|
withStorage(() => {
|
||||||
|
const config = loadSkinArmorPreview()
|
||||||
|
config.helmet.material = 'diamond'
|
||||||
|
config.helmet.trimPattern = 'sentry'
|
||||||
|
config.helmet.trimMaterial = 'gold'
|
||||||
|
saveSkinArmorPreview(config)
|
||||||
|
|
||||||
|
assert.deepEqual(loadSkinArmorPreview(), config)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('invalid stored armor values fall back per slot and field', () => {
|
||||||
|
withStorage((values) => {
|
||||||
|
values.set(
|
||||||
|
'axolotl:skin-armor-preview',
|
||||||
|
JSON.stringify({
|
||||||
|
helmet: { material: 'diamond', trimPattern: 'invalid', trimMaterial: 'gold' },
|
||||||
|
boots: { material: 'turtle', trimPattern: 'sentry', trimMaterial: 'invalid' },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const config = loadSkinArmorPreview()
|
||||||
|
assert.deepEqual(config.helmet, {
|
||||||
|
material: 'diamond',
|
||||||
|
trimPattern: null,
|
||||||
|
trimMaterial: 'gold',
|
||||||
|
})
|
||||||
|
assert.deepEqual(config.boots, {
|
||||||
|
material: null,
|
||||||
|
trimPattern: 'sentry',
|
||||||
|
trimMaterial: 'iron',
|
||||||
|
})
|
||||||
|
assert.equal(config.chestplate.material, null)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import {
|
||||||
|
ARMOR_SLOTS,
|
||||||
|
ARMOR_TRIM_MATERIALS,
|
||||||
|
ARMOR_TRIM_PATTERNS,
|
||||||
|
armorMaterialsForSlot,
|
||||||
|
type ArmorPreviewConfig,
|
||||||
|
createDefaultArmorPreviewConfig,
|
||||||
|
} from '@modrinth/ui'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'axolotl:skin-armor-preview'
|
||||||
|
|
||||||
|
function includes<T extends string>(values: readonly T[], value: unknown): value is T {
|
||||||
|
return typeof value === 'string' && values.some((item) => item === value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadSkinArmorPreview(): ArmorPreviewConfig {
|
||||||
|
const config = createDefaultArmorPreviewConfig()
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(globalThis.localStorage?.getItem(STORAGE_KEY) ?? 'null')
|
||||||
|
if (!saved || typeof saved !== 'object' || Array.isArray(saved)) return config
|
||||||
|
|
||||||
|
for (const slot of ARMOR_SLOTS) {
|
||||||
|
const piece = saved[slot]
|
||||||
|
if (!piece || typeof piece !== 'object' || Array.isArray(piece)) continue
|
||||||
|
if (piece.material === null || includes(armorMaterialsForSlot(slot), piece.material)) {
|
||||||
|
config[slot].material = piece.material
|
||||||
|
}
|
||||||
|
if (piece.trimPattern === null || includes(ARMOR_TRIM_PATTERNS, piece.trimPattern)) {
|
||||||
|
config[slot].trimPattern = piece.trimPattern
|
||||||
|
}
|
||||||
|
if (includes(ARMOR_TRIM_MATERIALS, piece.trimMaterial)) {
|
||||||
|
config[slot].trimMaterial = piece.trimMaterial
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Invalid or unavailable local storage falls back to a fresh preview.
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSkinArmorPreview(config: ArmorPreviewConfig): void {
|
||||||
|
try {
|
||||||
|
globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(config))
|
||||||
|
} catch {
|
||||||
|
// The preview remains usable when local storage is unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,9 +9,13 @@ import {
|
|||||||
SpinnerIcon,
|
SpinnerIcon,
|
||||||
} from '@modrinth/assets'
|
} from '@modrinth/assets'
|
||||||
import {
|
import {
|
||||||
|
type ArmorPreviewConfig,
|
||||||
|
ArmorPreviewControls,
|
||||||
ButtonStyled,
|
ButtonStyled,
|
||||||
|
cloneArmorPreviewConfig,
|
||||||
commonMessages,
|
commonMessages,
|
||||||
ConfirmModal,
|
ConfirmModal,
|
||||||
|
createDefaultArmorPreviewConfig,
|
||||||
defineMessages,
|
defineMessages,
|
||||||
injectNotificationManager,
|
injectNotificationManager,
|
||||||
NavTabs,
|
NavTabs,
|
||||||
@@ -24,12 +28,14 @@ import { invoke } from '@tauri-apps/api/core'
|
|||||||
import type { DragDropEvent } from '@tauri-apps/api/webview'
|
import type { DragDropEvent } from '@tauri-apps/api/webview'
|
||||||
import { getCurrentWebview } from '@tauri-apps/api/webview'
|
import { getCurrentWebview } from '@tauri-apps/api/webview'
|
||||||
import { computedAsync } from '@vueuse/core'
|
import { computedAsync } from '@vueuse/core'
|
||||||
import type { Ref } from 'vue'
|
import type { ComponentPublicInstance, Ref } from 'vue'
|
||||||
import { computed, inject, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'
|
import { computed, inject, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'
|
||||||
import { onBeforeRouteLeave, useRouter } from 'vue-router'
|
import { onBeforeRouteLeave, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import type AccountsCard from '@/components/ui/AccountsCard.vue'
|
import type AccountsCard from '@/components/ui/AccountsCard.vue'
|
||||||
|
import ArmorTrimTab from '@/components/ui/skin/ArmorTrimTab.vue'
|
||||||
import EditSkinModal from '@/components/ui/skin/EditSkinModal.vue'
|
import EditSkinModal from '@/components/ui/skin/EditSkinModal.vue'
|
||||||
|
import SkinListSkeleton from '@/components/ui/skin/SkinListSkeleton.vue'
|
||||||
import VirtualSkinSectionList from '@/components/ui/skin/VirtualSkinSectionList.vue'
|
import VirtualSkinSectionList from '@/components/ui/skin/VirtualSkinSectionList.vue'
|
||||||
import {
|
import {
|
||||||
openSkinSiteLogin,
|
openSkinSiteLogin,
|
||||||
@@ -45,6 +51,7 @@ import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
|||||||
import { check_reachable, get_default_user, users } from '@/helpers/auth'
|
import { check_reachable, get_default_user, users } from '@/helpers/auth'
|
||||||
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
|
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||||
import { skinBlobUrlMap } from '@/helpers/rendering/batch-skin-renderer.ts'
|
import { skinBlobUrlMap } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||||
|
import { loadSkinArmorPreview, saveSkinArmorPreview } from '@/helpers/skin-armor-preview'
|
||||||
import type { Cape, Skin, SkinTextureUrl } from '@/helpers/skins.ts'
|
import type { Cape, Skin, SkinTextureUrl } from '@/helpers/skins.ts'
|
||||||
import {
|
import {
|
||||||
determineModelType,
|
determineModelType,
|
||||||
@@ -252,6 +259,8 @@ const editSkinModal = useTemplateRef('editSkinModal')
|
|||||||
const addSkinFileInput = useTemplateRef<HTMLInputElement>('addSkinFileInput')
|
const addSkinFileInput = useTemplateRef<HTMLInputElement>('addSkinFileInput')
|
||||||
const skinSectionList = useTemplateRef<VirtualSkinSectionListExpose>('skinSectionList')
|
const skinSectionList = useTemplateRef<VirtualSkinSectionListExpose>('skinSectionList')
|
||||||
const skinPreviewArea = useTemplateRef<HTMLElement>('skinPreviewArea')
|
const skinPreviewArea = useTemplateRef<HTMLElement>('skinPreviewArea')
|
||||||
|
const armorTrimTab = useTemplateRef<ComponentPublicInstance>('armorTrimTab')
|
||||||
|
const armorPreviewControls = useTemplateRef<ComponentPublicInstance>('armorPreviewControls')
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -391,7 +400,8 @@ const isAddSkinButtonDragActive = ref(false)
|
|||||||
|
|
||||||
const deleteSkinModal = ref()
|
const deleteSkinModal = ref()
|
||||||
const skinToDelete = ref<Skin | null>(null)
|
const skinToDelete = ref<Skin | null>(null)
|
||||||
const skinListTab = ref<'saved' | 'default'>('saved')
|
const lastSkinListTab = ref<'saved' | 'default'>('saved')
|
||||||
|
const skinListTab = ref<'saved' | 'default' | 'armor'>('saved')
|
||||||
|
|
||||||
const skinListTabLinks = computed(() => [
|
const skinListTabLinks = computed(() => [
|
||||||
{
|
{
|
||||||
@@ -403,6 +413,70 @@ const skinListTabLinks = computed(() => [
|
|||||||
label: formatMessage(messages.defaultTab),
|
label: formatMessage(messages.defaultTab),
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
const ARMOR_TRIM_PANEL_ID = 'skins-armor-trim-panel'
|
||||||
|
const savedArmorPreview = ref<ArmorPreviewConfig>(loadSkinArmorPreview())
|
||||||
|
const armorPreviewConfig = ref<ArmorPreviewConfig>(cloneArmorPreviewConfig(savedArmorPreview.value))
|
||||||
|
|
||||||
|
function saveArmorPreview() {
|
||||||
|
savedArmorPreview.value = cloneArmorPreviewConfig(armorPreviewConfig.value)
|
||||||
|
saveSkinArmorPreview(savedArmorPreview.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetArmorPreviewToSaved() {
|
||||||
|
armorPreviewConfig.value = cloneArmorPreviewConfig(savedArmorPreview.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreDefaultArmorPreview() {
|
||||||
|
armorPreviewConfig.value = createDefaultArmorPreviewConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A third tab behind the two list tabs, opened from the preview's action row. */
|
||||||
|
function openArmorTab() {
|
||||||
|
if (skinListTab.value !== 'armor') lastSkinListTab.value = skinListTab.value
|
||||||
|
|
||||||
|
skinListTab.value = 'armor'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an element belongs to the armour surface: somewhere inside the open
|
||||||
|
* armour tab or the button that toggles it. Only those hold a focus ring that
|
||||||
|
* would be left behind when the tab closes.
|
||||||
|
*/
|
||||||
|
function isArmorPreviewElement(element: Element) {
|
||||||
|
const tabElement: unknown = armorTrimTab.value?.$el
|
||||||
|
const toggleElement: unknown = armorPreviewControls.value?.$el
|
||||||
|
|
||||||
|
return (
|
||||||
|
(tabElement instanceof Element && tabElement.contains(element)) ||
|
||||||
|
(toggleElement instanceof Element && toggleElement.contains(element))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeArmorTab() {
|
||||||
|
// Leaving the tab drops whatever was not saved on purpose.
|
||||||
|
resetArmorPreviewToSaved()
|
||||||
|
skinListTab.value = lastSkinListTab.value
|
||||||
|
|
||||||
|
// Escape and the toggle both leave the armour button holding focus, which
|
||||||
|
// keeps its focus ring drawn until something else is clicked. Only drop that
|
||||||
|
// ring when the focus sits on the armour surface, never on an unrelated
|
||||||
|
// control that happened to hold it when Escape was pressed.
|
||||||
|
const focused = document.activeElement
|
||||||
|
if (focused instanceof HTMLElement && isArmorPreviewElement(focused)) focused.blur()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tab bar keeps the last list tab highlighted while the armour tab shows, so
|
||||||
|
* picking one of them leaves the armour tab. That transition has to drop the
|
||||||
|
* draft exactly like the toggle and Escape paths do, or it lingers on the model
|
||||||
|
* while the panel is gone.
|
||||||
|
*/
|
||||||
|
function selectSkinListTab(index: number) {
|
||||||
|
if (skinListTab.value === 'armor') resetArmorPreviewToSaved()
|
||||||
|
|
||||||
|
lastSkinListTab.value = index === 0 ? 'saved' : 'default'
|
||||||
|
skinListTab.value = lastSkinListTab.value
|
||||||
|
}
|
||||||
|
|
||||||
function confirmDeleteSkin(skin: Skin) {
|
function confirmDeleteSkin(skin: Skin) {
|
||||||
if (isSkinManagementReadOnly.value) return
|
if (isSkinManagementReadOnly.value) return
|
||||||
@@ -1211,6 +1285,9 @@ await loadSkins()
|
|||||||
class="ml-5 mt-4 flex h-[calc(80vh-1rem)] items-center justify-center max-[700px]:h-[calc(50vh-1rem)]"
|
class="ml-5 mt-4 flex h-[calc(80vh-1rem)] items-center justify-center max-[700px]:h-[calc(50vh-1rem)]"
|
||||||
>
|
>
|
||||||
<SkinPreviewRenderer
|
<SkinPreviewRenderer
|
||||||
|
v-model:armor-config="armorPreviewConfig"
|
||||||
|
armor-preview
|
||||||
|
:armor-controls="false"
|
||||||
:cape-src="capeTexture"
|
:cape-src="capeTexture"
|
||||||
:texture-src="skinTexture || ''"
|
:texture-src="skinTexture || ''"
|
||||||
:variant="skinVariant"
|
:variant="skinVariant"
|
||||||
@@ -1257,6 +1334,16 @@ await loadSkins()
|
|||||||
<EditIcon />
|
<EditIcon />
|
||||||
{{ formatMessage(messages.editSkinButton) }}
|
{{ formatMessage(messages.editSkinButton) }}
|
||||||
</button>
|
</button>
|
||||||
|
<ArmorPreviewControls
|
||||||
|
v-if="!isSkinSiteProfile"
|
||||||
|
ref="armorPreviewControls"
|
||||||
|
v-model="armorPreviewConfig"
|
||||||
|
panel="external"
|
||||||
|
:panel-id="ARMOR_TRIM_PANEL_ID"
|
||||||
|
:open="skinListTab === 'armor'"
|
||||||
|
@open="openArmorTab"
|
||||||
|
@close="closeArmorTab"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</SkinPreviewRenderer>
|
</SkinPreviewRenderer>
|
||||||
</div>
|
</div>
|
||||||
@@ -1265,14 +1352,10 @@ await loadSkins()
|
|||||||
<div class="pt-2">
|
<div class="pt-2">
|
||||||
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
<NavTabs
|
<NavTabs
|
||||||
:active-index="skinListTab === 'saved' ? 0 : 1"
|
:active-index="lastSkinListTab === 'saved' ? 0 : 1"
|
||||||
:links="skinListTabLinks"
|
:links="skinListTabLinks"
|
||||||
mode="local"
|
mode="local"
|
||||||
@tab-click="
|
@tab-click="selectSkinListTab"
|
||||||
(index: number) => {
|
|
||||||
skinListTab = index === 0 ? 'saved' : 'default'
|
|
||||||
}
|
|
||||||
"
|
|
||||||
/>
|
/>
|
||||||
<ButtonStyled v-if="!isSkinSiteProfile" color="brand">
|
<ButtonStyled v-if="!isSkinSiteProfile" color="brand">
|
||||||
<button @click="router.push('/lab/skin-editor')">
|
<button @click="router.push('/lab/skin-editor')">
|
||||||
@@ -1281,27 +1364,45 @@ await loadSkins()
|
|||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
<VirtualSkinSectionList
|
<Transition name="armor-tab" mode="out-in">
|
||||||
ref="skinSectionList"
|
<div v-if="skinListTab !== 'armor'" key="list">
|
||||||
:active-tab="skinListTab"
|
<SkinListSkeleton v-if="isSkinListBuffering" />
|
||||||
:saved-skins="savedSkins"
|
<VirtualSkinSectionList
|
||||||
:default-skin-sections="defaultSkinSections"
|
v-show="!isSkinListBuffering"
|
||||||
:get-baked-skin-textures="getBakedSkinTextures"
|
ref="skinSectionList"
|
||||||
:is-skin-selected="isSkinSelected"
|
:active-tab="skinListTab"
|
||||||
:is-skin-active="isSkinActive"
|
:saved-skins="savedSkins"
|
||||||
:is-add-skin-button-drag-active="isAddSkinButtonDragActive"
|
:default-skin-sections="defaultSkinSections"
|
||||||
:read-only="isSkinManagementReadOnly"
|
:get-baked-skin-textures="getBakedSkinTextures"
|
||||||
:manage-saved-skins="!isSkinSiteProfile"
|
:is-skin-selected="isSkinSelected"
|
||||||
@select="changeSkin"
|
:is-skin-active="isSkinActive"
|
||||||
@edit="(skin, event) => !isSkinSiteProfile && editSkinModal?.show(event, skin)"
|
:is-add-skin-button-drag-active="isAddSkinButtonDragActive"
|
||||||
@delete="confirmDeleteSkin"
|
:read-only="isSkinManagementReadOnly"
|
||||||
@reorder-saved-skins="reorderSavedSkins"
|
:manage-saved-skins="!isSkinSiteProfile"
|
||||||
@add-skin="openAddSkinFileBrowser"
|
@select="changeSkin"
|
||||||
@add-skin-dragenter="onAddSkinDragOver"
|
@edit="(skin, event) => !isSkinSiteProfile && editSkinModal?.show(event, skin)"
|
||||||
@add-skin-dragover="onAddSkinDragOver"
|
@delete="confirmDeleteSkin"
|
||||||
@add-skin-dragleave="onAddSkinDragLeave"
|
@reorder-saved-skins="reorderSavedSkins"
|
||||||
@add-skin-drop="onAddSkinDrop"
|
@add-skin="openAddSkinFileBrowser"
|
||||||
/>
|
@add-skin-dragenter="onAddSkinDragOver"
|
||||||
|
@add-skin-dragover="onAddSkinDragOver"
|
||||||
|
@add-skin-dragleave="onAddSkinDragLeave"
|
||||||
|
@add-skin-drop="onAddSkinDrop"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ArmorTrimTab
|
||||||
|
v-else
|
||||||
|
ref="armorTrimTab"
|
||||||
|
key="armor"
|
||||||
|
v-model="armorPreviewConfig"
|
||||||
|
:panel-id="ARMOR_TRIM_PANEL_ID"
|
||||||
|
:saved="savedArmorPreview"
|
||||||
|
@save="saveArmorPreview"
|
||||||
|
@reset="resetArmorPreviewToSaved"
|
||||||
|
@defaults="restoreDefaultArmorPreview"
|
||||||
|
@close="closeArmorTab"
|
||||||
|
/>
|
||||||
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1382,4 +1483,17 @@ await loadSkins()
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The armour tab crossfades with the skin list it replaces.
|
||||||
|
.armor-tab-enter-active,
|
||||||
|
.armor-tab-leave-active {
|
||||||
|
transition:
|
||||||
|
opacity 180ms ease,
|
||||||
|
transform 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.armor-tab-enter-from,
|
||||||
|
.armor-tab-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(0.5rem);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "theseus_gui"
|
name = "theseus_gui"
|
||||||
# The actual version is set by the theseus-build workflow on tagging
|
# The actual version is set by the theseus-build workflow on tagging
|
||||||
version = "1.0.0-beta15"
|
version = "1.0.2-beta4"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
description = "Axolotl Launcher is a cross-platform Minecraft launcher"
|
description = "Axolotl Launcher is a cross-platform Minecraft launcher"
|
||||||
homepage = "https://www.ghs.red"
|
homepage = "https://www.ghs.red"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "theseus"
|
name = "theseus"
|
||||||
# The actual version is set by the theseus-build workflow on tagging
|
# The actual version is set by the theseus-build workflow on tagging
|
||||||
version = "1.0.0-beta15"
|
version = "1.0.2-beta4"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1947,9 +1947,9 @@ pub async fn download_assets(
|
|||||||
// failures. Runs concurrently (same budget as the original scheduler) so
|
// failures. Runs concurrently (same budget as the original scheduler) so
|
||||||
// import flows are not serialised.
|
// import flows are not serialised.
|
||||||
if !fallback_assets.is_empty() {
|
if !fallback_assets.is_empty() {
|
||||||
let limit = crate::util::download::task_concurrency_limit(st)
|
let limit =
|
||||||
.map(|limit| limit.saturating_mul(2))
|
crate::util::download::single_authority_task_concurrency_limit(st)
|
||||||
.unwrap_or(ASSET_BATCH_CONCURRENCY);
|
.unwrap_or(ASSET_BATCH_CONCURRENCY);
|
||||||
futures::stream::iter(fallback_assets)
|
futures::stream::iter(fallback_assets)
|
||||||
.map(Ok::<FallbackAsset, crate::Error>)
|
.map(Ok::<FallbackAsset, crate::Error>)
|
||||||
.try_for_each_concurrent(limit, |item| {
|
.try_for_each_concurrent(limit, |item| {
|
||||||
@@ -2123,7 +2123,7 @@ pub async fn download_libraries(
|
|||||||
let num_files = tasks.len();
|
let num_files = tasks.len();
|
||||||
loading_try_for_each_concurrent(
|
loading_try_for_each_concurrent(
|
||||||
stream::iter(tasks).map(Ok::<LibraryDownloadTask<'_>, crate::Error>),
|
stream::iter(tasks).map(Ok::<LibraryDownloadTask<'_>, crate::Error>),
|
||||||
crate::util::download::task_concurrency_limit(&st).map(|limit| limit.saturating_mul(2)),
|
crate::util::download::task_concurrency_limit(&st),
|
||||||
loading_bar,
|
loading_bar,
|
||||||
loading_amount,
|
loading_amount,
|
||||||
num_files,
|
num_files,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
//! Logic for launching Minecraft
|
//! Logic for launching Minecraft
|
||||||
use crate::api::pack::import::direct_link::direct_link_group;
|
|
||||||
use crate::data::ModLoader;
|
use crate::data::ModLoader;
|
||||||
use crate::event::emit::{emit_instance, emit_loading, init_loading};
|
use crate::event::emit::{emit_instance, emit_loading, init_loading};
|
||||||
use crate::event::{InstancePayloadType, LoadingBarType};
|
use crate::event::{InstancePayloadType, LoadingBarType};
|
||||||
@@ -717,14 +716,6 @@ async fn promote_external_instance_link(
|
|||||||
&mut tx,
|
&mut tx,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
if let Some(group) = direct_link_group(&direct.dot_minecraft) {
|
|
||||||
crate::state::instances::adapters::sqlite::instance_rows::replace_instance_groups(
|
|
||||||
&instance.id,
|
|
||||||
&[group],
|
|
||||||
&mut tx,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
emit_instance(&instance.id, InstancePayloadType::Edited).await?;
|
emit_instance(&instance.id, InstancePayloadType::Edited).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
use crate::api::pack::import::{
|
use crate::api::pack::import::{
|
||||||
ImportLauncherType,
|
ImportLauncherType, direct_link::resolve_direct_link,
|
||||||
direct_link::{direct_link_group, resolve_direct_link},
|
|
||||||
};
|
};
|
||||||
use crate::launcher::ExternalGameDirMode;
|
use crate::launcher::ExternalGameDirMode;
|
||||||
use crate::state::instances::{
|
use crate::state::instances::{
|
||||||
@@ -156,11 +155,6 @@ pub(crate) async fn create_direct_link_instance(
|
|||||||
&mut tx,
|
&mut tx,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let groups = direct_link_group(&resolved.dot_minecraft)
|
|
||||||
.into_iter()
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
instance_rows::replace_instance_groups(&instance_id, &groups, &mut tx)
|
|
||||||
.await?;
|
|
||||||
instance_rows::upsert_instance_launch_overrides(&launch_overrides, &mut tx)
|
instance_rows::upsert_instance_launch_overrides(&launch_overrides, &mut tx)
|
||||||
.await?;
|
.await?;
|
||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use super::create_direct_link_instance::create_direct_link_instance;
|
use super::create_direct_link_instance::create_direct_link_instance;
|
||||||
use crate::api::pack::import::direct_link::{
|
use crate::api::pack::import::direct_link::{
|
||||||
detect_direct_link_source, direct_link_group,
|
detect_direct_link_source, has_minecraft_version_manifest,
|
||||||
has_minecraft_version_manifest, resolve_direct_link,
|
resolve_direct_link,
|
||||||
};
|
};
|
||||||
use crate::event::{InstancePayloadType, emit::emit_instance};
|
use crate::event::{InstancePayloadType, emit::emit_instance};
|
||||||
use crate::launcher::ExternalGameDirMode;
|
use crate::launcher::ExternalGameDirMode;
|
||||||
@@ -163,11 +163,7 @@ pub(crate) async fn sync_direct_link_instances(
|
|||||||
let content_changed = metadata.applied_content_set.game_version
|
let content_changed = metadata.applied_content_set.game_version
|
||||||
!= resolved.game_version
|
!= resolved.game_version
|
||||||
|| metadata.applied_content_set.loader != resolved.loader;
|
|| metadata.applied_content_set.loader != resolved.loader;
|
||||||
let groups = direct_link_group(&resolved.dot_minecraft)
|
if fields_changed || content_changed {
|
||||||
.into_iter()
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let groups_changed = metadata.groups != groups;
|
|
||||||
if fields_changed || content_changed || groups_changed {
|
|
||||||
if content_changed {
|
if content_changed {
|
||||||
crate::state::edit_instance(
|
crate::state::edit_instance(
|
||||||
&instance.id,
|
&instance.id,
|
||||||
@@ -216,12 +212,6 @@ pub(crate) async fn sync_direct_link_instances(
|
|||||||
&mut tx,
|
&mut tx,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
instance_rows::replace_instance_groups(
|
|
||||||
&instance.id,
|
|
||||||
&groups,
|
|
||||||
&mut tx,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
let _ = emit_instance(
|
let _ = emit_instance(
|
||||||
&instance.id,
|
&instance.id,
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ pub struct State {
|
|||||||
download_sample_requests: AtomicU64,
|
download_sample_requests: AtomicU64,
|
||||||
download_sample_errors: AtomicU64,
|
download_sample_errors: AtomicU64,
|
||||||
download_sample_throttles: AtomicU64,
|
download_sample_throttles: AtomicU64,
|
||||||
|
download_sample_queue_waits: AtomicU64,
|
||||||
pub(crate) install_job_semaphore: Semaphore,
|
pub(crate) install_job_semaphore: Semaphore,
|
||||||
pub(crate) install_db_semaphore: Semaphore,
|
pub(crate) install_db_semaphore: Semaphore,
|
||||||
pub(crate) install_job_cancellations: DashMap<Uuid, CancellationToken>,
|
pub(crate) install_job_cancellations: DashMap<Uuid, CancellationToken>,
|
||||||
@@ -196,6 +197,7 @@ struct AutoConcurrencySample {
|
|||||||
requests: u64,
|
requests: u64,
|
||||||
errors: u64,
|
errors: u64,
|
||||||
throttles: u64,
|
throttles: u64,
|
||||||
|
queue_waits: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-instance lock registry with task-local reentrancy.
|
/// Per-instance lock registry with task-local reentrancy.
|
||||||
@@ -399,6 +401,15 @@ impl AutoConcurrencyController {
|
|||||||
} else {
|
} else {
|
||||||
sample.errors as f64 / sample.requests as f64
|
sample.errors as f64 / sample.requests as f64
|
||||||
};
|
};
|
||||||
|
if sample.queue_waits > 0 {
|
||||||
|
self.high_utilization_windows = 0;
|
||||||
|
self.pressure_windows = 0;
|
||||||
|
self.probe = None;
|
||||||
|
self.cooldown_until = Some(now + AUTO_DOWNLOAD_PROBE_COOLDOWN);
|
||||||
|
return current
|
||||||
|
.saturating_sub(current.div_ceil(4))
|
||||||
|
.max(AUTO_DOWNLOAD_CONCURRENCY_MIN);
|
||||||
|
}
|
||||||
// A single throttled window is handled by the per-host limiter; the
|
// A single throttled window is handled by the per-host limiter; the
|
||||||
// global concurrency only backs off when pressure persists across
|
// global concurrency only backs off when pressure persists across
|
||||||
// several samples, and then in small steps instead of a big cut.
|
// several samples, and then in small steps instead of a big cut.
|
||||||
@@ -677,6 +688,11 @@ impl State {
|
|||||||
self.download_sample_errors.fetch_add(1, Ordering::AcqRel);
|
self.download_sample_errors.fetch_add(1, Ordering::AcqRel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn record_download_queue_wait(&self) {
|
||||||
|
self.download_sample_queue_waits
|
||||||
|
.fetch_add(1, Ordering::AcqRel);
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn update_download_settings(
|
pub(crate) fn update_download_settings(
|
||||||
self: &Arc<Self>,
|
self: &Arc<Self>,
|
||||||
settings: &Settings,
|
settings: &Settings,
|
||||||
@@ -735,6 +751,7 @@ impl State {
|
|||||||
self.download_sample_requests.swap(0, Ordering::AcqRel);
|
self.download_sample_requests.swap(0, Ordering::AcqRel);
|
||||||
self.download_sample_errors.swap(0, Ordering::AcqRel);
|
self.download_sample_errors.swap(0, Ordering::AcqRel);
|
||||||
self.download_sample_throttles.swap(0, Ordering::AcqRel);
|
self.download_sample_throttles.swap(0, Ordering::AcqRel);
|
||||||
|
self.download_sample_queue_waits.swap(0, Ordering::AcqRel);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let sample = AutoConcurrencySample {
|
let sample = AutoConcurrencySample {
|
||||||
@@ -749,6 +766,9 @@ impl State {
|
|||||||
throttles: self
|
throttles: self
|
||||||
.download_sample_throttles
|
.download_sample_throttles
|
||||||
.swap(0, Ordering::AcqRel),
|
.swap(0, Ordering::AcqRel),
|
||||||
|
queue_waits: self
|
||||||
|
.download_sample_queue_waits
|
||||||
|
.swap(0, Ordering::AcqRel),
|
||||||
};
|
};
|
||||||
let current = self.download_concurrency();
|
let current = self.download_concurrency();
|
||||||
let ceiling =
|
let ceiling =
|
||||||
@@ -771,6 +791,7 @@ impl State {
|
|||||||
requests = sample.requests,
|
requests = sample.requests,
|
||||||
errors = sample.errors,
|
errors = sample.errors,
|
||||||
throttles = sample.throttles,
|
throttles = sample.throttles,
|
||||||
|
queue_waits = sample.queue_waits,
|
||||||
"Adjusted automatic download concurrency"
|
"Adjusted automatic download concurrency"
|
||||||
);
|
);
|
||||||
self.resize_download_concurrency(target);
|
self.resize_download_concurrency(target);
|
||||||
@@ -935,6 +956,7 @@ impl State {
|
|||||||
download_sample_requests: AtomicU64::new(0),
|
download_sample_requests: AtomicU64::new(0),
|
||||||
download_sample_errors: AtomicU64::new(0),
|
download_sample_errors: AtomicU64::new(0),
|
||||||
download_sample_throttles: AtomicU64::new(0),
|
download_sample_throttles: AtomicU64::new(0),
|
||||||
|
download_sample_queue_waits: AtomicU64::new(0),
|
||||||
install_job_semaphore: Semaphore::new(MAX_CONCURRENT_INSTALL_JOBS),
|
install_job_semaphore: Semaphore::new(MAX_CONCURRENT_INSTALL_JOBS),
|
||||||
install_db_semaphore: Semaphore::new(1),
|
install_db_semaphore: Semaphore::new(1),
|
||||||
install_job_cancellations: DashMap::new(),
|
install_job_cancellations: DashMap::new(),
|
||||||
@@ -1024,6 +1046,7 @@ pub(crate) async fn test_state(
|
|||||||
download_sample_requests: AtomicU64::new(0),
|
download_sample_requests: AtomicU64::new(0),
|
||||||
download_sample_errors: AtomicU64::new(0),
|
download_sample_errors: AtomicU64::new(0),
|
||||||
download_sample_throttles: AtomicU64::new(0),
|
download_sample_throttles: AtomicU64::new(0),
|
||||||
|
download_sample_queue_waits: AtomicU64::new(0),
|
||||||
install_job_semaphore: Semaphore::new(1),
|
install_job_semaphore: Semaphore::new(1),
|
||||||
install_db_semaphore: Semaphore::new(1),
|
install_db_semaphore: Semaphore::new(1),
|
||||||
install_job_cancellations: DashMap::new(),
|
install_job_cancellations: DashMap::new(),
|
||||||
@@ -1053,9 +1076,24 @@ mod auto_concurrency_tests {
|
|||||||
requests: 100,
|
requests: 100,
|
||||||
errors: 0,
|
errors: 0,
|
||||||
throttles: 0,
|
throttles: 0,
|
||||||
|
queue_waits: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn queue_pressure_backs_off_immediately_and_repeatedly() {
|
||||||
|
let mut controller = AutoConcurrencyController::default();
|
||||||
|
let now = Instant::now();
|
||||||
|
let congested = AutoConcurrencySample {
|
||||||
|
queue_waits: 1,
|
||||||
|
..healthy(8, 100)
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(controller.next_target(now, 128, 128, congested), 96);
|
||||||
|
assert_eq!(controller.next_target(now, 96, 128, congested), 72);
|
||||||
|
assert_eq!(controller.next_target(now, 16, 128, congested), 16);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn probes_up_after_two_saturated_windows_and_respects_ceiling() {
|
fn probes_up_after_two_saturated_windows_and_respects_ceiling() {
|
||||||
let mut controller = AutoConcurrencyController::default();
|
let mut controller = AutoConcurrencyController::default();
|
||||||
@@ -1130,6 +1168,7 @@ mod auto_concurrency_tests {
|
|||||||
requests: 100,
|
requests: 100,
|
||||||
errors: 5,
|
errors: 5,
|
||||||
throttles: 0,
|
throttles: 0,
|
||||||
|
queue_waits: 0,
|
||||||
};
|
};
|
||||||
assert_eq!(controller.next_target(Instant::now(), 16, 128, sample), 16);
|
assert_eq!(controller.next_target(Instant::now(), 16, 128, sample), 16);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ pub(crate) enum H2DownloadOutcome {
|
|||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub(crate) enum H2DownloadFailure {
|
pub(crate) enum H2DownloadFailure {
|
||||||
Ineligible(&'static str),
|
Ineligible(&'static str),
|
||||||
|
Capacity,
|
||||||
Connect,
|
Connect,
|
||||||
Tls,
|
Tls,
|
||||||
Protocol,
|
Protocol,
|
||||||
@@ -76,6 +77,7 @@ impl H2DownloadFailure {
|
|||||||
pub(crate) const fn as_str(self) -> &'static str {
|
pub(crate) const fn as_str(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Ineligible(reason) => reason,
|
Self::Ineligible(reason) => reason,
|
||||||
|
Self::Capacity => "HTTP/2 connection capacity is unavailable",
|
||||||
Self::Connect => "HTTP/2 TCP connection failed",
|
Self::Connect => "HTTP/2 TCP connection failed",
|
||||||
Self::Tls => "HTTP/2 TLS connection failed",
|
Self::Tls => "HTTP/2 TLS connection failed",
|
||||||
Self::Protocol => "HTTP/2 protocol failed",
|
Self::Protocol => "HTTP/2 protocol failed",
|
||||||
@@ -104,6 +106,7 @@ impl H2DownloadFailure {
|
|||||||
pub(crate) async fn try_download_via_h2(
|
pub(crate) async fn try_download_via_h2(
|
||||||
request: &DownloadRequest,
|
request: &DownloadRequest,
|
||||||
route: &DownloadRoute,
|
route: &DownloadRoute,
|
||||||
|
semaphore: &fetch::FetchSemaphore,
|
||||||
destination: &Path,
|
destination: &Path,
|
||||||
part_path: &Path,
|
part_path: &Path,
|
||||||
policy: super::native::NativeH2Policy,
|
policy: super::native::NativeH2Policy,
|
||||||
@@ -122,13 +125,19 @@ pub(crate) async fn try_download_via_h2(
|
|||||||
preserve_partial: false,
|
preserve_partial: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
let connection = match connect_authority(
|
let connection_wait =
|
||||||
route,
|
connect_authority(route, true, policy.allow_cold_connection);
|
||||||
true,
|
let connection_result = if let Some(cancellation) =
|
||||||
policy.allow_cold_connection,
|
request.cancellation.as_ref()
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
{
|
||||||
|
tokio::select! {
|
||||||
|
_ = cancellation.cancelled() => return H2DownloadOutcome::Canceled,
|
||||||
|
result = connection_wait => result,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
connection_wait.await
|
||||||
|
};
|
||||||
|
let connection = match connection_result {
|
||||||
Ok(connection) => connection,
|
Ok(connection) => connection,
|
||||||
Err(failure) => {
|
Err(failure) => {
|
||||||
return H2DownloadOutcome::Fallback {
|
return H2DownloadOutcome::Fallback {
|
||||||
@@ -137,6 +146,30 @@ pub(crate) async fn try_download_via_h2(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// The shared connection reserves native capacity when it is created.
|
||||||
|
// Acquire the per-download permit afterwards so every native path uses
|
||||||
|
// the same native-then-fetch order and cannot form a circular wait.
|
||||||
|
let fetch_permit_wait =
|
||||||
|
fetch::acquire_fetch_permit_with_pressure(semaphore);
|
||||||
|
let fetch_permit_result = if let Some(cancellation) =
|
||||||
|
request.cancellation.as_ref()
|
||||||
|
{
|
||||||
|
tokio::select! {
|
||||||
|
_ = cancellation.cancelled() => return H2DownloadOutcome::Canceled,
|
||||||
|
result = fetch_permit_wait => result,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fetch_permit_wait.await
|
||||||
|
};
|
||||||
|
let _fetch_permit = match fetch_permit_result {
|
||||||
|
Ok(permit) => permit,
|
||||||
|
Err(_) => {
|
||||||
|
return H2DownloadOutcome::Fallback {
|
||||||
|
failure: H2DownloadFailure::Connect,
|
||||||
|
preserve_partial: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
let Ok(uri) = route.url.parse::<Uri>() else {
|
let Ok(uri) = route.url.parse::<Uri>() else {
|
||||||
return H2DownloadOutcome::Fallback {
|
return H2DownloadOutcome::Fallback {
|
||||||
failure: H2DownloadFailure::Http,
|
failure: H2DownloadFailure::Http,
|
||||||
@@ -156,14 +189,20 @@ pub(crate) async fn try_download_via_h2(
|
|||||||
let total_size = if let Some(size) = expected_size {
|
let total_size = if let Some(size) = expected_size {
|
||||||
size
|
size
|
||||||
} else {
|
} else {
|
||||||
let _probe_stream_permit = match tokio::time::timeout(
|
let probe_wait = super::h2_stream_budget::acquire_with_pressure(route);
|
||||||
ASSET_RESOURCE_WAIT_TIMEOUT,
|
let probe_result = if let Some(cancellation) =
|
||||||
super::h2_stream_budget::acquire(route),
|
request.cancellation.as_ref()
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
{
|
||||||
Ok(Ok(permit)) => permit,
|
tokio::select! {
|
||||||
Ok(Err(_)) | Err(_) => {
|
_ = cancellation.cancelled() => return H2DownloadOutcome::Canceled,
|
||||||
|
result = probe_wait => result,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
probe_wait.await
|
||||||
|
};
|
||||||
|
let _probe_stream_permit = match probe_result {
|
||||||
|
Ok(permit) => permit,
|
||||||
|
Err(_) => {
|
||||||
return H2DownloadOutcome::Fallback {
|
return H2DownloadOutcome::Fallback {
|
||||||
failure: H2DownloadFailure::Connect,
|
failure: H2DownloadFailure::Connect,
|
||||||
preserve_partial: false,
|
preserve_partial: false,
|
||||||
@@ -239,10 +278,7 @@ pub(crate) async fn try_download_via_h2(
|
|||||||
crate::install::DownloadItemStatus::WaitingForResource,
|
crate::install::DownloadItemStatus::WaitingForResource,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let stream_wait = tokio::time::timeout(
|
let stream_wait = super::h2_stream_budget::acquire_with_pressure(route);
|
||||||
ASSET_RESOURCE_WAIT_TIMEOUT,
|
|
||||||
super::h2_stream_budget::acquire(route),
|
|
||||||
);
|
|
||||||
let stream_wait_started = Instant::now();
|
let stream_wait_started = Instant::now();
|
||||||
let stream_result = if let Some(cancellation) =
|
let stream_result = if let Some(cancellation) =
|
||||||
request.cancellation.as_ref()
|
request.cancellation.as_ref()
|
||||||
@@ -255,8 +291,8 @@ pub(crate) async fn try_download_via_h2(
|
|||||||
stream_wait.await
|
stream_wait.await
|
||||||
};
|
};
|
||||||
let _stream_permit = match stream_result {
|
let _stream_permit = match stream_result {
|
||||||
Ok(Ok(permit)) => permit,
|
Ok(permit) => permit,
|
||||||
Ok(Err(_)) | Err(_) => {
|
Err(_) => {
|
||||||
return H2DownloadOutcome::Fallback {
|
return H2DownloadOutcome::Fallback {
|
||||||
failure: H2DownloadFailure::Connect,
|
failure: H2DownloadFailure::Connect,
|
||||||
preserve_partial: false,
|
preserve_partial: false,
|
||||||
@@ -327,6 +363,7 @@ async fn connect_authority(
|
|||||||
"Failed to establish shared HTTP/2 connection"
|
"Failed to establish shared HTTP/2 connection"
|
||||||
);
|
);
|
||||||
Err(match error.kind {
|
Err(match error.kind {
|
||||||
|
H2ConnectFailureKind::Capacity => H2DownloadFailure::Capacity,
|
||||||
H2ConnectFailureKind::Tcp => H2DownloadFailure::Connect,
|
H2ConnectFailureKind::Tcp => H2DownloadFailure::Connect,
|
||||||
H2ConnectFailureKind::Tls => H2DownloadFailure::Tls,
|
H2ConnectFailureKind::Tls => H2DownloadFailure::Tls,
|
||||||
H2ConnectFailureKind::Protocol => H2DownloadFailure::Protocol,
|
H2ConnectFailureKind::Protocol => H2DownloadFailure::Protocol,
|
||||||
@@ -771,6 +808,12 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn capacity_wait_is_not_classified_as_a_transport_failure() {
|
||||||
|
assert!(!H2DownloadFailure::Capacity.is_transfer_failure());
|
||||||
|
assert!(!H2DownloadFailure::Capacity.should_cooldown_authority());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn single_stream_reports_bytes_before_the_response_finishes() {
|
async fn single_stream_reports_bytes_before_the_response_finishes() {
|
||||||
let size = 512 * 1024;
|
let size = 512 * 1024;
|
||||||
@@ -1183,28 +1226,6 @@ async fn download_asset_item(
|
|||||||
"timed out waiting for asset destination lock".to_string(),
|
"timed out waiting for asset destination lock".to_string(),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let fetch_permit = if apply_native_policy {
|
|
||||||
let Some(semaphore) = native_semaphore else {
|
|
||||||
return Err(crate::ErrorKind::OtherError(
|
|
||||||
"native asset batch is missing fetch budget".to_string(),
|
|
||||||
)
|
|
||||||
.into());
|
|
||||||
};
|
|
||||||
Some(
|
|
||||||
tokio::time::timeout(
|
|
||||||
ASSET_RESOURCE_WAIT_TIMEOUT,
|
|
||||||
semaphore.0.acquire(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|_| {
|
|
||||||
crate::ErrorKind::NetworkError(
|
|
||||||
"timed out waiting for asset fetch permit".to_string(),
|
|
||||||
)
|
|
||||||
})??,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
// A different downloader may have committed the object while this item
|
// A different downloader may have committed the object while this item
|
||||||
// waited for the destination lock. Reuse it instead of opening another
|
// waited for the destination lock. Reuse it instead of opening another
|
||||||
// stream, which also prevents cross-engine `.part`/rename races.
|
// stream, which also prevents cross-engine `.part`/rename races.
|
||||||
@@ -1243,24 +1264,26 @@ async fn download_asset_item(
|
|||||||
return Ok(AssetBatchItemOutcome::LocalObjectFailed { error });
|
return Ok(AssetBatchItemOutcome::LocalObjectFailed { error });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let _stream_permit = if apply_native_policy {
|
// Resolve the primary or lazily expanded sibling before taking the fetch
|
||||||
Some(
|
// permit. Shared connections reserve native capacity, so this preserves
|
||||||
tokio::time::timeout(
|
// the native-then-fetch order used by every other native path.
|
||||||
ASSET_RESOURCE_WAIT_TIMEOUT,
|
let connection = connections.connection(rescue).await;
|
||||||
super::h2_stream_budget::acquire_asset(route),
|
let fetch_permit = if apply_native_policy {
|
||||||
|
let Some(semaphore) = native_semaphore else {
|
||||||
|
return Err(crate::ErrorKind::OtherError(
|
||||||
|
"native asset batch is missing fetch budget".to_string(),
|
||||||
)
|
)
|
||||||
.await
|
.into());
|
||||||
.map_err(|_| {
|
};
|
||||||
crate::ErrorKind::NetworkError(
|
Some(fetch::acquire_fetch_permit_with_pressure(semaphore).await?)
|
||||||
"timed out waiting for asset HTTP/2 stream permit"
|
} else {
|
||||||
.to_string(),
|
None
|
||||||
)
|
};
|
||||||
})??,
|
let _stream_permit = if apply_native_policy {
|
||||||
)
|
Some(super::h2_stream_budget::acquire_asset_with_pressure(route).await?)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
let connection = connections.connection(rescue).await;
|
|
||||||
let _connection_stream = connection.track_stream();
|
let _connection_stream = connection.track_stream();
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
headers.insert(
|
headers.insert(
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const CONNECTION_WAIT_TIMEOUT: Duration = Duration::from_secs(45);
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||||
pub(crate) enum H2ConnectFailureKind {
|
pub(crate) enum H2ConnectFailureKind {
|
||||||
|
Capacity,
|
||||||
Tcp,
|
Tcp,
|
||||||
Tls,
|
Tls,
|
||||||
Protocol,
|
Protocol,
|
||||||
@@ -357,9 +358,41 @@ async fn connect_tcp(host: &str, port: u16) -> std::io::Result<TcpStream> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Connects a new shared HTTP/2 connection to `authority` (host[:port]).
|
/// Connects a new shared HTTP/2 connection to `authority` (host[:port]).
|
||||||
async fn establish(
|
async fn reserve_physical_budget(
|
||||||
route: &DownloadRoute,
|
route: &DownloadRoute,
|
||||||
reserve_native_budget: bool,
|
reserve_native_budget: bool,
|
||||||
|
) -> Result<Option<super::native_budget::NativeBudgetPermit>, H2ConnectError> {
|
||||||
|
Ok(if reserve_native_budget {
|
||||||
|
Some(
|
||||||
|
crate::util::fetch::wait_for_download_resource(
|
||||||
|
super::native_budget::acquire(route),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
H2ConnectError::new(
|
||||||
|
H2ConnectFailureKind::Capacity,
|
||||||
|
"HTTP/2 connection capacity remained stalled for 5 minutes"
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.map_err(|error| {
|
||||||
|
H2ConnectError::new(
|
||||||
|
H2ConnectFailureKind::Capacity,
|
||||||
|
format!(
|
||||||
|
"failed to reserve HTTP/2 connection capacity: {error}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})?,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connects a new shared HTTP/2 connection to `authority` (host[:port]).
|
||||||
|
async fn establish(
|
||||||
|
route: &DownloadRoute,
|
||||||
|
physical_budget: Option<super::native_budget::NativeBudgetPermit>,
|
||||||
) -> Result<Arc<SharedH2Connection>, H2ConnectError> {
|
) -> Result<Arc<SharedH2Connection>, H2ConnectError> {
|
||||||
let authority =
|
let authority =
|
||||||
crate::util::fetch::url_authority(&route.url).ok_or_else(|| {
|
crate::util::fetch::url_authority(&route.url).ok_or_else(|| {
|
||||||
@@ -368,16 +401,6 @@ async fn establish(
|
|||||||
"HTTP/2 route has no authority".to_string(),
|
"HTTP/2 route has no authority".to_string(),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let physical_budget = if reserve_native_budget {
|
|
||||||
Some(super::native_budget::acquire(route).await.map_err(|error| {
|
|
||||||
H2ConnectError::new(
|
|
||||||
H2ConnectFailureKind::Tcp,
|
|
||||||
format!("failed to reserve HTTP/2 connection capacity: {error}"),
|
|
||||||
)
|
|
||||||
})?)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
let (host, port) = authority
|
let (host, port) = authority
|
||||||
.rsplit_once(':')
|
.rsplit_once(':')
|
||||||
.map(|(host, port)| (host, port.parse::<u16>().unwrap_or(443)))
|
.map(|(host, port)| (host, port.parse::<u16>().unwrap_or(443)))
|
||||||
@@ -487,16 +510,16 @@ pub(crate) async fn shared_connection(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let slot = connection_slot(&authority).await;
|
let slot = connection_slot(&authority).await;
|
||||||
let mut cached = tokio::time::timeout(CONNECTION_WAIT_TIMEOUT, slot.lock())
|
let mut cached = crate::util::fetch::wait_for_download_resource(
|
||||||
.await
|
slot.lock(),
|
||||||
.map_err(|_| {
|
)
|
||||||
H2ConnectError::new(
|
.await
|
||||||
H2ConnectFailureKind::Protocol,
|
.map_err(|_| {
|
||||||
format!(
|
H2ConnectError::new(
|
||||||
"timed out waiting for HTTP/2 connection slot {authority}"
|
H2ConnectFailureKind::Capacity,
|
||||||
),
|
format!("HTTP/2 connection slot remained stalled for {authority}"),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
if let Some(connection) = cached.as_ref().filter(|connection| {
|
if let Some(connection) = cached.as_ref().filter(|connection| {
|
||||||
!connection.is_dead() && !connection.is_idle_expired()
|
!connection.is_dead() && !connection.is_idle_expired()
|
||||||
}) {
|
}) {
|
||||||
@@ -525,9 +548,11 @@ pub(crate) async fn shared_connection(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
tracing::debug!(authority, "Establishing cold shared HTTP/2 connection");
|
tracing::debug!(authority, "Establishing cold shared HTTP/2 connection");
|
||||||
|
let physical_budget =
|
||||||
|
reserve_physical_budget(route, reserve_native_budget).await?;
|
||||||
let connection = tokio::time::timeout(
|
let connection = tokio::time::timeout(
|
||||||
CONNECTION_WAIT_TIMEOUT,
|
CONNECTION_WAIT_TIMEOUT,
|
||||||
establish(route, reserve_native_budget),
|
establish(route, physical_budget),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| {
|
.map_err(|_| {
|
||||||
@@ -555,15 +580,16 @@ pub(crate) async fn shared_batch_connection(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let slot = batch_connection_slot(&authority).await;
|
let slot = batch_connection_slot(&authority).await;
|
||||||
let mut cached = tokio::time::timeout(
|
let mut cached = crate::util::fetch::wait_for_download_resource(
|
||||||
CONNECTION_WAIT_TIMEOUT,
|
|
||||||
slot.lock(),
|
slot.lock(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| {
|
.map_err(|_| {
|
||||||
H2ConnectError::new(
|
H2ConnectError::new(
|
||||||
H2ConnectFailureKind::Protocol,
|
H2ConnectFailureKind::Capacity,
|
||||||
format!("timed out waiting for asset HTTP/2 connection slot {authority}"),
|
format!(
|
||||||
|
"asset HTTP/2 connection slot remained stalled for {authority}"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
if let Some(connection) = cached.as_ref().filter(|connection| {
|
if let Some(connection) = cached.as_ref().filter(|connection| {
|
||||||
@@ -591,9 +617,11 @@ pub(crate) async fn shared_batch_connection(
|
|||||||
*cached = None;
|
*cached = None;
|
||||||
}
|
}
|
||||||
tracing::debug!(authority, "Establishing sibling HTTP/2 asset connection");
|
tracing::debug!(authority, "Establishing sibling HTTP/2 asset connection");
|
||||||
|
let physical_budget =
|
||||||
|
reserve_physical_budget(route, reserve_native_budget).await?;
|
||||||
let connection = tokio::time::timeout(
|
let connection = tokio::time::timeout(
|
||||||
CONNECTION_WAIT_TIMEOUT,
|
CONNECTION_WAIT_TIMEOUT,
|
||||||
establish(route, reserve_native_budget),
|
establish(route, physical_budget),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| {
|
.map_err(|_| {
|
||||||
@@ -614,10 +642,13 @@ pub(crate) async fn has_live_connection(authority: &str) -> bool {
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
drop(connections);
|
drop(connections);
|
||||||
let live = slot
|
let Ok(cached) = slot.try_lock() else {
|
||||||
.lock()
|
// This is only an eligibility probe. A busy slot means another task
|
||||||
.await
|
// is establishing or refreshing the connection; do not queue this
|
||||||
|
// caller behind that work before its cancellation-aware download path.
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
cached
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|connection| !connection.is_dead());
|
.is_some_and(|connection| !connection.is_dead())
|
||||||
live
|
|
||||||
}
|
}
|
||||||
@@ -218,13 +218,9 @@ async fn download_range(
|
|||||||
crate::install::DownloadItemStatus::WaitingForResource,
|
crate::install::DownloadItemStatus::WaitingForResource,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let _permit = tokio::time::timeout(
|
let _permit = super::h2_stream_budget::acquire_with_pressure(route)
|
||||||
Duration::from_secs(45),
|
.await
|
||||||
super::h2_stream_budget::acquire(route),
|
.map_err(|_| H2DownloadFailure::Connect)?;
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|_| H2DownloadFailure::Connect)?
|
|
||||||
.map_err(|_| H2DownloadFailure::Connect)?;
|
|
||||||
super::h2_download::record_install_stage(
|
super::h2_download::record_install_stage(
|
||||||
request,
|
request,
|
||||||
crate::install::DownloadItemStatus::Downloading,
|
crate::install::DownloadItemStatus::Downloading,
|
||||||
|
|||||||
@@ -104,6 +104,20 @@ pub(crate) async fn acquire(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn acquire_with_pressure(
|
||||||
|
route: &DownloadRoute,
|
||||||
|
) -> crate::Result<H2StreamPermit> {
|
||||||
|
crate::util::fetch::wait_for_download_resource(acquire(route))
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
crate::Error::from(crate::ErrorKind::NetworkError(
|
||||||
|
"HTTP/2 stream permit remained stalled for 5 minutes"
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
})?
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
/// Acquires a stream for the Minecraft asset batch. Assets retain a large
|
/// Acquires a stream for the Minecraft asset batch. Assets retain a large
|
||||||
/// logical worker window, but use a separate physical stream pool so they
|
/// logical worker window, but use a separate physical stream pool so they
|
||||||
/// cannot consume the ordinary native content share.
|
/// cannot consume the ordinary native content share.
|
||||||
@@ -134,10 +148,25 @@ pub(crate) async fn acquire_asset(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn acquire_asset_with_pressure(
|
||||||
|
route: &DownloadRoute,
|
||||||
|
) -> crate::Result<H2StreamPermit> {
|
||||||
|
crate::util::fetch::wait_for_download_resource(acquire_asset(route))
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
crate::Error::from(crate::ErrorKind::NetworkError(
|
||||||
|
"asset HTTP/2 stream permit remained stalled for 5 minutes"
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
})?
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::util::fetch::DownloadRouteSource;
|
use crate::util::fetch::DownloadRouteSource;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
fn route() -> DownloadRoute {
|
fn route() -> DownloadRoute {
|
||||||
DownloadRoute {
|
DownloadRoute {
|
||||||
@@ -190,4 +219,40 @@ mod tests {
|
|||||||
MAX_H2_STREAMS_PER_AUTHORITY
|
MAX_H2_STREAMS_PER_AUTHORITY
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn contended_stream_wait_reports_pressure_and_resumes() {
|
||||||
|
let route = DownloadRoute {
|
||||||
|
url: "https://h2-pressure.example/file".to_string(),
|
||||||
|
source: DownloadRouteSource::Official,
|
||||||
|
is_mirror: false,
|
||||||
|
allow_sensitive_headers: true,
|
||||||
|
supports_range: true,
|
||||||
|
proxy: ProxyPolicy::Direct,
|
||||||
|
};
|
||||||
|
let mut held = Vec::new();
|
||||||
|
for _ in 0..MAX_H2_STREAMS_PER_AUTHORITY {
|
||||||
|
held.push(acquire(&route).await.unwrap());
|
||||||
|
}
|
||||||
|
let pressure = Arc::new(AtomicUsize::new(0));
|
||||||
|
let observed = Arc::clone(&pressure);
|
||||||
|
let release = tokio::spawn(async move {
|
||||||
|
tokio::time::sleep(Duration::from_millis(35)).await;
|
||||||
|
drop(held);
|
||||||
|
});
|
||||||
|
|
||||||
|
let permit = crate::util::fetch::wait_for_resource_with_pressure(
|
||||||
|
acquire(&route),
|
||||||
|
Duration::from_millis(5),
|
||||||
|
Duration::from_millis(100),
|
||||||
|
move || {
|
||||||
|
observed.fetch_add(1, Ordering::Relaxed);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
release.await.unwrap();
|
||||||
|
assert!(permit.is_ok());
|
||||||
|
assert!(pressure.load(Ordering::Relaxed) >= 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -73,6 +73,19 @@ pub(crate) fn task_concurrency_limit(state: &crate::State) -> Option<usize> {
|
|||||||
if active_engine() == DownloadEngine::XmclCompat {
|
if active_engine() == DownloadEngine::XmclCompat {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(state.download_concurrency())
|
Some(native_budget::task_limit(
|
||||||
|
state.download_concurrency(),
|
||||||
|
usize::MAX,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn single_authority_task_concurrency_limit(
|
||||||
|
state: &crate::State,
|
||||||
|
) -> Option<usize> {
|
||||||
|
if active_engine() == DownloadEngine::XmclCompat {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(native_budget::task_limit(state.download_concurrency(), 1))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,6 +144,15 @@ pub(crate) fn available(route: &DownloadRoute) -> usize {
|
|||||||
.unwrap_or(MAX_CONNECTIONS_PER_AUTHORITY)
|
.unwrap_or(MAX_CONNECTIONS_PER_AUTHORITY)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn task_limit(
|
||||||
|
configured_limit: usize,
|
||||||
|
authority_count: usize,
|
||||||
|
) -> usize {
|
||||||
|
configured_limit.max(1).min(MAX_NATIVE_CONNECTIONS).min(
|
||||||
|
MAX_CONNECTIONS_PER_AUTHORITY.saturating_mul(authority_count.max(1)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -174,4 +183,11 @@ mod tests {
|
|||||||
drop(permits);
|
drop(permits);
|
||||||
assert!(try_acquire(&route).is_ok());
|
assert!(try_acquire(&route).is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn task_limit_matches_native_connection_capacity() {
|
||||||
|
assert_eq!(task_limit(128, 1), 8);
|
||||||
|
assert_eq!(task_limit(128, 4), 32);
|
||||||
|
assert_eq!(task_limit(4, 1), 4);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -85,6 +85,8 @@ const MAX_REDIRECT_LOCATION_BYTES: usize = 8 * 1024;
|
|||||||
const FILE_TRANSFER_CONNECT_TIMEOUT: time::Duration =
|
const FILE_TRANSFER_CONNECT_TIMEOUT: time::Duration =
|
||||||
time::Duration::from_secs(20);
|
time::Duration::from_secs(20);
|
||||||
const RESOURCE_WAIT_TIMEOUT: time::Duration = time::Duration::from_secs(45);
|
const RESOURCE_WAIT_TIMEOUT: time::Duration = time::Duration::from_secs(45);
|
||||||
|
const RESOURCE_PRESSURE_INTERVAL: time::Duration = time::Duration::from_secs(3);
|
||||||
|
const RESOURCE_STALL_TIMEOUT: time::Duration = time::Duration::from_secs(300);
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
const FILE_TRANSFER_READ_TIMEOUT: time::Duration =
|
const FILE_TRANSFER_READ_TIMEOUT: time::Duration =
|
||||||
time::Duration::from_secs(60);
|
time::Duration::from_secs(60);
|
||||||
@@ -3894,6 +3896,73 @@ struct NativeConnectionPermit<'a> {
|
|||||||
_native: crate::util::download::native_budget::NativeBudgetPermit,
|
_native: crate::util::download::native_budget::NativeBudgetPermit,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub(crate) enum ResourceWaitError {
|
||||||
|
TimedOut,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn wait_for_resource_with_pressure<F, T, P>(
|
||||||
|
future: F,
|
||||||
|
pressure_interval: time::Duration,
|
||||||
|
stall_timeout: time::Duration,
|
||||||
|
mut on_pressure: P,
|
||||||
|
) -> Result<T, ResourceWaitError>
|
||||||
|
where
|
||||||
|
F: Future<Output = T>,
|
||||||
|
P: FnMut(),
|
||||||
|
{
|
||||||
|
let pressure_interval =
|
||||||
|
pressure_interval.max(time::Duration::from_millis(1));
|
||||||
|
let pressure_timer = tokio::time::sleep(pressure_interval);
|
||||||
|
let stall_timer = tokio::time::sleep(stall_timeout);
|
||||||
|
tokio::pin!(future, pressure_timer, stall_timer);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
result = &mut future => return Ok(result),
|
||||||
|
_ = &mut stall_timer => return Err(ResourceWaitError::TimedOut),
|
||||||
|
_ = &mut pressure_timer => {
|
||||||
|
on_pressure();
|
||||||
|
pressure_timer.as_mut().reset(
|
||||||
|
tokio::time::Instant::now() + pressure_interval,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn wait_for_download_resource<F, T>(
|
||||||
|
future: F,
|
||||||
|
) -> Result<T, ResourceWaitError>
|
||||||
|
where
|
||||||
|
F: Future<Output = T>,
|
||||||
|
{
|
||||||
|
wait_for_resource_with_pressure(
|
||||||
|
future,
|
||||||
|
RESOURCE_PRESSURE_INTERVAL,
|
||||||
|
RESOURCE_STALL_TIMEOUT,
|
||||||
|
|| {
|
||||||
|
if let Some(state) = crate::State::get_if_initialized() {
|
||||||
|
state.record_download_queue_wait();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn acquire_fetch_permit_with_pressure(
|
||||||
|
semaphore: &FetchSemaphore,
|
||||||
|
) -> crate::Result<SemaphorePermit<'_>> {
|
||||||
|
wait_for_download_resource(semaphore.0.acquire())
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
crate::Error::from(ErrorKind::NetworkError(
|
||||||
|
"download permit remained stalled for 5 minutes".to_string(),
|
||||||
|
))
|
||||||
|
})?
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
async fn acquire_native_connection<'a>(
|
async fn acquire_native_connection<'a>(
|
||||||
route: &DownloadRoute,
|
route: &DownloadRoute,
|
||||||
semaphore: &'a FetchSemaphore,
|
semaphore: &'a FetchSemaphore,
|
||||||
@@ -3906,6 +3975,20 @@ async fn acquire_native_connection<'a>(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn acquire_native_connection_with_pressure<'a>(
|
||||||
|
route: &DownloadRoute,
|
||||||
|
semaphore: &'a FetchSemaphore,
|
||||||
|
) -> crate::Result<NativeConnectionPermit<'a>> {
|
||||||
|
wait_for_download_resource(acquire_native_connection(route, semaphore))
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
crate::Error::from(ErrorKind::NetworkError(
|
||||||
|
"native download resources remained stalled for 5 minutes"
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
|
})?
|
||||||
|
}
|
||||||
|
|
||||||
fn try_acquire_native_connection<'a>(
|
fn try_acquire_native_connection<'a>(
|
||||||
route: &DownloadRoute,
|
route: &DownloadRoute,
|
||||||
semaphore: &'a FetchSemaphore,
|
semaphore: &'a FetchSemaphore,
|
||||||
@@ -5703,19 +5786,11 @@ async fn download_to_path_inner(
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
if let Some((h2_route, h2_policy)) = h2_selection {
|
if let Some((h2_route, h2_policy)) = h2_selection {
|
||||||
let h2_permit =
|
|
||||||
tokio::time::timeout(RESOURCE_WAIT_TIMEOUT, semaphore.0.acquire())
|
|
||||||
.await
|
|
||||||
.map_err(|_| {
|
|
||||||
ErrorKind::NetworkError(
|
|
||||||
"timed out waiting for HTTP/2 download permit"
|
|
||||||
.to_string(),
|
|
||||||
)
|
|
||||||
})??;
|
|
||||||
let h2_started = Instant::now();
|
let h2_started = Instant::now();
|
||||||
match crate::util::download::h2_download::try_download_via_h2(
|
match crate::util::download::h2_download::try_download_via_h2(
|
||||||
&request,
|
&request,
|
||||||
&h2_route,
|
&h2_route,
|
||||||
|
semaphore,
|
||||||
destination,
|
destination,
|
||||||
&part_path,
|
&part_path,
|
||||||
h2_policy,
|
h2_policy,
|
||||||
@@ -5808,7 +5883,6 @@ async fn download_to_path_inner(
|
|||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
drop(h2_permit);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut official_integrity_retry = h2_failed_nonofficial.is_some();
|
let mut official_integrity_retry = h2_failed_nonofficial.is_some();
|
||||||
@@ -6147,8 +6221,6 @@ async fn download_to_path_inner(
|
|||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
};
|
};
|
||||||
let mut activity = crate::State::get_if_initialized()
|
|
||||||
.map(|state| state.begin_download_connection());
|
|
||||||
record_install_download_started(
|
record_install_download_started(
|
||||||
&request,
|
&request,
|
||||||
route,
|
route,
|
||||||
@@ -6161,25 +6233,21 @@ async fn download_to_path_inner(
|
|||||||
DownloadItemStatus::WaitingForResource,
|
DownloadItemStatus::WaitingForResource,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let permit_wait = tokio::time::timeout(
|
let permit_wait =
|
||||||
RESOURCE_WAIT_TIMEOUT,
|
acquire_native_connection_with_pressure(route, semaphore);
|
||||||
acquire_native_connection(route, semaphore),
|
|
||||||
);
|
|
||||||
let resource_wait_started = Instant::now();
|
let resource_wait_started = Instant::now();
|
||||||
let permit = if let Some(cancellation) = request.cancellation.as_ref() {
|
let permit = if let Some(cancellation) =
|
||||||
|
request.cancellation.as_ref()
|
||||||
|
{
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = cancellation.cancelled() => return Err(ErrorKind::OtherError("download canceled while waiting for native resources".to_string()).into()),
|
_ = cancellation.cancelled() => return Err(ErrorKind::OtherError("download canceled while waiting for native resources".to_string()).into()),
|
||||||
result = permit_wait => result,
|
result = permit_wait => result,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
permit_wait.await
|
permit_wait.await
|
||||||
}
|
}?;
|
||||||
.map_err(|_| {
|
let mut activity = crate::State::get_if_initialized()
|
||||||
ErrorKind::NetworkError(
|
.map(|state| state.begin_download_connection());
|
||||||
"timed out waiting for native download resources"
|
|
||||||
.to_string(),
|
|
||||||
)
|
|
||||||
})??;
|
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
route = %sanitize_url_for_log(&route.url),
|
route = %sanitize_url_for_log(&route.url),
|
||||||
resource = "native_connection_and_fetch",
|
resource = "native_connection_and_fetch",
|
||||||
@@ -7135,6 +7203,40 @@ mod tests {
|
|||||||
static H2_FALLBACK_TEST_LOCK: LazyLock<std::sync::Mutex<()>> =
|
static H2_FALLBACK_TEST_LOCK: LazyLock<std::sync::Mutex<()>> =
|
||||||
LazyLock::new(|| std::sync::Mutex::new(()));
|
LazyLock::new(|| std::sync::Mutex::new(()));
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn resource_wait_reports_pressure_without_losing_queue_position() {
|
||||||
|
let pressure_events = Arc::new(AtomicUsize::new(0));
|
||||||
|
let observed = Arc::clone(&pressure_events);
|
||||||
|
let result = wait_for_resource_with_pressure(
|
||||||
|
async {
|
||||||
|
tokio::time::sleep(Duration::from_millis(35)).await;
|
||||||
|
7
|
||||||
|
},
|
||||||
|
Duration::from_millis(5),
|
||||||
|
Duration::from_millis(100),
|
||||||
|
move || {
|
||||||
|
observed.fetch_add(1, Ordering::Relaxed);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(result, Ok(7));
|
||||||
|
assert!(pressure_events.load(Ordering::Relaxed) >= 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn resource_wait_keeps_a_final_stall_guard() {
|
||||||
|
let result = wait_for_resource_with_pressure(
|
||||||
|
std::future::pending::<()>(),
|
||||||
|
Duration::from_millis(5),
|
||||||
|
Duration::from_millis(25),
|
||||||
|
|| {},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(result, Err(ResourceWaitError::TimedOut));
|
||||||
|
}
|
||||||
|
|
||||||
async fn spawn_range_server(
|
async fn spawn_range_server(
|
||||||
data: Arc<Vec<u8>>,
|
data: Arc<Vec<u8>>,
|
||||||
wrong_content_range: bool,
|
wrong_content_range: bool,
|
||||||
|
|||||||
|
After Width: | Height: | Size: 251 B |
|
After Width: | Height: | Size: 423 B |
|
After Width: | Height: | Size: 438 B |
|
After Width: | Height: | Size: 434 B |
|
After Width: | Height: | Size: 424 B |
|
After Width: | Height: | Size: 413 B |
|
After Width: | Height: | Size: 170 B |
|
After Width: | Height: | Size: 467 B |
|
After Width: | Height: | Size: 253 B |
|
After Width: | Height: | Size: 167 B |
|
After Width: | Height: | Size: 258 B |
|
After Width: | Height: | Size: 272 B |
|
After Width: | Height: | Size: 261 B |
|
After Width: | Height: | Size: 243 B |
|
After Width: | Height: | Size: 254 B |
|
After Width: | Height: | Size: 180 B |
|
After Width: | Height: | Size: 258 B |
|
After Width: | Height: | Size: 183 B |
|
After Width: | Height: | Size: 225 B |
|
After Width: | Height: | Size: 149 B |
|
After Width: | Height: | Size: 165 B |
|
After Width: | Height: | Size: 135 B |
|
After Width: | Height: | Size: 148 B |
|
After Width: | Height: | Size: 267 B |
|
After Width: | Height: | Size: 171 B |
|
After Width: | Height: | Size: 189 B |
|
After Width: | Height: | Size: 160 B |
|
After Width: | Height: | Size: 196 B |
|
After Width: | Height: | Size: 177 B |
|
After Width: | Height: | Size: 207 B |
|
After Width: | Height: | Size: 175 B |
|
After Width: | Height: | Size: 189 B |
|
After Width: | Height: | Size: 165 B |
|
After Width: | Height: | Size: 177 B |
|
After Width: | Height: | Size: 224 B |
|
After Width: | Height: | Size: 182 B |
|
After Width: | Height: | Size: 211 B |
|
After Width: | Height: | Size: 210 B |
|
After Width: | Height: | Size: 187 B |
|
After Width: | Height: | Size: 169 B |
|
After Width: | Height: | Size: 189 B |
|
After Width: | Height: | Size: 158 B |
|
After Width: | Height: | Size: 174 B |
|
After Width: | Height: | Size: 203 B |
|
After Width: | Height: | Size: 143 B |
|
After Width: | Height: | Size: 161 B |
|
After Width: | Height: | Size: 134 B |
|
After Width: | Height: | Size: 157 B |
|
After Width: | Height: | Size: 151 B |
|
After Width: | Height: | Size: 181 B |
|
After Width: | Height: | Size: 200 B |
|
After Width: | Height: | Size: 245 B |
|
After Width: | Height: | Size: 202 B |
|
After Width: | Height: | Size: 209 B |
|
After Width: | Height: | Size: 171 B |
|
After Width: | Height: | Size: 216 B |
|
After Width: | Height: | Size: 161 B |
|
After Width: | Height: | Size: 208 B |
|
After Width: | Height: | Size: 176 B |
|
After Width: | Height: | Size: 223 B |
|
After Width: | Height: | Size: 215 B |
|
After Width: | Height: | Size: 179 B |
|
After Width: | Height: | Size: 198 B |
|
After Width: | Height: | Size: 227 B |
|
After Width: | Height: | Size: 207 B |
|
After Width: | Height: | Size: 208 B |
|
After Width: | Height: | Size: 254 B |
|
After Width: | Height: | Size: 208 B |
|
After Width: | Height: | Size: 209 B |
|
After Width: | Height: | Size: 221 B |
|
After Width: | Height: | Size: 152 B |
|
After Width: | Height: | Size: 213 B |
|
After Width: | Height: | Size: 218 B |
|
After Width: | Height: | Size: 216 B |
|
After Width: | Height: | Size: 252 B |
|
After Width: | Height: | Size: 103 B |
|
After Width: | Height: | Size: 104 B |