fix: ditch files v0

This commit is contained in:
Calum H. (IMB11)
2026-06-26 17:09:23 +01:00
parent 2e08fe7950
commit ef07172006
21 changed files with 308 additions and 380 deletions
@@ -407,6 +407,7 @@ export type PendingChange = {
type ServerListingProps = {
server_id: string
worldId?: string | null
name: string
status: Archon.Servers.v0.Status
suspension_reason?: Archon.Servers.v0.SuspensionReason | null
@@ -546,16 +547,28 @@ async function dataURLToBlob(dataURL: string): Promise<Blob> {
return res.blob()
}
async function getActiveWorldId(serverId: string): Promise<string | null> {
const server = await archon.servers_v1.get(serverId)
const activeWorld = server.worlds.find((world) => world.is_active)
return activeWorld?.id ?? server.worlds[0]?.id ?? null
}
async function uploadWorldFile(worldId: string, path: string, file: File | Blob) {
await kyros.files_v1.ensureFile(worldId, path)
await kyros.files_v1.uploadFile(worldId, path, file).promise
}
const { data: image } = useQuery({
queryKey: ['server-icon', props.server_id] as const,
queryKey: computed(() => ['server-icon', props.server_id, props.worldId ?? null] as const),
queryFn: async (): Promise<string | null> => {
if (!props.server_id || props.status !== 'available') return null
try {
const fsAuth = await archon.servers_v0.getFilesystemAuth(props.server_id)
const worldId = props.worldId ?? (await getActiveWorldId(props.server_id))
if (!worldId) return null
try {
const blob = await kyros.files_v0.downloadFileWithAuth(fsAuth, '/server-icon.png')
const blob = await kyros.files_v1.downloadRawFileContents(worldId, '/server-icon.png')
return await processImageBlob(blob, 64)
} catch (error) {
const statusCode = (error as { statusCode?: number })?.statusCode
@@ -564,8 +577,8 @@ const { data: image } = useQuery({
}
try {
const originalBlob = await kyros.files_v0.downloadFileWithAuth(
fsAuth,
const originalBlob = await kyros.files_v1.downloadRawFileContents(
worldId,
'/server-icon-original.png',
)
return await processImageBlob(originalBlob, 64)
@@ -585,13 +598,12 @@ const { data: image } = useQuery({
const scaledBlob = await dataURLToBlob(scaledDataUrl)
const scaledFile = new File([scaledBlob], 'server-icon.png', { type: 'image/png' })
await kyros.files_v0.uploadFileWithAuth(fsAuth, '/server-icon.png', scaledFile).promise
await uploadWorldFile(worldId, '/server-icon.png', scaledFile)
const originalFile = new File([blob], 'server-icon-original.png', {
type: 'image/png',
})
await kyros.files_v0.uploadFileWithAuth(fsAuth, '/server-icon-original.png', originalFile)
.promise
await uploadWorldFile(worldId, '/server-icon-original.png', originalFile)
return scaledDataUrl
}
@@ -73,7 +73,7 @@ const props = withDefaults(
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const { serverId, server } = injectModrinthServerContext()
const { serverId, server, worldId } = injectModrinthServerContext()
const queryClient = useQueryClient()
const isUploadingIcon = ref(false)
const isSyncingIcon = ref(false)
@@ -95,6 +95,7 @@ const {
computed(() => server.value?.upstream ?? null),
{
includeProjectFallback: false,
worldId,
},
)
@@ -107,6 +108,19 @@ function isNotFound(error: unknown): boolean {
return getStatusCode(error) === 404
}
function getWorldId() {
if (!worldId.value) {
throw new Error('World ID is not available.')
}
return worldId.value
}
async function uploadWorldFile(path: string, file: File | Blob) {
const id = getWorldId()
await client.kyros.files_v1.ensureFile(id, path)
await client.kyros.files_v1.uploadFile(id, path, file).promise
}
const uploadFile = async (e: Event) => {
if (isIconActionDisabled.value) return
@@ -144,39 +158,11 @@ const uploadFile = async (e: Event) => {
img.src = URL.createObjectURL(file)
})
const fsAuth = await client.archon.servers_v0.getFilesystemAuth(serverId)
try {
await client.kyros.files_v0.uploadFileWithAuth(fsAuth, '/server-icon.png', scaledFile).promise
} catch (scaledUploadError) {
// Node FS may reject create when file already exists. Delete and retry once.
try {
await client.kyros.files_v0.deleteFileOrFolderWithAuth(fsAuth, '/server-icon.png', false)
} catch (deleteError) {
if (!isNotFound(deleteError)) {
throw scaledUploadError
}
}
await client.kyros.files_v0.uploadFileWithAuth(fsAuth, '/server-icon.png', scaledFile).promise
}
await uploadWorldFile('/server-icon.png', scaledFile)
// Keep original file in sync when possible, but don't block icon updates on failures here.
try {
await client.kyros.files_v0.deleteFileOrFolderWithAuth(
fsAuth,
'/server-icon-original.png',
false,
)
} catch (deleteOriginalError) {
if (!isNotFound(deleteOriginalError)) {
// best effort
}
}
try {
await client.kyros.files_v0.uploadFileWithAuth(fsAuth, '/server-icon-original.png', file)
.promise
await uploadWorldFile('/server-icon-original.png', file)
} catch (originalUploadError) {
if (!isNotFound(originalUploadError)) {
// best effort
@@ -222,10 +208,10 @@ const resetIcon = async () => {
isSyncingIcon.value = true
try {
const fsAuth = await client.archon.servers_v0.getFilesystemAuth(serverId)
const id = getWorldId()
const deleteResults = await Promise.allSettled([
client.kyros.files_v0.deleteFileOrFolderWithAuth(fsAuth, '/server-icon.png', false),
client.kyros.files_v0.deleteFileOrFolderWithAuth(fsAuth, '/server-icon-original.png', false),
client.kyros.files_v1.deleteFile(id, '/server-icon.png'),
client.kyros.files_v1.deleteFile(id, '/server-icon-original.png'),
])
for (const result of deleteResults) {
@@ -370,7 +370,7 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
dismissedOpIds.value = new Set([...dismissedOpIds.value, opId])
}
try {
await client.kyros.files_v0.modifyOperation(opId, action)
await client.kyros.files_v1.modifyOperation(opId, action)
} catch (error) {
if (action === 'dismiss') return
console.error(`Failed to ${action} operation:`, error)
@@ -6,11 +6,13 @@ import { injectModrinthClient } from '#ui/providers'
type UpstreamRef = ComputedRef<Archon.Servers.v0.Server['upstream'] | null | undefined>
type ServerIdSource = string | { readonly value: string }
type WorldIdSource = string | null | undefined | { readonly value: string | null | undefined }
type UseServerImageOptions = {
enabled?: ComputedRef<boolean> | boolean
size?: number
includeProjectFallback?: boolean
worldId?: WorldIdSource
}
export async function processImageBlob(blob: Blob, size: number): Promise<string> {
@@ -49,6 +51,7 @@ export function useServerImage(
const iconSize = options.size ?? 512
const includeProjectFallback = options.includeProjectFallback ?? false
const resolvedServerId = computed(() => resolveServerId(serverId))
const resolvedWorldId = computed(() => resolveWorldId(options.worldId))
const queryKey = computed(
() =>
@@ -57,6 +60,7 @@ export function useServerImage(
'detail',
resolvedServerId.value,
'icon',
resolvedWorldId.value ?? 'active',
upstream.value?.project_id ?? null,
] as const,
)
@@ -74,18 +78,22 @@ export function useServerImage(
if (!id) return null
try {
const fsAuth = await client.archon.servers_v0.getFilesystemAuth(id)
const targetWorldId = resolvedWorldId.value ?? (await getActiveWorldId(id))
if (!targetWorldId) return null
try {
const blob = await client.kyros.files_v0.downloadFileWithAuth(fsAuth, '/server-icon.png')
const blob = await client.kyros.files_v1.downloadRawFileContents(
targetWorldId,
'/server-icon.png',
)
return await processImageBlob(blob, iconSize)
} catch (error) {
if (!isNotFound(error)) throw error
}
try {
const blob = await client.kyros.files_v0.downloadFileWithAuth(
fsAuth,
const blob = await client.kyros.files_v1.downloadRawFileContents(
targetWorldId,
'/server-icon-original.png',
)
return await processImageBlob(blob, iconSize)
@@ -94,7 +102,6 @@ export function useServerImage(
}
} catch (error) {
console.debug('Server image fetch failed:', error)
return null
}
if (!includeProjectFallback || !upstream.value?.project_id) return null
@@ -133,6 +140,12 @@ export function useServerImage(
localImage.value = undefined
}
async function getActiveWorldId(id: string): Promise<string | null> {
const server = await client.archon.servers_v1.get(id)
const activeWorld = server.worlds.find((world) => world.is_active)
return activeWorld?.id ?? server.worlds[0]?.id ?? null
}
return {
image,
queryKey,
@@ -146,3 +159,9 @@ export function useServerImage(
function resolveServerId(serverId: ServerIdSource): string {
return typeof serverId === 'string' ? serverId : serverId.value
}
function resolveWorldId(worldId: WorldIdSource): string | null {
if (worldId == null) return null
if (typeof worldId === 'string') return worldId
return worldId.value ?? null
}
@@ -121,6 +121,7 @@ const { formatMessage } = useVIntl()
const props = withDefaults(
defineProps<{
worldId: string
disabled?: boolean
disabledTooltip?: string
}>(),
@@ -187,14 +188,6 @@ const messages = defineMessages({
id: 'files.zip-url-modal.error-url-invalid',
defaultMessage: 'URL must be valid.',
},
cfNotFoundTitle: {
id: 'files.zip-url-modal.cf-not-found-title',
defaultMessage: 'CurseForge modpack not found',
},
cfNotFoundText: {
id: 'files.zip-url-modal.cf-not-found-text',
defaultMessage: 'Could not find CurseForge modpack at that URL.',
},
installFailedTitle: {
id: 'files.zip-url-modal.install-failed-title',
defaultMessage: 'Installation failed',
@@ -265,19 +258,16 @@ const handleSubmit = async () => {
submitted.value = true
try {
const dry = await client.kyros.files_v0.extractFile(trimmedUrl.value, true, true)
if (!cf.value || dry.modpack_name) {
await client.kyros.files_v0.extractFile(trimmedUrl.value, true, false)
hide()
} else {
submitted.value = false
addNotification({
title: formatMessage(messages.cfNotFoundTitle),
text: formatMessage(messages.cfNotFoundText),
type: 'error',
})
const stream = await client.kyros.files_v1.unzipFile(props.worldId, {
source: { type: 'zip_url', url: trimmedUrl.value },
target: '/',
})
const reader = stream.getReader()
while (true) {
const { done } = await reader.read()
if (done) break
}
hide()
} catch (err) {
submitted.value = false
console.error('Error installing:', err)
@@ -181,12 +181,13 @@ interface UploadItem {
| 'cancelled'
| 'incorrect-type'
size: string
uploader?: ReturnType<typeof client.kyros.files_v0.uploadFile>
uploader?: ReturnType<typeof client.kyros.files_v1.uploadFile>
error?: Error
}
interface Props {
currentPath: string
worldId: string
fileType?: string
marginBottom?: number
acceptedTypes?: Array<string>
@@ -281,11 +282,12 @@ const uploadFile = async (file: File) => {
uploadItem.status = 'uploading'
const filePath = `${props.currentPath}/${file.name}`.replace('//', '/')
const uploader = client.kyros.files_v0.uploadFile(filePath, file, {
await client.kyros.files_v1.ensureFile(props.worldId, filePath)
const uploader = client.kyros.files_v1.uploadFile(props.worldId, filePath, file, {
onProgress: ({ progress }) => {
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name)
if (index !== -1) {
uploadQueue.value[index].progress = Math.round(progress)
uploadQueue.value[index].progress = Math.round(progress * 100)
}
},
})
@@ -4,8 +4,9 @@
<FileCreateItemModal ref="createItemModal" :type="newItemType" @create="handleCreateNewItem" />
<FileUploadConflictModal ref="uploadConflictModal" @proceed="handleExtractConfirm" />
<FileUploadZipUrlModal
v-if="ctx.showInstallFromUrl"
v-if="showInstallFromUrl"
ref="uploadZipUrlModal"
:world-id="installFromUrlWorldId"
:disabled="isBusy"
:disabled-tooltip="busyTooltip"
/>
@@ -48,7 +49,7 @@
:is-editor-find-open="fileEditorRef?.isFindOpen"
:search-query="searchQuery"
:show-refresh-button="showRefreshButton"
:show-install-from-url="ctx.showInstallFromUrl"
:show-install-from-url="showInstallFromUrl"
:base-id="baseId"
:disabled="isBusy"
:disabled-tooltip="busyTooltip"
@@ -378,6 +379,8 @@ const selectedItem = ref<FileItem | null>(null)
const unsavedChangesModal = ref<InstanceType<typeof FileUnsavedChangesModal>>()
const hasUnsavedChanges = computed(() => fileEditorRef.value?.hasUnsavedChanges ?? false)
const installFromUrlWorldId = computed(() => ctx.worldId?.value ?? '')
const showInstallFromUrl = computed(() => !!ctx.showInstallFromUrl && !!installFromUrlWorldId.value)
async function confirmDiscardChanges(): Promise<boolean> {
if (!hasUnsavedChanges.value) return true
@@ -35,6 +35,7 @@ export interface FileManagerContext {
uploadFiles: (files: File[]) => void
cancelUpload?: () => void
uploadState?: Ref<UploadState> | ComputedRef<UploadState>
worldId?: Ref<string | null> | ComputedRef<string | null>
refresh: () => void
@@ -818,6 +818,7 @@ const { disconnect: disconnectPanelSync } = useServerPanelSync({
const { image: serverImage } = useServerImage(
props.serverId,
computed(() => serverData.value?.upstream ?? null),
{ worldId },
)
const { data: serverProject } = useServerProject(computed(() => serverData.value?.upstream ?? null))
@@ -1228,7 +1229,7 @@ const handleFilesystemOps = (data: Archon.Websocket.v0.WSFilesystemOpsEvent) =>
const cancelled = allOps.filter((x) => x.state === 'cancelled')
Promise.all(
cancelled.map((x) =>
client.kyros.files_v0.modifyOperation(x.id, 'dismiss').catch((error) => {
client.kyros.files_v1.modifyOperation(x.id, 'dismiss').catch((error) => {
console.error('Failed to dismiss cancelled operation:', error)
}),
),
@@ -1260,20 +1261,25 @@ const handleInstallationResult = async (data: Archon.Websocket.v0.WSInstallation
installError.value = new Error(errorMessage.value)
try {
let files = await client.kyros.files_v0.listDirectory('/', 1, 100)
if (files && files.total > 1) {
for (let i = 2; i <= files.total; i++) {
const nextFiles = await client.kyros.files_v0.listDirectory('/', i, 100)
if (nextFiles?.items?.length === 0) break
if (nextFiles) files = nextFiles
if (!worldId.value) break
let files = await client.kyros.files_v1.listDescendants(worldId.value, '/', 1, 100)
for (let i = 2; i <= files.page_total; i++) {
const nextFiles = await client.kyros.files_v1.listDescendants(worldId.value, '/', i, 100)
if (nextFiles.items.length === 0) break
files = {
...nextFiles,
items: [...files.items, ...nextFiles.items],
}
}
const fileName = files?.items?.find((file) =>
const file = files.items.find((file) =>
file.name.startsWith('modrinth-installation'),
)?.name
errorLogFile.value = fileName ?? ''
if (fileName) {
const content = await client.kyros.files_v0.downloadFile(fileName)
)
errorLogFile.value = file?.full_path ?? ''
if (file) {
const content = await client.kyros.files_v1.downloadRawFileContents(
worldId.value,
file.full_path,
)
errorLog.value = await content.text()
}
} catch (err) {
@@ -326,9 +326,11 @@ const moveMutation = useMutation({
})
const createMutation = useMutation({
mutationFn: (_vars: { path: string; type: 'file' | 'directory' }) => {
// await client.kyros.files_v0.createFileOrFolder(path, type)
throw new Error('Creating files or folders is not supported by the v1 world-scoped files API.')
mutationFn: ({ path, type }: { path: string; type: 'file' | 'directory' }) => {
const id = getWorldId()
return type === 'directory'
? client.kyros.files_v1.mkdirFile(id, path)
: client.kyros.files_v1.touchFile(id, path)
},
onError: (err: Error) => {
addNotification({
@@ -337,6 +339,9 @@ const createMutation = useMutation({
type: 'error',
})
},
onSettled: () => {
refreshList()
},
})
// File I/O
@@ -344,7 +349,7 @@ async function readFile(path: string): Promise<string> {
const normalizedPath = path.startsWith('/') ? path : `/${path}`
const id = getWorldId()
const cachedContent = queryClient.getQueryData<string>(['file-content', id, normalizedPath])
if (cachedContent) return cachedContent
if (cachedContent != null) return cachedContent
const blob = await client.kyros.files_v1.downloadRawFileContents(id, normalizedPath)
return await blob.text()
}
@@ -356,10 +361,11 @@ async function readFileAsBlob(path: string): Promise<Blob> {
async function writeFile(path: string, content: string): Promise<void> {
if (fileWriteDisabled.value) return
void path
void content
// await client.kyros.files_v0.updateFile(path, content)
throw new Error('Updating file contents is not supported by the v1 world-scoped files API.')
const normalizedPath = path.startsWith('/') ? path : `/${path}`
const id = getWorldId()
await client.kyros.files_v1.editFile(id, normalizedPath, content)
queryClient.setQueryData(['file-content', id, normalizedPath], content)
refreshList()
}
async function downloadFile(path: string, fileName: string): Promise<void> {
@@ -462,14 +468,14 @@ provideFileManager({
uploadFiles,
cancelUpload,
uploadState,
worldId,
refresh: refreshList,
isBusy: fileWriteDisabled,
busyTooltip: fileWriteDisabledTooltip,
busyWarning,
// extractFile: async (path, override, dry) => client.kyros.files_v0.extractFile(path, override, dry),
prefetchDirectory,
prefetchFile,
showInstallFromUrl: false,
showInstallFromUrl: true,
canRestart: canUsePowerActions.value,
restartServer,
canShareToMclogs: true,
@@ -58,6 +58,7 @@ const client = injectModrinthClient()
const {
server: _serverData,
serverId,
worldId,
isConnected,
isWsAuthIncorrect,
stats,
@@ -83,9 +84,13 @@ const isDismissed = () => Date.now() < dismissedUntil.value
const inspectError = async () => {
if (isDismissed()) return
if (!worldId.value) return
try {
const blob = await client.kyros.files_v0.downloadFile('/logs/latest.log')
const blob = await client.kyros.files_v1.downloadRawFileContents(
worldId.value,
'/logs/latest.log',
)
const log = await blob.text()
if (!log) return
@@ -180,6 +180,7 @@
v-for="server in ownedFilteredData.filter((s) => !s.is_medal)"
:key="`owned-${server.server_id}`"
v-bind="server"
:world-id="getServerWorldId(server.server_id)"
:cancellation-date="serverBillingMap.get(server.server_id)?.cancellationDate"
:is-provisioning="serverBillingMap.get(server.server_id)?.isProvisioning"
:on-resubscribe="serverBillingMap.get(server.server_id)?.onResubscribe"
@@ -211,6 +212,7 @@
v-for="server in sharedFilteredData.filter((s) => !s.is_medal)"
:key="`shared-${server.server_id}`"
v-bind="server"
:world-id="getServerWorldId(server.server_id)"
/>
</TransitionGroup>
<div v-else class="text-secondary">
@@ -762,6 +764,12 @@ const { data: serverFullList } = useQuery({
enabled: loggedIn,
})
function getServerWorldId(serverId: string): string | null {
const server = serverFullList.value?.find((server) => server.id === serverId)
const activeWorld = server?.worlds.find((world) => world.is_active)
return activeWorld?.id ?? server?.worlds[0]?.id ?? null
}
type ServerBillingInfo = {
cancellationDate?: string | null
isProvisioning?: boolean