fix: make hosted downloads controllable and failures diagnosable
Sync source to CNB / Sync Git ref (push) Skipped
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
Sync source to CNB / Sync Git ref (push) Skipped
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
This commit is contained in:
1 parent
1838afce56
commit
bf95d715a4
17 files changed
+848
-72
No files matched your search
@@ -0,0 +1,96 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import {
|
||||||
|
hostedDownloadActions,
|
||||||
|
hostedDownloadState,
|
||||||
|
runHostedDownloadAction,
|
||||||
|
runHostedDownloadControl,
|
||||||
|
} from './hosted-download-controls.ts'
|
||||||
|
|
||||||
|
test('only a controllable parent hosted task offers pause, resume and stop', () => {
|
||||||
|
const bar = {
|
||||||
|
bar_type: {
|
||||||
|
type: 'hosted_pack_sync',
|
||||||
|
instance_id: 'one',
|
||||||
|
state: 'running' as const,
|
||||||
|
can_pause: true,
|
||||||
|
can_cancel: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert.deepEqual(hostedDownloadActions(bar), ['pause', 'cancel'])
|
||||||
|
assert.deepEqual(hostedDownloadActions({ bar_type: { ...bar.bar_type, state: 'paused' } }), [
|
||||||
|
'resume',
|
||||||
|
'cancel',
|
||||||
|
])
|
||||||
|
for (const state of ['finalizing', 'canceling'] as const) {
|
||||||
|
assert.deepEqual(hostedDownloadActions({ bar_type: { ...bar.bar_type, state } }), [])
|
||||||
|
}
|
||||||
|
assert.deepEqual(
|
||||||
|
hostedDownloadActions({ bar_type: { ...bar.bar_type, type: 'hosted_mod_download' } }),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
assert.deepEqual(
|
||||||
|
hostedDownloadActions({ bar_type: { type: 'hosted_pack_sync', instance_id: 'one' } }),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('failed and canceled hosted tasks offer direct retry, preserving terminal state', () => {
|
||||||
|
for (const state of ['failed', 'canceled'] as const) {
|
||||||
|
const bar = {
|
||||||
|
bar_type: { type: 'hosted_pack_sync', instance_id: 'one', state, error: 'failure' },
|
||||||
|
}
|
||||||
|
assert.deepEqual(hostedDownloadActions(bar), ['retry'])
|
||||||
|
assert.equal(hostedDownloadState(bar), state)
|
||||||
|
}
|
||||||
|
assert.equal(hostedDownloadState({ bar_type: { error: 'old backend failure' } }), 'failed')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('resume refreshes the session before invoking controls; pause and stop need no session', async () => {
|
||||||
|
const calls: string[] = []
|
||||||
|
const deps = {
|
||||||
|
prepareSession: async () => {
|
||||||
|
calls.push('session')
|
||||||
|
},
|
||||||
|
invoke: async (command: string, args: { instanceId: string }) => {
|
||||||
|
calls.push(`${command}:${args.instanceId}`)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
await runHostedDownloadControl('resume', 'one', deps)
|
||||||
|
await runHostedDownloadControl('pause', 'one', deps)
|
||||||
|
await runHostedDownloadControl('cancel', 'one', deps)
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
'session',
|
||||||
|
'plugin:install|hosted_resume:one',
|
||||||
|
'plugin:install|hosted_pause:one',
|
||||||
|
'plugin:install|hosted_cancel:one',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('session refresh failures prevent resume', async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
runHostedDownloadControl('resume', 'one', {
|
||||||
|
prepareSession: async () => {
|
||||||
|
throw new Error('signed out')
|
||||||
|
},
|
||||||
|
invoke: async () => {
|
||||||
|
assert.fail('resume must not run')
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/signed out/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('retry immediately restarts the existing hosted instance through the manager', async () => {
|
||||||
|
const calls: string[] = []
|
||||||
|
await runHostedDownloadAction('retry', 'existing-instance', {
|
||||||
|
retryHosted: async (instanceId) => {
|
||||||
|
calls.push(instanceId)
|
||||||
|
},
|
||||||
|
control: async () => {
|
||||||
|
assert.fail('retry must use the full hosted transaction')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert.deepEqual(calls, ['existing-instance'])
|
||||||
|
})
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { LoadingBar } from './state.ts'
|
||||||
|
|
||||||
|
export type HostedDownloadControl = 'pause' | 'resume' | 'cancel'
|
||||||
|
export type HostedDownloadAction = HostedDownloadControl | 'retry'
|
||||||
|
|
||||||
|
export function runHostedDownloadAction(
|
||||||
|
action: HostedDownloadAction,
|
||||||
|
instanceId: string,
|
||||||
|
operations: {
|
||||||
|
retryHosted: (instanceId: string) => Promise<void>
|
||||||
|
control: (action: HostedDownloadControl, instanceId: string) => Promise<void>
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return action === 'retry'
|
||||||
|
? operations.retryHosted(instanceId)
|
||||||
|
: operations.control(action, instanceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hostedDownloadState(bar: LoadingBar) {
|
||||||
|
return bar.bar_type?.state ?? (bar.bar_type?.error ? 'failed' : 'running')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hostedDownloadActions(bar: LoadingBar): HostedDownloadAction[] {
|
||||||
|
const type = bar.bar_type
|
||||||
|
if (type?.type !== 'hosted_pack_sync' || !type.instance_id) return []
|
||||||
|
const state = hostedDownloadState(bar)
|
||||||
|
if (state === 'failed' || state === 'canceled') return ['retry']
|
||||||
|
if (state !== 'running' && state !== 'paused') return []
|
||||||
|
const actions: HostedDownloadAction[] = []
|
||||||
|
if (type.can_pause) actions.push(state === 'paused' ? 'resume' : 'pause')
|
||||||
|
if (type.can_cancel) actions.push('cancel')
|
||||||
|
return actions
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runHostedDownloadControl(
|
||||||
|
action: HostedDownloadControl,
|
||||||
|
instanceId: string,
|
||||||
|
dependencies: {
|
||||||
|
prepareSession: () => Promise<void>
|
||||||
|
invoke: (command: string, args: { instanceId: string }) => Promise<void>
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (action === 'resume') await dependencies.prepareSession()
|
||||||
|
await dependencies.invoke(`plugin:install|hosted_${action}`, { instanceId })
|
||||||
|
}
|
||||||
@@ -3,6 +3,27 @@ import test from 'node:test'
|
|||||||
|
|
||||||
import { createHostedDownloadFailures } from './hosted-download-failures.ts'
|
import { createHostedDownloadFailures } from './hosted-download-failures.ts'
|
||||||
|
|
||||||
|
test('stopped hosted downloads retain their state and instance for direct retry', () => {
|
||||||
|
const state = createHostedDownloadFailures()
|
||||||
|
state.update({
|
||||||
|
fraction: null,
|
||||||
|
loader_uuid: 'stopped',
|
||||||
|
event: {
|
||||||
|
type: 'hosted_pack_sync',
|
||||||
|
instance_id: 'existing-instance',
|
||||||
|
state: 'canceled',
|
||||||
|
error: 'Stopped by user',
|
||||||
|
can_pause: false,
|
||||||
|
can_cancel: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert.equal(state.values()[0]?.bar_type?.state, 'canceled')
|
||||||
|
assert.equal(state.values()[0]?.bar_type?.instance_id, 'existing-instance')
|
||||||
|
state.begin('existing-instance', state.values())
|
||||||
|
assert.equal(state.has('existing-instance'), false)
|
||||||
|
assert.equal(state.isRetired('stopped'), true)
|
||||||
|
})
|
||||||
|
|
||||||
test('retry clears only its own failure before progress arrives and ignores late old events', () => {
|
test('retry clears only its own failure before progress arrives and ignores late old events', () => {
|
||||||
const state = createHostedDownloadFailures()
|
const state = createHostedDownloadFailures()
|
||||||
const failed = (instanceId: string, loader: string, error: string) => ({
|
const failed = (instanceId: string, loader: string, error: string) => ({
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
skinSiteUser,
|
skinSiteUser,
|
||||||
waitForSkinSiteSession,
|
waitForSkinSiteSession,
|
||||||
} from '../composables/skin-site-session.ts'
|
} from '../composables/skin-site-session.ts'
|
||||||
|
import { type HostedDownloadControl, runHostedDownloadControl } from './hosted-download-controls.ts'
|
||||||
import { authenticateInstancePlayer } from './instance-player.ts'
|
import { authenticateInstancePlayer } from './instance-player.ts'
|
||||||
|
|
||||||
export const HOSTED_ACCOUNT_REQUIRED_MESSAGE = '您需要登录正版或皮肤站账号后才能操作'
|
export const HOSTED_ACCOUNT_REQUIRED_MESSAGE = '您需要登录正版或皮肤站账号后才能操作'
|
||||||
@@ -146,6 +147,11 @@ export const hostedCreate = async (gameDirRoot?: string | null) => {
|
|||||||
}
|
}
|
||||||
export const hostedBinding = (instanceId: string) =>
|
export const hostedBinding = (instanceId: string) =>
|
||||||
invokeHosted<HostedBinding | null>('plugin:install|hosted_binding', { instanceId })
|
invokeHosted<HostedBinding | null>('plugin:install|hosted_binding', { instanceId })
|
||||||
|
export const controlHostedDownload = (action: HostedDownloadControl, instanceId: string) =>
|
||||||
|
runHostedDownloadControl(action, instanceId, {
|
||||||
|
prepareSession: prepareHostedSession,
|
||||||
|
invoke: invokeHosted<void>,
|
||||||
|
})
|
||||||
export const hostedSync = async (instanceId: string) => {
|
export const hostedSync = async (instanceId: string) => {
|
||||||
await requireHostedGameAccount()
|
await requireHostedGameAccount()
|
||||||
startHostedPackAttempt(instanceId)
|
startHostedPackAttempt(instanceId)
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ export interface LoadingBarType {
|
|||||||
batch_id?: string
|
batch_id?: string
|
||||||
file_name?: string
|
file_name?: string
|
||||||
error?: string | null
|
error?: string | null
|
||||||
|
state?: 'running' | 'paused' | 'canceling' | 'canceled' | 'failed' | 'finalizing'
|
||||||
|
can_pause?: boolean
|
||||||
|
can_cancel?: boolean
|
||||||
type?: string
|
type?: string
|
||||||
version?: string
|
version?: string
|
||||||
instance_id?: string
|
instance_id?: string
|
||||||
@@ -28,7 +31,12 @@ export interface LoadingBar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type OpeningCommandEvent =
|
export type OpeningCommandEvent =
|
||||||
'RunMRPack' | 'InstallServer' | 'InstallVersion' | 'InstallMod' | 'InstallModpack' | string
|
| 'RunMRPack'
|
||||||
|
| 'InstallServer'
|
||||||
|
| 'InstallVersion'
|
||||||
|
| 'InstallMod'
|
||||||
|
| 'InstallModpack'
|
||||||
|
| string
|
||||||
|
|
||||||
export interface OpeningCommand {
|
export interface OpeningCommand {
|
||||||
event: OpeningCommandEvent
|
event: OpeningCommandEvent
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
{
|
{
|
||||||
|
"app.hosted-packs.progress.pause": { "message": "Pause" },
|
||||||
|
"app.hosted-packs.progress.resume": { "message": "Resume" },
|
||||||
|
"app.hosted-packs.progress.stop": { "message": "Stop" },
|
||||||
|
"app.downloads.status.paused": { "message": "Paused" },
|
||||||
"app.skin-site.captcha.loading": { "message": "Loading captcha…" },
|
"app.skin-site.captcha.loading": { "message": "Loading captcha…" },
|
||||||
"app.skin-site.captcha.submit": { "message": "Submit" },
|
"app.skin-site.captcha.submit": { "message": "Submit" },
|
||||||
"app.skin-site.captcha.subtitle": {
|
"app.skin-site.captcha.subtitle": {
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
{
|
{
|
||||||
|
"app.hosted-packs.progress.pause": { "message": "暂停" },
|
||||||
|
"app.hosted-packs.progress.resume": { "message": "继续" },
|
||||||
|
"app.hosted-packs.progress.stop": { "message": "停止" },
|
||||||
|
"app.downloads.status.paused": { "message": "已暂停" },
|
||||||
"app.skin-site.captcha.loading": { "message": "正在加载验证码…" },
|
"app.skin-site.captcha.loading": { "message": "正在加载验证码…" },
|
||||||
"app.skin-site.captcha.submit": { "message": "验证" },
|
"app.skin-site.captcha.submit": { "message": "验证" },
|
||||||
"app.skin-site.captcha.subtitle": {
|
"app.skin-site.captcha.subtitle": {
|
||||||
|
|||||||
@@ -84,7 +84,7 @@
|
|||||||
</TagItem>
|
</TagItem>
|
||||||
<Badge
|
<Badge
|
||||||
:color="bar.bar_type?.error ? 'red' : 'orange'"
|
:color="bar.bar_type?.error ? 'red' : 'orange'"
|
||||||
:type="statusLabel(bar.bar_type?.error ? 'failed' : 'running')"
|
:type="statusLabel(hostedDownloadState(bar))"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
@@ -94,16 +94,35 @@
|
|||||||
:progress="legacyPercent(bar)"
|
:progress="legacyPercent(bar)"
|
||||||
:max="100"
|
:max="100"
|
||||||
:label="formatMessage(messages.progress)"
|
:label="formatMessage(messages.progress)"
|
||||||
:waiting="!bar.total"
|
:waiting="!bar.total && hostedDownloadState(bar) !== 'paused'"
|
||||||
:show-progress="Boolean(bar.total)"
|
:show-progress="Boolean(bar.total)"
|
||||||
/>
|
/>
|
||||||
<RouterLink
|
<div
|
||||||
v-else
|
v-if="hostedDownloadActions(bar).length"
|
||||||
:to="hostedRetryRoute(bar.bar_type?.instance_id ?? '')"
|
class="mt-3 flex flex-wrap items-center gap-2"
|
||||||
class="mt-3 inline-block text-brand hover:underline"
|
|
||||||
>
|
>
|
||||||
{{ formatMessage(messages.hostedRetry) }}
|
<ButtonStyled
|
||||||
</RouterLink>
|
v-for="action in hostedDownloadActions(bar)"
|
||||||
|
:key="action"
|
||||||
|
:color="action === 'retry' ? 'brand' : action === 'cancel' ? 'red' : 'standard'"
|
||||||
|
:type="action === 'retry' ? 'standard' : 'outlined'"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
:disabled="busy.has(hostedBusyKey(bar, action))"
|
||||||
|
@click="hostedAction(bar, action)"
|
||||||
|
>
|
||||||
|
{{ formatMessage(hostedActionMessages[action]) }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<RouterLink
|
||||||
|
v-if="bar.bar_type?.error"
|
||||||
|
:to="hostedRetryRoute(bar.bar_type.instance_id ?? '')"
|
||||||
|
class="text-brand hover:underline"
|
||||||
|
>
|
||||||
|
{{ formatMessage(messages.openInstance) }}
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
@@ -471,7 +490,14 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import MissingModpackContentModal from '@/components/ui/modal/MissingModpackContentModal.vue'
|
import MissingModpackContentModal from '@/components/ui/modal/MissingModpackContentModal.vue'
|
||||||
import { listPendingCurseForgeManualDownloads } from '@/helpers/curseforge'
|
import { listPendingCurseForgeManualDownloads } from '@/helpers/curseforge'
|
||||||
import type { CurseForgeManualDownloadItem } from '@/helpers/curseforge-manual'
|
import type { CurseForgeManualDownloadItem } from '@/helpers/curseforge-manual'
|
||||||
|
import {
|
||||||
|
type HostedDownloadAction,
|
||||||
|
hostedDownloadActions,
|
||||||
|
hostedDownloadState,
|
||||||
|
runHostedDownloadAction,
|
||||||
|
} from '@/helpers/hosted-download-controls'
|
||||||
import { hostedRetryRoute } from '@/helpers/hosted-install-retry'
|
import { hostedRetryRoute } from '@/helpers/hosted-install-retry'
|
||||||
|
import { controlHostedDownload } from '@/helpers/hosted-packs'
|
||||||
import {
|
import {
|
||||||
download_job_support_details,
|
download_job_support_details,
|
||||||
type InstallJobSnapshot,
|
type InstallJobSnapshot,
|
||||||
@@ -517,10 +543,6 @@ const focusedJobId = computed(() => focusedDownloadJobId(route.query.job))
|
|||||||
const focusState = ref(createDownloadFocusState(focusedJobId.value))
|
const focusState = ref(createDownloadFocusState(focusedJobId.value))
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
hostedRetry: {
|
|
||||||
id: 'app.hosted-packs.progress.retry',
|
|
||||||
defaultMessage: 'Return to the instance to retry',
|
|
||||||
},
|
|
||||||
newDownload: { id: 'app.downloads.new-download', defaultMessage: 'New download' },
|
newDownload: { id: 'app.downloads.new-download', defaultMessage: 'New download' },
|
||||||
inProgress: { id: 'app.downloads.in-progress', defaultMessage: 'In progress' },
|
inProgress: { id: 'app.downloads.in-progress', defaultMessage: 'In progress' },
|
||||||
history: { id: 'app.downloads.history', defaultMessage: 'History' },
|
history: { id: 'app.downloads.history', defaultMessage: 'History' },
|
||||||
@@ -653,7 +675,15 @@ const messages = defineMessages({
|
|||||||
unknownPhase: { id: 'app.downloads.phase.unknown', defaultMessage: 'Working' },
|
unknownPhase: { id: 'app.downloads.phase.unknown', defaultMessage: 'Working' },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const hostedActionMessages = defineMessages({
|
||||||
|
pause: { id: 'app.hosted-packs.progress.pause', defaultMessage: 'Pause' },
|
||||||
|
resume: { id: 'app.hosted-packs.progress.resume', defaultMessage: 'Resume' },
|
||||||
|
cancel: { id: 'app.hosted-packs.progress.stop', defaultMessage: 'Stop' },
|
||||||
|
retry: { id: 'app.downloads.retry', defaultMessage: 'Retry' },
|
||||||
|
})
|
||||||
|
|
||||||
const statusMessages = defineMessages({
|
const statusMessages = defineMessages({
|
||||||
|
paused: { id: 'app.downloads.status.paused', defaultMessage: 'Paused' },
|
||||||
queued: { id: 'app.downloads.status.queued', defaultMessage: 'Queued' },
|
queued: { id: 'app.downloads.status.queued', defaultMessage: 'Queued' },
|
||||||
running: { id: 'app.downloads.status.running', defaultMessage: 'Running' },
|
running: { id: 'app.downloads.status.running', defaultMessage: 'Running' },
|
||||||
canceling: { id: 'app.downloads.status.canceling', defaultMessage: 'Canceling' },
|
canceling: { id: 'app.downloads.status.canceling', defaultMessage: 'Canceling' },
|
||||||
@@ -1262,6 +1292,7 @@ function toggleExpanded(jobId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function withBusy(jobId: string, action: () => Promise<void>) {
|
async function withBusy(jobId: string, action: () => Promise<void>) {
|
||||||
|
if (busy.value.has(jobId)) return
|
||||||
busy.value = new Set([...busy.value, jobId])
|
busy.value = new Set([...busy.value, jobId])
|
||||||
try {
|
try {
|
||||||
await action()
|
await action()
|
||||||
@@ -1278,6 +1309,21 @@ async function cancel(job: InstallJobSnapshot) {
|
|||||||
await withBusy(job.job_id, () => manager.cancel(job.job_id))
|
await withBusy(job.job_id, () => manager.cancel(job.job_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hostedBusyKey(bar: LoadingBar, action: HostedDownloadAction) {
|
||||||
|
return `hosted-${action === 'retry' ? 'retry' : 'control'}:${bar.bar_type?.instance_id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hostedAction(bar: LoadingBar, action: HostedDownloadAction) {
|
||||||
|
const instanceId = bar.bar_type?.instance_id
|
||||||
|
if (!instanceId || !hostedDownloadActions(bar).includes(action)) return
|
||||||
|
await withBusy(hostedBusyKey(bar, action), () =>
|
||||||
|
runHostedDownloadAction(action, instanceId, {
|
||||||
|
retryHosted: manager.retryHosted,
|
||||||
|
control: controlHostedDownload,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async function retry(job: InstallJobSnapshot) {
|
async function retry(job: InstallJobSnapshot) {
|
||||||
await withBusy(job.job_id, () => manager.retry(job.job_id))
|
await withBusy(job.job_id, () => manager.retry(job.job_id))
|
||||||
}
|
}
|
||||||
@@ -1293,12 +1339,14 @@ async function resolveMissing(job: InstallJobSnapshot) {
|
|||||||
(item) =>
|
(item) =>
|
||||||
item.status === 'skipped' && item.manual_url && item.project_id && item.version_id,
|
item.status === 'skipped' && item.manual_url && item.project_id && item.version_id,
|
||||||
)
|
)
|
||||||
.map((item): CurseForgeManualDownloadItem => ({
|
.map(
|
||||||
projectId: Number(item.project_id),
|
(item): CurseForgeManualDownloadItem => ({
|
||||||
fileId: Number(item.version_id),
|
projectId: Number(item.project_id),
|
||||||
fileName: item.name,
|
fileId: Number(item.version_id),
|
||||||
websiteUrl: item.manual_url ?? undefined,
|
fileName: item.name,
|
||||||
}))
|
websiteUrl: item.manual_url ?? undefined,
|
||||||
|
}),
|
||||||
|
)
|
||||||
const instanceId = job.instance_id
|
const instanceId = job.instance_id
|
||||||
const hasGeneralMissingItems = job.items.some((item) => item.status === 'failed')
|
const hasGeneralMissingItems = job.items.some((item) => item.status === 'failed')
|
||||||
if (instanceId && (job.provider === 'curse_forge' || fallbackCurseForgeItems.length > 0)) {
|
if (instanceId && (job.provider === 'curse_forge' || fallbackCurseForgeItems.length > 0)) {
|
||||||
|
|||||||
@@ -380,6 +380,9 @@ fn main() {
|
|||||||
"hosted_create",
|
"hosted_create",
|
||||||
"hosted_binding",
|
"hosted_binding",
|
||||||
"hosted_sync",
|
"hosted_sync",
|
||||||
|
"hosted_pause",
|
||||||
|
"hosted_resume",
|
||||||
|
"hosted_cancel",
|
||||||
"hosted_instance_mode",
|
"hosted_instance_mode",
|
||||||
"hosted_set_instance_mode",
|
"hosted_set_instance_mode",
|
||||||
"install_pack_to_existing_instance",
|
"install_pack_to_existing_instance",
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
|||||||
hosted_create,
|
hosted_create,
|
||||||
hosted_binding,
|
hosted_binding,
|
||||||
hosted_sync,
|
hosted_sync,
|
||||||
|
hosted_pause,
|
||||||
|
hosted_resume,
|
||||||
|
hosted_cancel,
|
||||||
hosted_instance_mode,
|
hosted_instance_mode,
|
||||||
hosted_set_instance_mode,
|
hosted_set_instance_mode,
|
||||||
install_get_modpack_preview,
|
install_get_modpack_preview,
|
||||||
@@ -105,6 +108,21 @@ pub async fn hosted_sync(
|
|||||||
Ok(theseus::pack::hosted::synchronize(&instance_id).await?)
|
Ok(theseus::pack::hosted::synchronize(&instance_id).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn hosted_pause(instance_id: String) -> Result<()> {
|
||||||
|
Ok(theseus::pack::hosted::pause(&instance_id).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn hosted_resume(instance_id: String) -> Result<()> {
|
||||||
|
Ok(theseus::pack::hosted::resume(&instance_id).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn hosted_cancel(instance_id: String) -> Result<()> {
|
||||||
|
Ok(theseus::pack::hosted::cancel(&instance_id).await?)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct InstallCreateInstanceRequest {
|
pub struct InstallCreateInstanceRequest {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
//! Administrator-approved skin-site packs. Only content-addressed changed files cross the network.
|
//! Administrator-approved skin-site packs. Only content-addressed changed files cross the network.
|
||||||
|
mod control;
|
||||||
mod progress;
|
mod progress;
|
||||||
|
pub use control::{HostedTaskState, cancel, pause, resume};
|
||||||
mod tagged;
|
mod tagged;
|
||||||
mod transport;
|
mod transport;
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -938,10 +940,20 @@ async fn synchronize_locked(instance_id: &str) -> crate::Result<SyncResult> {
|
|||||||
let metadata = crate::instance::get(instance_id)
|
let metadata = crate::instance::get(instance_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| invalid("Unknown instance"))?;
|
.ok_or_else(|| invalid("Unknown instance"))?;
|
||||||
|
let task = control::Registration::new(instance_id)?;
|
||||||
let progress =
|
let progress =
|
||||||
PackProgress::new(instance_id, &metadata.instance.name).await?;
|
PackProgress::new(instance_id, &metadata.instance.name).await?;
|
||||||
let result =
|
let result = task
|
||||||
synchronize_with_progress(instance_id, metadata, &progress).await;
|
.control
|
||||||
|
.run(|| {
|
||||||
|
synchronize_with_progress(
|
||||||
|
instance_id,
|
||||||
|
metadata.clone(),
|
||||||
|
&progress,
|
||||||
|
&task.control,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
if let Err(error) = &result {
|
if let Err(error) = &result {
|
||||||
progress.fail(error);
|
progress.fail(error);
|
||||||
}
|
}
|
||||||
@@ -952,6 +964,7 @@ async fn synchronize_with_progress(
|
|||||||
instance_id: &str,
|
instance_id: &str,
|
||||||
metadata: crate::state::InstanceMetadata,
|
metadata: crate::state::InstanceMetadata,
|
||||||
progress: &PackProgress,
|
progress: &PackProgress,
|
||||||
|
control: &control::Control,
|
||||||
) -> crate::Result<SyncResult> {
|
) -> crate::Result<SyncResult> {
|
||||||
if metadata.instance.linked_launcher.is_some()
|
if metadata.instance.linked_launcher.is_some()
|
||||||
|| metadata.instance.symlink_target.is_some()
|
|| metadata.instance.symlink_target.is_some()
|
||||||
@@ -1275,7 +1288,7 @@ async fn synchronize_with_progress(
|
|||||||
&format!("文件 0/{total_files}"),
|
&format!("文件 0/{total_files}"),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
let results = stream::iter(pending_downloads.into_values().map(
|
downloaded += control::collect_downloads(stream::iter(pending_downloads.into_values().map(
|
||||||
|(file, url, object)| {
|
|(file, url, object)| {
|
||||||
let current = ¤t;
|
let current = ¤t;
|
||||||
let completed = &completed;
|
let completed = &completed;
|
||||||
@@ -1342,34 +1355,14 @@ async fn synchronize_with_progress(
|
|||||||
request = request
|
request = request
|
||||||
.with_header("Authorization", auth.clone());
|
.with_header("Authorization", auth.clone());
|
||||||
}
|
}
|
||||||
let first_result = download_to_path(
|
download_to_path(
|
||||||
request.clone(),
|
request.clone(),
|
||||||
&object,
|
&object,
|
||||||
&state.fetch_semaphore,
|
&state.fetch_semaphore,
|
||||||
&state.pool,
|
&state.pool,
|
||||||
Some(&mut on_progress),
|
Some(&mut on_progress),
|
||||||
)
|
)
|
||||||
.await;
|
.await.map_err(|error| error.with_context(format!("下载整合包文件失败:{file_path}")))?;
|
||||||
if let Err(error) = first_result {
|
|
||||||
tracing::warn!(
|
|
||||||
path = %file_path,
|
|
||||||
%error,
|
|
||||||
"Parallel StarLight file download failed; retrying after the batch load has eased"
|
|
||||||
);
|
|
||||||
ensure_session(auth).await?;
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(
|
|
||||||
750,
|
|
||||||
))
|
|
||||||
.await;
|
|
||||||
download_to_path(
|
|
||||||
request,
|
|
||||||
&object,
|
|
||||||
&state.fetch_semaphore,
|
|
||||||
&state.pool,
|
|
||||||
Some(&mut on_progress),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
let completed_files =
|
let completed_files =
|
||||||
completed.fetch_add(1, Ordering::Relaxed) + 1;
|
completed.fetch_add(1, Ordering::Relaxed) + 1;
|
||||||
let completed_bytes = {
|
let completed_bytes = {
|
||||||
@@ -1391,12 +1384,7 @@ async fn synchronize_with_progress(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
.buffer_unordered(concurrency)
|
.buffer_unordered(concurrency)).await?;
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.await;
|
|
||||||
downloaded += results.into_iter().try_fold(0, |sum, result| {
|
|
||||||
Ok::<u64, crate::Error>(sum + result?)
|
|
||||||
})?;
|
|
||||||
}
|
}
|
||||||
ensure_session(&auth).await?;
|
ensure_session(&auth).await?;
|
||||||
downloaded += tagged::download(
|
downloaded += tagged::download(
|
||||||
@@ -1446,6 +1434,8 @@ async fn synchronize_with_progress(
|
|||||||
previous,
|
previous,
|
||||||
metadata,
|
metadata,
|
||||||
};
|
};
|
||||||
|
// From this atomic boundary onward the rollback/runtime transaction must finish.
|
||||||
|
control.begin_finalizing()?;
|
||||||
write_json(&root, JOURNAL, &journal)?;
|
write_json(&root, JOURNAL, &journal)?;
|
||||||
let result = async {
|
let result = async {
|
||||||
progress.update(0, 0, "正在安装游戏组件", true);
|
progress.update(0, 0, "正在安装游戏组件", true);
|
||||||
|
|||||||
@@ -0,0 +1,329 @@
|
|||||||
|
use futures::{Stream, StreamExt};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::{
|
||||||
|
future::Future,
|
||||||
|
sync::{Arc, LazyLock, Mutex},
|
||||||
|
};
|
||||||
|
use tokio::sync::watch;
|
||||||
|
|
||||||
|
#[derive(
|
||||||
|
Clone, Copy, Debug, Default, Deserialize, Serialize, Hash, PartialEq, Eq,
|
||||||
|
)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum HostedTaskState {
|
||||||
|
#[default]
|
||||||
|
Running,
|
||||||
|
Paused,
|
||||||
|
Canceling,
|
||||||
|
Canceled,
|
||||||
|
Failed,
|
||||||
|
Finalizing,
|
||||||
|
}
|
||||||
|
|
||||||
|
static CONTROLS: LazyLock<dashmap::DashMap<String, Arc<Control>>> =
|
||||||
|
LazyLock::new(dashmap::DashMap::new);
|
||||||
|
|
||||||
|
pub(super) struct Control {
|
||||||
|
instance_id: String,
|
||||||
|
state: Mutex<HostedTaskState>,
|
||||||
|
changes: watch::Sender<HostedTaskState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stopped() -> crate::Error {
|
||||||
|
super::invalid("已停止整合包下载;已下载的缓存会保留,可重试继续")
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Control {
|
||||||
|
fn new(instance_id: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
instance_id: instance_id.to_owned(),
|
||||||
|
state: Mutex::new(HostedTaskState::Running),
|
||||||
|
changes: watch::channel(HostedTaskState::Running).0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state(&self) -> HostedTaskState {
|
||||||
|
*self.state.lock().unwrap_or_else(|error| error.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transition(
|
||||||
|
&self,
|
||||||
|
allowed: &[HostedTaskState],
|
||||||
|
next: HostedTaskState,
|
||||||
|
) -> crate::Result<()> {
|
||||||
|
let mut state =
|
||||||
|
self.state.lock().unwrap_or_else(|error| error.into_inner());
|
||||||
|
if !allowed.contains(&state) {
|
||||||
|
return Err(super::invalid(
|
||||||
|
"任务状态已变化或正在应用安装文件,当前操作不可用",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
*state = next;
|
||||||
|
// Publish under the transition lock, so a late pause cannot overwrite finalization.
|
||||||
|
crate::event::emit::set_hosted_loading_state(&self.instance_id, next);
|
||||||
|
self.changes.send_replace(next);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pause(&self) -> crate::Result<()> {
|
||||||
|
self.transition(&[HostedTaskState::Running], HostedTaskState::Paused)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resume(&self) -> crate::Result<()> {
|
||||||
|
self.transition(&[HostedTaskState::Paused], HostedTaskState::Running)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancel(&self) -> crate::Result<()> {
|
||||||
|
self.transition(
|
||||||
|
&[HostedTaskState::Running, HostedTaskState::Paused],
|
||||||
|
HostedTaskState::Canceling,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn begin_finalizing(&self) -> crate::Result<()> {
|
||||||
|
self.transition(
|
||||||
|
&[HostedTaskState::Running],
|
||||||
|
HostedTaskState::Finalizing,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The operation may be dropped only before begin_finalizing. Restarting
|
||||||
|
/// re-plans from verified cache objects and the downloader's partial files.
|
||||||
|
pub(super) async fn run<T, F, Fut>(
|
||||||
|
&self,
|
||||||
|
mut operation: F,
|
||||||
|
) -> crate::Result<T>
|
||||||
|
where
|
||||||
|
F: FnMut() -> Fut,
|
||||||
|
Fut: Future<Output = crate::Result<T>>,
|
||||||
|
{
|
||||||
|
let mut changes = self.changes.subscribe();
|
||||||
|
loop {
|
||||||
|
let state = *changes.borrow_and_update();
|
||||||
|
match state {
|
||||||
|
HostedTaskState::Paused => {
|
||||||
|
changes.changed().await.map_err(|_| stopped())?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
HostedTaskState::Canceling => return Err(stopped()),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
let work = operation();
|
||||||
|
tokio::pin!(work);
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
biased;
|
||||||
|
changed = changes.changed() => {
|
||||||
|
changed.map_err(|_| stopped())?;
|
||||||
|
match self.state() {
|
||||||
|
HostedTaskState::Paused => break,
|
||||||
|
HostedTaskState::Canceling => return Err(stopped()),
|
||||||
|
_ => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = &mut work => {
|
||||||
|
match self.state() {
|
||||||
|
HostedTaskState::Paused => break,
|
||||||
|
HostedTaskState::Canceling => return Err(stopped()),
|
||||||
|
_ => return result,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) struct Registration {
|
||||||
|
instance_id: String,
|
||||||
|
pub(super) control: Arc<Control>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Registration {
|
||||||
|
pub(super) fn new(instance_id: &str) -> crate::Result<Self> {
|
||||||
|
use dashmap::mapref::entry::Entry;
|
||||||
|
let control = Arc::new(Control::new(instance_id));
|
||||||
|
match CONTROLS.entry(instance_id.to_owned()) {
|
||||||
|
Entry::Occupied(_) => {
|
||||||
|
return Err(super::invalid("此实例已有整合包下载任务"));
|
||||||
|
}
|
||||||
|
Entry::Vacant(entry) => {
|
||||||
|
entry.insert(control.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
instance_id: instance_id.to_owned(),
|
||||||
|
control,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Registration {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
CONTROLS.remove_if(&self.instance_id, |_, value| {
|
||||||
|
Arc::ptr_eq(value, &self.control)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lookup(instance_id: &str) -> crate::Result<Arc<Control>> {
|
||||||
|
CONTROLS
|
||||||
|
.get(instance_id)
|
||||||
|
.map(|entry| entry.value().clone())
|
||||||
|
.ok_or_else(|| super::invalid("整合包下载任务已结束,请重试"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn pause(instance_id: &str) -> crate::Result<()> {
|
||||||
|
lookup(instance_id)?.pause()
|
||||||
|
}
|
||||||
|
pub async fn resume(instance_id: &str) -> crate::Result<()> {
|
||||||
|
lookup(instance_id)?.resume()
|
||||||
|
}
|
||||||
|
pub async fn cancel(instance_id: &str) -> crate::Result<()> {
|
||||||
|
lookup(instance_id)?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop the batch on its first error so stalled peers cannot hide failure.
|
||||||
|
pub(super) async fn collect_downloads<S>(downloads: S) -> crate::Result<u64>
|
||||||
|
where
|
||||||
|
S: Stream<Item = crate::Result<u64>>,
|
||||||
|
{
|
||||||
|
tokio::pin!(downloads);
|
||||||
|
let mut total = 0;
|
||||||
|
while let Some(result) = downloads.next().await {
|
||||||
|
total += result?;
|
||||||
|
}
|
||||||
|
Ok(total)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use futures::FutureExt;
|
||||||
|
use std::sync::{
|
||||||
|
Arc,
|
||||||
|
atomic::{AtomicUsize, Ordering},
|
||||||
|
};
|
||||||
|
use tokio::sync::{Notify, Semaphore};
|
||||||
|
|
||||||
|
struct TransferGuard(Arc<AtomicUsize>, Arc<Notify>);
|
||||||
|
|
||||||
|
impl Drop for TransferGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.0.fetch_add(1, Ordering::SeqCst);
|
||||||
|
self.1.notify_one();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pause_drops_live_transfers_and_resume_restarts_before_stop() {
|
||||||
|
let control = Arc::new(Control::new("pause-resume-test"));
|
||||||
|
let starts = Arc::new(Semaphore::new(0));
|
||||||
|
let drops = Arc::new(AtomicUsize::new(0));
|
||||||
|
let dropped = Arc::new(Notify::new());
|
||||||
|
let task = {
|
||||||
|
let control = control.clone();
|
||||||
|
let starts = starts.clone();
|
||||||
|
let drops = drops.clone();
|
||||||
|
let dropped = dropped.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
control
|
||||||
|
.run(|| {
|
||||||
|
let guard =
|
||||||
|
TransferGuard(drops.clone(), dropped.clone());
|
||||||
|
starts.add_permits(1);
|
||||||
|
async move {
|
||||||
|
let _guard = guard;
|
||||||
|
std::future::pending::<crate::Result<()>>().await
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
})
|
||||||
|
};
|
||||||
|
starts.acquire().await.unwrap().forget();
|
||||||
|
control.pause().unwrap();
|
||||||
|
dropped.notified().await;
|
||||||
|
assert_eq!(drops.load(Ordering::SeqCst), 1);
|
||||||
|
assert_eq!(control.state(), HostedTaskState::Paused);
|
||||||
|
control.resume().unwrap();
|
||||||
|
starts.acquire().await.unwrap().forget();
|
||||||
|
control.cancel().unwrap();
|
||||||
|
let result = task.await.unwrap();
|
||||||
|
assert!(result.unwrap_err().to_string().contains("停止"));
|
||||||
|
assert_eq!(drops.load(Ordering::SeqCst), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stopping_an_already_paused_task_never_starts_a_transfer() {
|
||||||
|
let control = Arc::new(Control::new("paused-stop-test"));
|
||||||
|
control.pause().unwrap();
|
||||||
|
let task = {
|
||||||
|
let control = control.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
control
|
||||||
|
.run(|| async {
|
||||||
|
panic!("paused work was polled");
|
||||||
|
Ok::<(), crate::Error>(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
})
|
||||||
|
};
|
||||||
|
control.cancel().unwrap();
|
||||||
|
assert!(task.await.unwrap().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn finalization_rejects_pause_and_stop() {
|
||||||
|
let control = Control::new("finalization-test");
|
||||||
|
control.begin_finalizing().unwrap();
|
||||||
|
assert_eq!(control.state(), HostedTaskState::Finalizing);
|
||||||
|
assert!(control.pause().is_err());
|
||||||
|
assert!(control.cancel().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pause_and_finalization_have_only_one_winner() {
|
||||||
|
for _ in 0..32 {
|
||||||
|
let control = Arc::new(Control::new("finalization-race-test"));
|
||||||
|
let barrier = Arc::new(std::sync::Barrier::new(2));
|
||||||
|
let task = {
|
||||||
|
let control = control.clone();
|
||||||
|
let barrier = barrier.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
barrier.wait();
|
||||||
|
control.pause().is_ok()
|
||||||
|
})
|
||||||
|
};
|
||||||
|
barrier.wait();
|
||||||
|
let finalized = control.begin_finalizing().is_ok();
|
||||||
|
assert_ne!(finalized, task.join().unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn first_failure_drops_stalled_peer_downloads() {
|
||||||
|
let drops = Arc::new(AtomicUsize::new(0));
|
||||||
|
let guard = TransferGuard(drops.clone(), Arc::new(Notify::new()));
|
||||||
|
let downloads = futures::stream::iter(vec![
|
||||||
|
async move {
|
||||||
|
let _guard = guard;
|
||||||
|
std::future::pending::<crate::Result<u64>>().await
|
||||||
|
}
|
||||||
|
.boxed(),
|
||||||
|
async { Err(super::super::invalid("failed file")) }.boxed(),
|
||||||
|
])
|
||||||
|
.buffer_unordered(2);
|
||||||
|
let result = collect_downloads(downloads).await;
|
||||||
|
assert!(result.unwrap_err().to_string().contains("failed file"));
|
||||||
|
assert_eq!(drops.load(Ordering::SeqCst), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registration_is_unique_and_released_on_drop() {
|
||||||
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let registration = Registration::new(&id).unwrap();
|
||||||
|
assert!(Registration::new(&id).is_err());
|
||||||
|
drop(registration);
|
||||||
|
assert!(Registration::new(&id).is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,6 +59,9 @@ impl PackProgress {
|
|||||||
instance_id: instance_id.to_owned(),
|
instance_id: instance_id.to_owned(),
|
||||||
instance_name: name.to_owned(),
|
instance_name: name.to_owned(),
|
||||||
error: None,
|
error: None,
|
||||||
|
state: super::HostedTaskState::Running,
|
||||||
|
can_pause: true,
|
||||||
|
can_cancel: true,
|
||||||
},
|
},
|
||||||
1.0,
|
1.0,
|
||||||
"正在获取整合包信息",
|
"正在获取整合包信息",
|
||||||
|
|||||||
@@ -681,7 +681,7 @@ pub(super) async fn download(
|
|||||||
set_loading(&bar, 0, file.size, "等待下载")?;
|
set_loading(&bar, 0, file.size, "等待下载")?;
|
||||||
work.push((file, url, object, bar));
|
work.push((file, url, object, bar));
|
||||||
}
|
}
|
||||||
let results =
|
control::collect_downloads(
|
||||||
stream::iter(work.into_iter().map(|(file, url, object, bar)| {
|
stream::iter(work.into_iter().map(|(file, url, object, bar)| {
|
||||||
let current = ¤t;
|
let current = ¤t;
|
||||||
let state = &state;
|
let state = &state;
|
||||||
@@ -740,10 +740,7 @@ pub(super) async fn download(
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
.buffer_unordered(concurrency)
|
.buffer_unordered(concurrency),
|
||||||
.collect::<Vec<_>>()
|
)
|
||||||
.await;
|
.await
|
||||||
results
|
|
||||||
.into_iter()
|
|
||||||
.try_fold(0, |sum, result| Ok(sum + result?))
|
|
||||||
}
|
}
|
||||||
@@ -232,8 +232,24 @@ pub fn fail_hosted_loading(key: &LoadingBarId, error: &str) {
|
|||||||
&& let Some(mut bar) = state.loading_bars.get_mut(&key.0)
|
&& let Some(mut bar) = state.loading_bars.get_mut(&key.0)
|
||||||
{
|
{
|
||||||
match &mut bar.bar_type {
|
match &mut bar.bar_type {
|
||||||
LoadingBarType::HostedPackSync { error: failure, .. }
|
LoadingBarType::HostedPackSync {
|
||||||
| LoadingBarType::HostedModDownload { error: failure, .. } => {
|
error: failure,
|
||||||
|
state,
|
||||||
|
can_pause,
|
||||||
|
can_cancel,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
use crate::api::pack::hosted::HostedTaskState;
|
||||||
|
*state = if *state == HostedTaskState::Canceling {
|
||||||
|
HostedTaskState::Canceled
|
||||||
|
} else {
|
||||||
|
HostedTaskState::Failed
|
||||||
|
};
|
||||||
|
*can_pause = false;
|
||||||
|
*can_cancel = false;
|
||||||
|
*failure = Some(error.to_owned());
|
||||||
|
}
|
||||||
|
LoadingBarType::HostedModDownload { error: failure, .. } => {
|
||||||
*failure = Some(error.to_owned())
|
*failure = Some(error.to_owned())
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -241,6 +257,67 @@ pub fn fail_hosted_loading(key: &LoadingBarId, error: &str) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update the authoritative snapshot and notify observers even while all
|
||||||
|
/// transfer futures are paused and no byte-progress event can be produced.
|
||||||
|
pub fn set_hosted_loading_state(
|
||||||
|
instance_id: &str,
|
||||||
|
next: crate::api::pack::hosted::HostedTaskState,
|
||||||
|
) {
|
||||||
|
use crate::api::pack::hosted::HostedTaskState;
|
||||||
|
let Ok(event_state) = crate::EventState::get() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for mut bar in event_state.loading_bars.iter_mut() {
|
||||||
|
let LoadingBarType::HostedPackSync {
|
||||||
|
instance_id: id,
|
||||||
|
state,
|
||||||
|
can_pause,
|
||||||
|
can_cancel,
|
||||||
|
..
|
||||||
|
} = &mut bar.bar_type
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if id != instance_id {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
*state = next;
|
||||||
|
let controllable =
|
||||||
|
matches!(next, HostedTaskState::Running | HostedTaskState::Paused);
|
||||||
|
*can_pause = controllable;
|
||||||
|
*can_cancel = controllable;
|
||||||
|
match next {
|
||||||
|
HostedTaskState::Paused => {
|
||||||
|
bar.message = "已暂停,点击继续以恢复下载".into()
|
||||||
|
}
|
||||||
|
HostedTaskState::Canceling => {
|
||||||
|
bar.message = "正在停止整合包下载".into()
|
||||||
|
}
|
||||||
|
HostedTaskState::Running => {
|
||||||
|
bar.message = "正在继续整合包下载".into()
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
#[cfg(feature = "tauri")]
|
||||||
|
{
|
||||||
|
let _ = event_state.app.emit(
|
||||||
|
"loading",
|
||||||
|
LoadingPayload {
|
||||||
|
fraction: Some(if bar.total > 0.0 {
|
||||||
|
bar.current / bar.total
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
}),
|
||||||
|
message: bar.message.clone(),
|
||||||
|
event: bar.bar_type.clone(),
|
||||||
|
loader_uuid: bar.loading_bar_uuid,
|
||||||
|
total: Some(bar.total),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// emit_warning(message)
|
// emit_warning(message)
|
||||||
pub async fn emit_warning(message: &str) -> crate::Result<()> {
|
pub async fn emit_warning(message: &str) -> crate::Result<()> {
|
||||||
#[cfg(feature = "tauri")]
|
#[cfg(feature = "tauri")]
|
||||||
|
|||||||
@@ -142,6 +142,12 @@ pub enum LoadingBarType {
|
|||||||
instance_id: String,
|
instance_id: String,
|
||||||
instance_name: String,
|
instance_name: String,
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
state: crate::api::pack::hosted::HostedTaskState,
|
||||||
|
#[serde(default)]
|
||||||
|
can_pause: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
can_cancel: bool,
|
||||||
},
|
},
|
||||||
HostedModDownload {
|
HostedModDownload {
|
||||||
instance_id: String,
|
instance_id: String,
|
||||||
|
|||||||
@@ -689,6 +689,46 @@ fn bounded_diagnostic_text(value: impl AsRef<str>, max_chars: usize) -> String {
|
|||||||
value.as_ref().chars().take(max_chars).collect()
|
value.as_ref().chars().take(max_chars).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sanitize_download_error_text(value: &str) -> String {
|
||||||
|
static URLS: LazyLock<regex::Regex> = LazyLock::new(|| {
|
||||||
|
regex::Regex::new(r"(?i)\b(?:https?|socks4a?|socks5h?)://[^\s]+")
|
||||||
|
.expect("download diagnostic URL pattern should be valid")
|
||||||
|
});
|
||||||
|
let sanitized = URLS.replace_all(value, |matched: ®ex::Captures<'_>| {
|
||||||
|
let url = &matched[0];
|
||||||
|
// A malformed URL must not fall back to logging its userinfo. The
|
||||||
|
// normal helper also strips signed query strings and fragments.
|
||||||
|
if Url::parse(url).is_ok() {
|
||||||
|
sanitize_url_for_log(url)
|
||||||
|
} else {
|
||||||
|
"[redacted URL]".to_string()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
bounded_diagnostic_text(sanitized, 256)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn download_transport_error_detail(source: &reqwest::Error) -> String {
|
||||||
|
// reqwest's Display stops before the useful DNS/TLS/socket cause and can
|
||||||
|
// include a signed request URL. Walk only its underlying causes instead.
|
||||||
|
let mut details = Vec::new();
|
||||||
|
let mut cause = source.source();
|
||||||
|
while let Some(error) = cause {
|
||||||
|
let detail = sanitize_download_error_text(&error.to_string());
|
||||||
|
if details.last() != Some(&detail) {
|
||||||
|
details.push(detail);
|
||||||
|
}
|
||||||
|
if details.len() == 8 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
cause = error.source();
|
||||||
|
}
|
||||||
|
if details.is_empty() {
|
||||||
|
sanitize_download_error_text(&source.to_string())
|
||||||
|
} else {
|
||||||
|
bounded_diagnostic_text(details.join(": "), 256)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn download_error_category(error: &crate::Error) -> &'static str {
|
fn download_error_category(error: &crate::Error) -> &'static str {
|
||||||
match error.raw.as_ref() {
|
match error.raw.as_ref() {
|
||||||
ErrorKind::FetchError(source) => {
|
ErrorKind::FetchError(source) => {
|
||||||
@@ -750,7 +790,16 @@ fn download_error_category(error: &crate::Error) -> &'static str {
|
|||||||
fn download_error_detail(error: &crate::Error) -> String {
|
fn download_error_detail(error: &crate::Error) -> String {
|
||||||
match error.raw.as_ref() {
|
match error.raw.as_ref() {
|
||||||
ErrorKind::FetchError(source) => source.status().map_or_else(
|
ErrorKind::FetchError(source) => source.status().map_or_else(
|
||||||
|| format!("{} failure", download_error_category(error)),
|
|| {
|
||||||
|
bounded_diagnostic_text(
|
||||||
|
format!(
|
||||||
|
"{} failure: {}",
|
||||||
|
download_error_category(error),
|
||||||
|
download_transport_error_detail(source)
|
||||||
|
),
|
||||||
|
256,
|
||||||
|
)
|
||||||
|
},
|
||||||
|status| format!("HTTP {}", status.as_u16()),
|
|status| format!("HTTP {}", status.as_u16()),
|
||||||
),
|
),
|
||||||
ErrorKind::LabrinthError(error) => error.status.map_or_else(
|
ErrorKind::LabrinthError(error) => error.status.map_or_else(
|
||||||
@@ -929,8 +978,10 @@ fn add_starlight_direct_recovery_route(routes: &mut Vec<DownloadRoute>) {
|
|||||||
.filter(|route| {
|
.filter(|route| {
|
||||||
route.proxy == ProxyPolicy::System
|
route.proxy == ProxyPolicy::System
|
||||||
&& Url::parse(&route.url).ok().is_some_and(|url| {
|
&& Url::parse(&route.url).ok().is_some_and(|url| {
|
||||||
url.host_str() == Some("skin.starlight.cool")
|
matches!(
|
||||||
&& url.path().starts_with("/starlight/mod/packs/files/")
|
url.host_str(),
|
||||||
|
Some("skin.starlight.cool" | "download.starlight.cool")
|
||||||
|
) && url.path().starts_with("/starlight/mod/packs/files/")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.cloned()
|
.cloned()
|
||||||
@@ -6308,7 +6359,9 @@ async fn download_to_path_inner(
|
|||||||
source = route.source.as_str(),
|
source = route.source.as_str(),
|
||||||
attempt = attempts,
|
attempt = attempts,
|
||||||
max_attempts = file_attempt_budget,
|
max_attempts = file_attempt_budget,
|
||||||
error = %error,
|
proxy = ?route.proxy,
|
||||||
|
category = download_error_category(&error),
|
||||||
|
error = %download_error_detail(&error),
|
||||||
"File download request failed; trying the next source or retry"
|
"File download request failed; trying the next source or retry"
|
||||||
);
|
);
|
||||||
last_error = Some(error);
|
last_error = Some(error);
|
||||||
@@ -8857,18 +8910,86 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn starlight_hosted_files_have_a_direct_recovery_route() {
|
fn starlight_hosted_files_have_a_direct_recovery_route() {
|
||||||
let mut routes = vec![official_route(
|
for url in [
|
||||||
"https://skin.starlight.cool/starlight/mod/packs/files/release/hash",
|
"https://skin.starlight.cool/starlight/mod/packs/files/release/hash",
|
||||||
ResourceClass::Modpack,
|
"https://download.starlight.cool:20443/starlight/mod/packs/files/4/hash",
|
||||||
)];
|
] {
|
||||||
|
let mut routes = vec![official_route(url, ResourceClass::Modpack)];
|
||||||
|
add_starlight_direct_recovery_route(&mut routes);
|
||||||
|
add_starlight_direct_recovery_route(&mut routes);
|
||||||
|
|
||||||
add_starlight_direct_recovery_route(&mut routes);
|
assert_eq!(routes.len(), 2, "{url}");
|
||||||
|
assert_eq!(routes[0].proxy, ProxyPolicy::System);
|
||||||
|
assert_eq!(routes[1].proxy, ProxyPolicy::Direct);
|
||||||
|
assert_eq!(routes[0].url, routes[1].url);
|
||||||
|
assert!(routes[1].allow_sensitive_headers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
assert_eq!(routes.len(), 2);
|
#[test]
|
||||||
assert_eq!(routes[0].proxy, ProxyPolicy::System);
|
fn starlight_direct_recovery_excludes_other_paths_and_lookalike_hosts() {
|
||||||
assert_eq!(routes[1].proxy, ProxyPolicy::Direct);
|
for url in [
|
||||||
assert_eq!(routes[0].url, routes[1].url);
|
"https://download.starlight.cool:20443/starlight/mod/packs",
|
||||||
assert!(routes[1].allow_sensitive_headers);
|
"https://download.starlight.cool.evil.example/starlight/mod/packs/files/4/hash",
|
||||||
|
"https://evil.example/starlight/mod/packs/files/4/hash",
|
||||||
|
"https://skin.starlight.cool/yggdrasil",
|
||||||
|
] {
|
||||||
|
let mut routes = vec![official_route(url, ResourceClass::Modpack)];
|
||||||
|
add_starlight_direct_recovery_route(&mut routes);
|
||||||
|
assert_eq!(routes.len(), 1, "{url}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn download_transport_diagnostic_preserves_causes_without_url_secrets()
|
||||||
|
{
|
||||||
|
let listener =
|
||||||
|
tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let address = listener.local_addr().unwrap();
|
||||||
|
drop(listener);
|
||||||
|
let url = format!(
|
||||||
|
"http://username:password@{address}/file?token=secret#fragment"
|
||||||
|
);
|
||||||
|
let error: crate::Error = reqwest::Client::builder()
|
||||||
|
.no_proxy()
|
||||||
|
.build()
|
||||||
|
.unwrap()
|
||||||
|
.get(url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap_err()
|
||||||
|
.into();
|
||||||
|
let detail = download_error_detail(&error);
|
||||||
|
assert_eq!(download_error_category(&error), "connect");
|
||||||
|
assert!(detail.contains("connect"), "{detail}");
|
||||||
|
assert!(
|
||||||
|
detail.contains("tcp") || detail.contains("os error"),
|
||||||
|
"{detail}"
|
||||||
|
);
|
||||||
|
for secret in ["username", "password", "token", "secret", "fragment"] {
|
||||||
|
assert!(!detail.contains(secret), "{detail}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn download_transport_diagnostic_scrubs_embedded_urls() {
|
||||||
|
let detail = sanitize_download_error_text(
|
||||||
|
"request https://username:password@example.com/file?token=secret#fragment failed: connecting to http://proxyuser:proxypass@proxy.example:8080/?key=proxysecret: connection refused",
|
||||||
|
);
|
||||||
|
assert!(detail.contains("https://example.com/file"), "{detail}");
|
||||||
|
assert!(detail.contains("http://proxy.example:8080/"), "{detail}");
|
||||||
|
assert!(detail.contains("connection refused"), "{detail}");
|
||||||
|
for secret in [
|
||||||
|
"username",
|
||||||
|
"password",
|
||||||
|
"token",
|
||||||
|
"secret",
|
||||||
|
"fragment",
|
||||||
|
"proxyuser",
|
||||||
|
"proxypass",
|
||||||
|
] {
|
||||||
|
assert!(!detail.contains(secret), "{detail}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in new issue
Block a user