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
+4 -2
View File
@@ -40,7 +40,8 @@ client.archon.servers_v1
client.archon.backups_queue_v1
client.archon.backups_v1
client.archon.content_v0
client.kyros.files_v0
client.kyros.files_v1
client.kyros.upload_sessions_v1
client.iso3166.data
... etc.
```
@@ -140,7 +141,8 @@ Uploads go through the feature chain (auth, retry, etc.). Features detect upload
### Usage Example (server file upload)
```ts
const uploader = client.kyros.files_v0.uploadFile(path, file, {
await client.kyros.files_v1.ensureFile(worldId, path)
const uploader = client.kyros.files_v1.uploadFile(worldId, path, file, {
onProgress: ({ progress }) => {
uploadProgress.value = Math.round(progress * 100)
},
+2 -1
View File
@@ -147,7 +147,8 @@ Built-in features include authentication, node auth, retries, circuit breaking,
Upload endpoints return an `UploadHandle<T>` with progress and cancellation support:
```ts
const upload = client.kyros.files_v0.uploadFile(path, file)
await client.kyros.files_v1.ensureFile(worldId, path)
const upload = client.kyros.files_v1.uploadFile(worldId, path, file)
upload.onProgress(({ progress }) => {
console.log(Math.round(progress * 100))
-2
View File
@@ -14,7 +14,6 @@ import { ArchonServersV1Module } from './archon/servers/v1'
import { ArchonTransfersInternalModule } from './archon/transfers/internal'
import { ISO3166Module } from './iso3166'
import { KyrosContentV1Module } from './kyros/content/v1'
import { KyrosFilesV0Module } from './kyros/files/v0'
import { KyrosFilesV1Module } from './kyros/files/v1'
import { KyrosLogsV1Module } from './kyros/logs/v1'
import { KyrosUploadSessionsV1Module } from './kyros/upload-sessions/v1'
@@ -88,7 +87,6 @@ export const MODULE_REGISTRY = {
mclogs_logs_v1: MclogsLogsV1Module,
launchermeta_manifest_v0: LauncherMetaManifestV0Module,
kyros_content_v1: KyrosContentV1Module,
kyros_files_v0: KyrosFilesV0Module,
kyros_files_v1: KyrosFilesV1Module,
kyros_logs_v1: KyrosLogsV1Module,
kyros_upload_sessions_v1: KyrosUploadSessionsV1Module,
@@ -1,232 +0,0 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { UploadHandle, UploadProgress } from '../../../types/upload'
import { getNodeBaseUrl } from '../../../utils/node-url'
import type { Archon } from '../../archon/types'
import type { Kyros } from '../types'
type NodeFsAuth = Pick<Archon.Servers.v0.JWTAuth, 'url' | 'token'>
export class KyrosFilesV0Module extends AbstractModule {
public getModuleID(): string {
return 'kyros_files_v0'
}
private getNodeBaseUrl(auth: NodeFsAuth): string {
return getNodeBaseUrl(auth.url)
}
/**
* List directory contents with pagination
*
* @param path - Directory path (e.g., "/")
* @param page - Page number (1-indexed)
* @param pageSize - Items per page
* @returns Directory listing with items and pagination info
*/
public async listDirectory(
path: string,
page: number = 1,
pageSize: number = 100,
): Promise<Kyros.Files.v0.DirectoryResponse> {
return this.client.request<Kyros.Files.v0.DirectoryResponse>('/fs/list', {
api: '',
version: 'modrinth/v0',
method: 'GET',
params: { path, page, page_size: pageSize },
useNodeAuth: true,
})
}
/**
* Create a file or directory
*
* @param path - Path for new item (e.g., "/new-folder")
* @param type - Type of item to create
*/
public async createFileOrFolder(path: string, type: 'file' | 'directory'): Promise<void> {
return this.client.request<void>('/fs/create', {
api: '',
version: 'modrinth/v0',
method: 'POST',
params: { path, type },
headers: { 'Content-Type': 'application/octet-stream' },
useNodeAuth: true,
})
}
/**
* Download a file from a server's filesystem
*
* @param path - File path (e.g., "/server-icon-original.png")
* @returns Promise resolving to file Blob
*/
public async downloadFile(path: string): Promise<Blob> {
return this.client.request<Blob>('/fs/download', {
api: '',
version: 'modrinth/v0',
method: 'GET',
params: { path },
useNodeAuth: true,
})
}
/**
* Download a file using explicit filesystem auth credentials.
*
* @param auth - Filesystem auth (url + token) from Archon
* @param path - File path (e.g., "/server-icon.png")
* @returns Promise resolving to file Blob
*/
public async downloadFileWithAuth(auth: NodeFsAuth, path: string): Promise<Blob> {
return this.client.request<Blob>('/fs/download', {
api: this.getNodeBaseUrl(auth),
version: 'modrinth/v0',
method: 'GET',
params: { path },
headers: { Authorization: `Bearer ${auth.token}` },
skipAuth: true,
})
}
/**
* Upload a file to a server's filesystem with progress tracking
*
* @param path - Destination path (e.g., "/server-icon.png")
* @param file - File to upload
* @param options - Optional progress callback and feature overrides
* @returns UploadHandle with promise, onProgress, and cancel
* @deprecated Use `kyros.upload_sessions_v1` for bulk uploads so cancellation can remove staged files before finalize.
*/
public uploadFile(
path: string,
file: File | Blob,
options?: {
onProgress?: (progress: UploadProgress) => void
retry?: boolean | number
},
): UploadHandle<void> {
return this.client.upload<void>('/fs/create', {
api: '',
version: 'modrinth/v0',
file,
params: { path, type: 'file' },
onProgress: options?.onProgress,
retry: options?.retry,
useNodeAuth: true,
})
}
/**
* Upload a file using explicit filesystem auth credentials.
*
* @param auth - Filesystem auth (url + token) from Archon
* @param path - Destination path (e.g., "/server-icon.png")
* @param file - File to upload
* @param options - Optional progress callback and feature overrides
* @returns UploadHandle with promise, onProgress, and cancel
*/
public uploadFileWithAuth(
auth: NodeFsAuth,
path: string,
file: File | Blob,
options?: {
onProgress?: (progress: UploadProgress) => void
retry?: boolean | number
},
): UploadHandle<void> {
return this.client.upload<void>('/fs/create', {
api: this.getNodeBaseUrl(auth),
version: 'modrinth/v0',
file,
params: { path, type: 'file' },
headers: { Authorization: `Bearer ${auth.token}` },
onProgress: options?.onProgress,
retry: options?.retry,
skipAuth: true,
})
}
/**
* Update file contents
*
* @param path - File path to update
* @param content - New file content (string or Blob)
*/
public async updateFile(path: string, content: string | Blob): Promise<void> {
const blob = typeof content === 'string' ? new Blob([content]) : content
return this.client.request<void>('/fs/update', {
api: '',
version: 'modrinth/v0',
method: 'PUT',
params: { path },
body: blob,
headers: { 'Content-Type': 'application/octet-stream' },
useNodeAuth: true,
})
}
/**
* Delete a file or folder using explicit filesystem auth credentials.
*
* @param auth - Filesystem auth (url + token) from Archon
* @param path - Path to delete
* @param recursive - If true, delete directory contents recursively
*/
public async deleteFileOrFolderWithAuth(
auth: NodeFsAuth,
path: string,
recursive: boolean,
): Promise<void> {
return this.client.request<void>('/fs/delete', {
api: this.getNodeBaseUrl(auth),
version: 'modrinth/v0',
method: 'DELETE',
params: { path, recursive },
headers: { Authorization: `Bearer ${auth.token}` },
skipAuth: true,
})
}
/**
* Extract an archive file (zip, tar, etc.)
*
* Uses v1 API endpoint.
*
* @param path - Path to archive file
* @param override - If true, overwrite existing files
* @param dry - If true, perform dry run (returns conflicts without extracting)
* @returns Extract result with modpack name and conflicting files
*/
public async extractFile(
path: string,
override: boolean = true,
dry: boolean = false,
): Promise<Kyros.Files.v0.ExtractResult> {
return this.client.request<Kyros.Files.v0.ExtractResult>('/fs/unarchive', {
api: '',
version: 'v1',
method: 'POST',
params: { src: path, trg: '/', override, dry },
useNodeAuth: true,
})
}
/**
* Modify a filesystem operation (dismiss or cancel)
*
* Uses v1 API endpoint.
*
* @param opId - Operation ID (UUID)
* @param action - Action to perform
*/
public async modifyOperation(opId: string, action: 'dismiss' | 'cancel'): Promise<void> {
return this.client.request<void>(`/fs/ops/${action}`, {
api: '',
version: 'v1',
method: 'POST',
params: { id: opId },
useNodeAuth: true,
})
}
}
@@ -1,4 +1,5 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { UploadHandle, UploadProgress } from '../../../types/upload'
import type { Kyros } from '../types'
export class KyrosFilesV1Module extends AbstractModule {
@@ -6,6 +7,25 @@ export class KyrosFilesV1Module extends AbstractModule {
return 'kyros_files_v1'
}
private isConflict(error: unknown): boolean {
const err = error as { statusCode?: number; response?: { status?: number } }
return (err.statusCode ?? err.response?.status) === 409
}
public async createDownloadSession(
worldId: string,
path: string,
zipped: boolean,
): Promise<void> {
return this.client.request<void>(`/worlds/${worldId}/files/contents`, {
api: '',
version: 'v1',
method: 'POST',
body: { path, zipped },
useNodeAuth: true,
})
}
public async listDescendants(
worldId: string,
path: string,
@@ -47,6 +67,70 @@ export class KyrosFilesV1Module extends AbstractModule {
})
}
public async editFile(worldId: string, path: string, content: string | Blob): Promise<void> {
const body = typeof content === 'string' ? new Blob([content]) : content
return this.client.request<void>(`/worlds/${worldId}/files/edit`, {
api: '',
version: 'v1',
method: 'POST',
params: { path },
body,
headers: { 'Content-Type': 'application/octet-stream' },
useNodeAuth: true,
})
}
public uploadFile(
worldId: string,
path: string,
file: File | Blob,
options?: {
onProgress?: (progress: UploadProgress) => void
retry?: boolean | number
},
): UploadHandle<void> {
return this.client.upload<void>(`/worlds/${worldId}/files/edit`, {
api: '',
version: 'v1',
file,
params: { path },
onProgress: options?.onProgress,
retry: options?.retry,
useNodeAuth: true,
})
}
public async touchFile(worldId: string, path: string): Promise<void> {
return this.client.request<void>(`/worlds/${worldId}/files/touch`, {
api: '',
version: 'v1',
method: 'POST',
body: { path },
useNodeAuth: true,
})
}
public async mkdirFile(worldId: string, path: string): Promise<void> {
return this.client.request<void>(`/worlds/${worldId}/files/mkdir`, {
api: '',
version: 'v1',
method: 'POST',
body: { path },
useNodeAuth: true,
})
}
public async ensureFile(worldId: string, path: string): Promise<void> {
try {
await this.touchFile(worldId, path)
} catch (error) {
if (!this.isConflict(error)) {
throw error
}
}
}
public async deleteFile(worldId: string, path: string): Promise<void> {
return this.client.request<void>(`/worlds/${worldId}/files/delete`, {
api: '',
@@ -90,4 +174,49 @@ export class KyrosFilesV1Module extends AbstractModule {
},
)
}
public unzipFile(
worldId: string,
request: Kyros.Files.v1.UnzipFileRequest,
): Promise<ReadableStream<Uint8Array>> {
return this.client.stream(`/worlds/${worldId}/files/unzip`, {
api: '',
version: 'v1',
method: 'POST',
body: request,
headers: { Accept: 'application/json-seq' },
useNodeAuth: true,
})
}
public uploadZip(
worldId: string,
path: string,
file: File | Blob,
options?: {
onProgress?: (progress: UploadProgress) => void
retry?: boolean | number
},
): UploadHandle<void> {
return this.client.upload<void>(`/worlds/${worldId}/files/upload-zip`, {
api: '',
version: 'v1',
file,
params: { path },
headers: { 'Content-Type': 'application/zip' },
onProgress: options?.onProgress,
retry: options?.retry,
useNodeAuth: true,
})
}
public async modifyOperation(opId: string, action: 'dismiss' | 'cancel'): Promise<void> {
return this.client.request<void>(`/fs/ops/${action}`, {
api: '',
version: 'v1',
method: 'POST',
params: { id: opId },
useNodeAuth: true,
})
}
}
+22 -29
View File
@@ -2,27 +2,25 @@ export namespace Kyros {
export namespace UploadSessions {
export namespace v1 {
export type Scope = 'content' | 'files'
export type UploadSessionStatus =
| 'active'
| 'uploading'
| 'finalizing'
| 'cancelled'
| 'finalized'
| 'expired'
export type UploadSessionFile = {
file: File | Blob
filename: string
}
export interface UploadSessionResponse {
upload_id: string
status: UploadSessionStatus
status: string
created_at: number
updated_at: number
last_upload_at: number | null
last_upload_at?: number | null
expires_at: number
entry_count: number
uploaded_byte_count: number
}
export interface GetUploadSessionResponse {
session: UploadSessionResponse | null
session?: UploadSessionResponse | null
}
}
}
@@ -31,6 +29,16 @@ export namespace Kyros {
export namespace v1 {
export type DescendantType = 'regular' | 'directory' | 'symlink' | 'other'
export type UnzipSource =
| {
type: 'zip_url'
url: string
}
| {
type: 'zip_path'
path: string
}
export interface CreateDownloadSessionRequest {
path: string
zipped: boolean
@@ -81,29 +89,14 @@ export namespace Kyros {
path: string
name: string
}
}
export namespace v0 {
export interface DirectoryItem {
name: string
type: 'file' | 'directory' | 'symlink'
export interface PathMutationRequest {
path: string
modified: number
created: number
size?: number
count?: number
target?: string
}
export interface DirectoryResponse {
items: DirectoryItem[]
total: number
current: number
}
export interface ExtractResult {
modpack_name: string | null
conflicting_files: string[]
export interface UnzipFileRequest {
source: UnzipSource
target: string
}
}
}
@@ -2,11 +2,6 @@ import { AbstractModule } from '../../../core/abstract-module'
import type { UploadHandle, UploadProgress } from '../../../types/upload'
import type { Kyros } from '../types'
export type UploadSessionFile = {
file: File | Blob
filename: string
}
export class KyrosUploadSessionsV1Module extends AbstractModule {
public getModuleID(): string {
return 'kyros_upload_sessions_v1'
@@ -46,7 +41,7 @@ export class KyrosUploadSessionsV1Module extends AbstractModule {
scope: Kyros.UploadSessions.v1.Scope,
worldId: string,
uploadId: string,
files: UploadSessionFile[],
files: Kyros.UploadSessions.v1.UploadSessionFile[],
options?: {
onProgress?: (progress: UploadProgress) => void
retry?: boolean | number
@@ -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