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
@@ -7,15 +7,17 @@ import {
import { useQueryClient } from '@tanstack/vue-query' import { useQueryClient } from '@tanstack/vue-query'
const client = injectModrinthClient() const client = injectModrinthClient()
const { serverId } = injectModrinthServerContext() const { worldId } = injectModrinthServerContext()
const queryClient = useQueryClient() const queryClient = useQueryClient()
try { try {
await queryClient.ensureQueryData({ if (worldId.value) {
queryKey: ['files', serverId, '/'], await queryClient.ensureQueryData({
queryFn: () => client.kyros.files_v0.listDirectory('/', 1, 2000), queryKey: ['files', 'v1', worldId.value, '/'],
staleTime: 30_000, queryFn: () => client.kyros.files_v1.listDescendants(worldId.value!, '/', 1, 200),
}) staleTime: 30_000,
})
}
} catch { } catch {
// Let mounted layouts' useQuery surface errors; do not fail route setup. // Let mounted layouts' useQuery surface errors; do not fail route setup.
} }
+4 -2
View File
@@ -40,7 +40,8 @@ client.archon.servers_v1
client.archon.backups_queue_v1 client.archon.backups_queue_v1
client.archon.backups_v1 client.archon.backups_v1
client.archon.content_v0 client.archon.content_v0
client.kyros.files_v0 client.kyros.files_v1
client.kyros.upload_sessions_v1
client.iso3166.data client.iso3166.data
... etc. ... etc.
``` ```
@@ -140,7 +141,8 @@ Uploads go through the feature chain (auth, retry, etc.). Features detect upload
### Usage Example (server file upload) ### Usage Example (server file upload)
```ts ```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 }) => { onProgress: ({ progress }) => {
uploadProgress.value = Math.round(progress * 100) 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: Upload endpoints return an `UploadHandle<T>` with progress and cancellation support:
```ts ```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 }) => { upload.onProgress(({ progress }) => {
console.log(Math.round(progress * 100)) 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 { ArchonTransfersInternalModule } from './archon/transfers/internal'
import { ISO3166Module } from './iso3166' import { ISO3166Module } from './iso3166'
import { KyrosContentV1Module } from './kyros/content/v1' import { KyrosContentV1Module } from './kyros/content/v1'
import { KyrosFilesV0Module } from './kyros/files/v0'
import { KyrosFilesV1Module } from './kyros/files/v1' import { KyrosFilesV1Module } from './kyros/files/v1'
import { KyrosLogsV1Module } from './kyros/logs/v1' import { KyrosLogsV1Module } from './kyros/logs/v1'
import { KyrosUploadSessionsV1Module } from './kyros/upload-sessions/v1' import { KyrosUploadSessionsV1Module } from './kyros/upload-sessions/v1'
@@ -88,7 +87,6 @@ export const MODULE_REGISTRY = {
mclogs_logs_v1: MclogsLogsV1Module, mclogs_logs_v1: MclogsLogsV1Module,
launchermeta_manifest_v0: LauncherMetaManifestV0Module, launchermeta_manifest_v0: LauncherMetaManifestV0Module,
kyros_content_v1: KyrosContentV1Module, kyros_content_v1: KyrosContentV1Module,
kyros_files_v0: KyrosFilesV0Module,
kyros_files_v1: KyrosFilesV1Module, kyros_files_v1: KyrosFilesV1Module,
kyros_logs_v1: KyrosLogsV1Module, kyros_logs_v1: KyrosLogsV1Module,
kyros_upload_sessions_v1: KyrosUploadSessionsV1Module, 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 { AbstractModule } from '../../../core/abstract-module'
import type { UploadHandle, UploadProgress } from '../../../types/upload'
import type { Kyros } from '../types' import type { Kyros } from '../types'
export class KyrosFilesV1Module extends AbstractModule { export class KyrosFilesV1Module extends AbstractModule {
@@ -6,6 +7,25 @@ export class KyrosFilesV1Module extends AbstractModule {
return 'kyros_files_v1' 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( public async listDescendants(
worldId: string, worldId: string,
path: 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> { public async deleteFile(worldId: string, path: string): Promise<void> {
return this.client.request<void>(`/worlds/${worldId}/files/delete`, { return this.client.request<void>(`/worlds/${worldId}/files/delete`, {
api: '', 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 UploadSessions {
export namespace v1 { export namespace v1 {
export type Scope = 'content' | 'files' export type Scope = 'content' | 'files'
export type UploadSessionStatus =
| 'active' export type UploadSessionFile = {
| 'uploading' file: File | Blob
| 'finalizing' filename: string
| 'cancelled' }
| 'finalized'
| 'expired'
export interface UploadSessionResponse { export interface UploadSessionResponse {
upload_id: string upload_id: string
status: UploadSessionStatus status: string
created_at: number created_at: number
updated_at: number updated_at: number
last_upload_at: number | null last_upload_at?: number | null
expires_at: number expires_at: number
entry_count: number entry_count: number
uploaded_byte_count: number uploaded_byte_count: number
} }
export interface GetUploadSessionResponse { export interface GetUploadSessionResponse {
session: UploadSessionResponse | null session?: UploadSessionResponse | null
} }
} }
} }
@@ -31,6 +29,16 @@ export namespace Kyros {
export namespace v1 { export namespace v1 {
export type DescendantType = 'regular' | 'directory' | 'symlink' | 'other' export type DescendantType = 'regular' | 'directory' | 'symlink' | 'other'
export type UnzipSource =
| {
type: 'zip_url'
url: string
}
| {
type: 'zip_path'
path: string
}
export interface CreateDownloadSessionRequest { export interface CreateDownloadSessionRequest {
path: string path: string
zipped: boolean zipped: boolean
@@ -81,29 +89,14 @@ export namespace Kyros {
path: string path: string
name: string name: string
} }
}
export namespace v0 { export interface PathMutationRequest {
export interface DirectoryItem {
name: string
type: 'file' | 'directory' | 'symlink'
path: string path: string
modified: number
created: number
size?: number
count?: number
target?: string
} }
export interface DirectoryResponse { export interface UnzipFileRequest {
items: DirectoryItem[] source: UnzipSource
total: number target: string
current: number
}
export interface ExtractResult {
modpack_name: string | null
conflicting_files: string[]
} }
} }
} }
@@ -2,11 +2,6 @@ import { AbstractModule } from '../../../core/abstract-module'
import type { UploadHandle, UploadProgress } from '../../../types/upload' import type { UploadHandle, UploadProgress } from '../../../types/upload'
import type { Kyros } from '../types' import type { Kyros } from '../types'
export type UploadSessionFile = {
file: File | Blob
filename: string
}
export class KyrosUploadSessionsV1Module extends AbstractModule { export class KyrosUploadSessionsV1Module extends AbstractModule {
public getModuleID(): string { public getModuleID(): string {
return 'kyros_upload_sessions_v1' return 'kyros_upload_sessions_v1'
@@ -46,7 +41,7 @@ export class KyrosUploadSessionsV1Module extends AbstractModule {
scope: Kyros.UploadSessions.v1.Scope, scope: Kyros.UploadSessions.v1.Scope,
worldId: string, worldId: string,
uploadId: string, uploadId: string,
files: UploadSessionFile[], files: Kyros.UploadSessions.v1.UploadSessionFile[],
options?: { options?: {
onProgress?: (progress: UploadProgress) => void onProgress?: (progress: UploadProgress) => void
retry?: boolean | number retry?: boolean | number
@@ -407,6 +407,7 @@ export type PendingChange = {
type ServerListingProps = { type ServerListingProps = {
server_id: string server_id: string
worldId?: string | null
name: string name: string
status: Archon.Servers.v0.Status status: Archon.Servers.v0.Status
suspension_reason?: Archon.Servers.v0.SuspensionReason | null suspension_reason?: Archon.Servers.v0.SuspensionReason | null
@@ -546,16 +547,28 @@ async function dataURLToBlob(dataURL: string): Promise<Blob> {
return res.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({ 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> => { queryFn: async (): Promise<string | null> => {
if (!props.server_id || props.status !== 'available') return null if (!props.server_id || props.status !== 'available') return null
try { 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 { 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) return await processImageBlob(blob, 64)
} catch (error) { } catch (error) {
const statusCode = (error as { statusCode?: number })?.statusCode const statusCode = (error as { statusCode?: number })?.statusCode
@@ -564,8 +577,8 @@ const { data: image } = useQuery({
} }
try { try {
const originalBlob = await kyros.files_v0.downloadFileWithAuth( const originalBlob = await kyros.files_v1.downloadRawFileContents(
fsAuth, worldId,
'/server-icon-original.png', '/server-icon-original.png',
) )
return await processImageBlob(originalBlob, 64) return await processImageBlob(originalBlob, 64)
@@ -585,13 +598,12 @@ const { data: image } = useQuery({
const scaledBlob = await dataURLToBlob(scaledDataUrl) const scaledBlob = await dataURLToBlob(scaledDataUrl)
const scaledFile = new File([scaledBlob], 'server-icon.png', { type: 'image/png' }) 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', { const originalFile = new File([blob], 'server-icon-original.png', {
type: 'image/png', type: 'image/png',
}) })
await kyros.files_v0.uploadFileWithAuth(fsAuth, '/server-icon-original.png', originalFile) await uploadWorldFile(worldId, '/server-icon-original.png', originalFile)
.promise
return scaledDataUrl return scaledDataUrl
} }
@@ -73,7 +73,7 @@ const props = withDefaults(
const { addNotification } = injectNotificationManager() const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const client = injectModrinthClient() const client = injectModrinthClient()
const { serverId, server } = injectModrinthServerContext() const { serverId, server, worldId } = injectModrinthServerContext()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const isUploadingIcon = ref(false) const isUploadingIcon = ref(false)
const isSyncingIcon = ref(false) const isSyncingIcon = ref(false)
@@ -95,6 +95,7 @@ const {
computed(() => server.value?.upstream ?? null), computed(() => server.value?.upstream ?? null),
{ {
includeProjectFallback: false, includeProjectFallback: false,
worldId,
}, },
) )
@@ -107,6 +108,19 @@ function isNotFound(error: unknown): boolean {
return getStatusCode(error) === 404 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) => { const uploadFile = async (e: Event) => {
if (isIconActionDisabled.value) return if (isIconActionDisabled.value) return
@@ -144,39 +158,11 @@ const uploadFile = async (e: Event) => {
img.src = URL.createObjectURL(file) img.src = URL.createObjectURL(file)
}) })
const fsAuth = await client.archon.servers_v0.getFilesystemAuth(serverId) await uploadWorldFile('/server-icon.png', scaledFile)
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
}
// Keep original file in sync when possible, but don't block icon updates on failures here. // Keep original file in sync when possible, but don't block icon updates on failures here.
try { try {
await client.kyros.files_v0.deleteFileOrFolderWithAuth( await uploadWorldFile('/server-icon-original.png', file)
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
} catch (originalUploadError) { } catch (originalUploadError) {
if (!isNotFound(originalUploadError)) { if (!isNotFound(originalUploadError)) {
// best effort // best effort
@@ -222,10 +208,10 @@ const resetIcon = async () => {
isSyncingIcon.value = true isSyncingIcon.value = true
try { try {
const fsAuth = await client.archon.servers_v0.getFilesystemAuth(serverId) const id = getWorldId()
const deleteResults = await Promise.allSettled([ const deleteResults = await Promise.allSettled([
client.kyros.files_v0.deleteFileOrFolderWithAuth(fsAuth, '/server-icon.png', false), client.kyros.files_v1.deleteFile(id, '/server-icon.png'),
client.kyros.files_v0.deleteFileOrFolderWithAuth(fsAuth, '/server-icon-original.png', false), client.kyros.files_v1.deleteFile(id, '/server-icon-original.png'),
]) ])
for (const result of deleteResults) { for (const result of deleteResults) {
@@ -370,7 +370,7 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
dismissedOpIds.value = new Set([...dismissedOpIds.value, opId]) dismissedOpIds.value = new Set([...dismissedOpIds.value, opId])
} }
try { try {
await client.kyros.files_v0.modifyOperation(opId, action) await client.kyros.files_v1.modifyOperation(opId, action)
} catch (error) { } catch (error) {
if (action === 'dismiss') return if (action === 'dismiss') return
console.error(`Failed to ${action} operation:`, error) 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 UpstreamRef = ComputedRef<Archon.Servers.v0.Server['upstream'] | null | undefined>
type ServerIdSource = string | { readonly value: string } type ServerIdSource = string | { readonly value: string }
type WorldIdSource = string | null | undefined | { readonly value: string | null | undefined }
type UseServerImageOptions = { type UseServerImageOptions = {
enabled?: ComputedRef<boolean> | boolean enabled?: ComputedRef<boolean> | boolean
size?: number size?: number
includeProjectFallback?: boolean includeProjectFallback?: boolean
worldId?: WorldIdSource
} }
export async function processImageBlob(blob: Blob, size: number): Promise<string> { export async function processImageBlob(blob: Blob, size: number): Promise<string> {
@@ -49,6 +51,7 @@ export function useServerImage(
const iconSize = options.size ?? 512 const iconSize = options.size ?? 512
const includeProjectFallback = options.includeProjectFallback ?? false const includeProjectFallback = options.includeProjectFallback ?? false
const resolvedServerId = computed(() => resolveServerId(serverId)) const resolvedServerId = computed(() => resolveServerId(serverId))
const resolvedWorldId = computed(() => resolveWorldId(options.worldId))
const queryKey = computed( const queryKey = computed(
() => () =>
@@ -57,6 +60,7 @@ export function useServerImage(
'detail', 'detail',
resolvedServerId.value, resolvedServerId.value,
'icon', 'icon',
resolvedWorldId.value ?? 'active',
upstream.value?.project_id ?? null, upstream.value?.project_id ?? null,
] as const, ] as const,
) )
@@ -74,18 +78,22 @@ export function useServerImage(
if (!id) return null if (!id) return null
try { try {
const fsAuth = await client.archon.servers_v0.getFilesystemAuth(id) const targetWorldId = resolvedWorldId.value ?? (await getActiveWorldId(id))
if (!targetWorldId) return null
try { 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) return await processImageBlob(blob, iconSize)
} catch (error) { } catch (error) {
if (!isNotFound(error)) throw error if (!isNotFound(error)) throw error
} }
try { try {
const blob = await client.kyros.files_v0.downloadFileWithAuth( const blob = await client.kyros.files_v1.downloadRawFileContents(
fsAuth, targetWorldId,
'/server-icon-original.png', '/server-icon-original.png',
) )
return await processImageBlob(blob, iconSize) return await processImageBlob(blob, iconSize)
@@ -94,7 +102,6 @@ export function useServerImage(
} }
} catch (error) { } catch (error) {
console.debug('Server image fetch failed:', error) console.debug('Server image fetch failed:', error)
return null
} }
if (!includeProjectFallback || !upstream.value?.project_id) return null if (!includeProjectFallback || !upstream.value?.project_id) return null
@@ -133,6 +140,12 @@ export function useServerImage(
localImage.value = undefined 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 { return {
image, image,
queryKey, queryKey,
@@ -146,3 +159,9 @@ export function useServerImage(
function resolveServerId(serverId: ServerIdSource): string { function resolveServerId(serverId: ServerIdSource): string {
return typeof serverId === 'string' ? serverId : serverId.value 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( const props = withDefaults(
defineProps<{ defineProps<{
worldId: string
disabled?: boolean disabled?: boolean
disabledTooltip?: string disabledTooltip?: string
}>(), }>(),
@@ -187,14 +188,6 @@ const messages = defineMessages({
id: 'files.zip-url-modal.error-url-invalid', id: 'files.zip-url-modal.error-url-invalid',
defaultMessage: 'URL must be valid.', 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: { installFailedTitle: {
id: 'files.zip-url-modal.install-failed-title', id: 'files.zip-url-modal.install-failed-title',
defaultMessage: 'Installation failed', defaultMessage: 'Installation failed',
@@ -265,19 +258,16 @@ const handleSubmit = async () => {
submitted.value = true submitted.value = true
try { try {
const dry = await client.kyros.files_v0.extractFile(trimmedUrl.value, true, true) const stream = await client.kyros.files_v1.unzipFile(props.worldId, {
source: { type: 'zip_url', url: trimmedUrl.value },
if (!cf.value || dry.modpack_name) { target: '/',
await client.kyros.files_v0.extractFile(trimmedUrl.value, true, false) })
hide() const reader = stream.getReader()
} else { while (true) {
submitted.value = false const { done } = await reader.read()
addNotification({ if (done) break
title: formatMessage(messages.cfNotFoundTitle),
text: formatMessage(messages.cfNotFoundText),
type: 'error',
})
} }
hide()
} catch (err) { } catch (err) {
submitted.value = false submitted.value = false
console.error('Error installing:', err) console.error('Error installing:', err)
@@ -181,12 +181,13 @@ interface UploadItem {
| 'cancelled' | 'cancelled'
| 'incorrect-type' | 'incorrect-type'
size: string size: string
uploader?: ReturnType<typeof client.kyros.files_v0.uploadFile> uploader?: ReturnType<typeof client.kyros.files_v1.uploadFile>
error?: Error error?: Error
} }
interface Props { interface Props {
currentPath: string currentPath: string
worldId: string
fileType?: string fileType?: string
marginBottom?: number marginBottom?: number
acceptedTypes?: Array<string> acceptedTypes?: Array<string>
@@ -281,11 +282,12 @@ const uploadFile = async (file: File) => {
uploadItem.status = 'uploading' uploadItem.status = 'uploading'
const filePath = `${props.currentPath}/${file.name}`.replace('//', '/') 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 }) => { onProgress: ({ progress }) => {
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name) const index = uploadQueue.value.findIndex((item) => item.file.name === file.name)
if (index !== -1) { 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" /> <FileCreateItemModal ref="createItemModal" :type="newItemType" @create="handleCreateNewItem" />
<FileUploadConflictModal ref="uploadConflictModal" @proceed="handleExtractConfirm" /> <FileUploadConflictModal ref="uploadConflictModal" @proceed="handleExtractConfirm" />
<FileUploadZipUrlModal <FileUploadZipUrlModal
v-if="ctx.showInstallFromUrl" v-if="showInstallFromUrl"
ref="uploadZipUrlModal" ref="uploadZipUrlModal"
:world-id="installFromUrlWorldId"
:disabled="isBusy" :disabled="isBusy"
:disabled-tooltip="busyTooltip" :disabled-tooltip="busyTooltip"
/> />
@@ -48,7 +49,7 @@
:is-editor-find-open="fileEditorRef?.isFindOpen" :is-editor-find-open="fileEditorRef?.isFindOpen"
:search-query="searchQuery" :search-query="searchQuery"
:show-refresh-button="showRefreshButton" :show-refresh-button="showRefreshButton"
:show-install-from-url="ctx.showInstallFromUrl" :show-install-from-url="showInstallFromUrl"
:base-id="baseId" :base-id="baseId"
:disabled="isBusy" :disabled="isBusy"
:disabled-tooltip="busyTooltip" :disabled-tooltip="busyTooltip"
@@ -378,6 +379,8 @@ const selectedItem = ref<FileItem | null>(null)
const unsavedChangesModal = ref<InstanceType<typeof FileUnsavedChangesModal>>() const unsavedChangesModal = ref<InstanceType<typeof FileUnsavedChangesModal>>()
const hasUnsavedChanges = computed(() => fileEditorRef.value?.hasUnsavedChanges ?? false) 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> { async function confirmDiscardChanges(): Promise<boolean> {
if (!hasUnsavedChanges.value) return true if (!hasUnsavedChanges.value) return true
@@ -35,6 +35,7 @@ export interface FileManagerContext {
uploadFiles: (files: File[]) => void uploadFiles: (files: File[]) => void
cancelUpload?: () => void cancelUpload?: () => void
uploadState?: Ref<UploadState> | ComputedRef<UploadState> uploadState?: Ref<UploadState> | ComputedRef<UploadState>
worldId?: Ref<string | null> | ComputedRef<string | null>
refresh: () => void refresh: () => void
@@ -818,6 +818,7 @@ const { disconnect: disconnectPanelSync } = useServerPanelSync({
const { image: serverImage } = useServerImage( const { image: serverImage } = useServerImage(
props.serverId, props.serverId,
computed(() => serverData.value?.upstream ?? null), computed(() => serverData.value?.upstream ?? null),
{ worldId },
) )
const { data: serverProject } = useServerProject(computed(() => serverData.value?.upstream ?? null)) 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') const cancelled = allOps.filter((x) => x.state === 'cancelled')
Promise.all( Promise.all(
cancelled.map((x) => 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) 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) installError.value = new Error(errorMessage.value)
try { try {
let files = await client.kyros.files_v0.listDirectory('/', 1, 100) if (!worldId.value) break
if (files && files.total > 1) { let files = await client.kyros.files_v1.listDescendants(worldId.value, '/', 1, 100)
for (let i = 2; i <= files.total; i++) { for (let i = 2; i <= files.page_total; i++) {
const nextFiles = await client.kyros.files_v0.listDirectory('/', i, 100) const nextFiles = await client.kyros.files_v1.listDescendants(worldId.value, '/', i, 100)
if (nextFiles?.items?.length === 0) break if (nextFiles.items.length === 0) break
if (nextFiles) files = nextFiles files = {
...nextFiles,
items: [...files.items, ...nextFiles.items],
} }
} }
const fileName = files?.items?.find((file) => const file = files.items.find((file) =>
file.name.startsWith('modrinth-installation'), file.name.startsWith('modrinth-installation'),
)?.name )
errorLogFile.value = fileName ?? '' errorLogFile.value = file?.full_path ?? ''
if (fileName) { if (file) {
const content = await client.kyros.files_v0.downloadFile(fileName) const content = await client.kyros.files_v1.downloadRawFileContents(
worldId.value,
file.full_path,
)
errorLog.value = await content.text() errorLog.value = await content.text()
} }
} catch (err) { } catch (err) {
@@ -326,9 +326,11 @@ const moveMutation = useMutation({
}) })
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: (_vars: { path: string; type: 'file' | 'directory' }) => { mutationFn: ({ path, type }: { path: string; type: 'file' | 'directory' }) => {
// await client.kyros.files_v0.createFileOrFolder(path, type) const id = getWorldId()
throw new Error('Creating files or folders is not supported by the v1 world-scoped files API.') return type === 'directory'
? client.kyros.files_v1.mkdirFile(id, path)
: client.kyros.files_v1.touchFile(id, path)
}, },
onError: (err: Error) => { onError: (err: Error) => {
addNotification({ addNotification({
@@ -337,6 +339,9 @@ const createMutation = useMutation({
type: 'error', type: 'error',
}) })
}, },
onSettled: () => {
refreshList()
},
}) })
// File I/O // File I/O
@@ -344,7 +349,7 @@ async function readFile(path: string): Promise<string> {
const normalizedPath = path.startsWith('/') ? path : `/${path}` const normalizedPath = path.startsWith('/') ? path : `/${path}`
const id = getWorldId() const id = getWorldId()
const cachedContent = queryClient.getQueryData<string>(['file-content', id, normalizedPath]) 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) const blob = await client.kyros.files_v1.downloadRawFileContents(id, normalizedPath)
return await blob.text() return await blob.text()
} }
@@ -356,10 +361,11 @@ async function readFileAsBlob(path: string): Promise<Blob> {
async function writeFile(path: string, content: string): Promise<void> { async function writeFile(path: string, content: string): Promise<void> {
if (fileWriteDisabled.value) return if (fileWriteDisabled.value) return
void path const normalizedPath = path.startsWith('/') ? path : `/${path}`
void content const id = getWorldId()
// await client.kyros.files_v0.updateFile(path, content) await client.kyros.files_v1.editFile(id, normalizedPath, content)
throw new Error('Updating file contents is not supported by the v1 world-scoped files API.') queryClient.setQueryData(['file-content', id, normalizedPath], content)
refreshList()
} }
async function downloadFile(path: string, fileName: string): Promise<void> { async function downloadFile(path: string, fileName: string): Promise<void> {
@@ -462,14 +468,14 @@ provideFileManager({
uploadFiles, uploadFiles,
cancelUpload, cancelUpload,
uploadState, uploadState,
worldId,
refresh: refreshList, refresh: refreshList,
isBusy: fileWriteDisabled, isBusy: fileWriteDisabled,
busyTooltip: fileWriteDisabledTooltip, busyTooltip: fileWriteDisabledTooltip,
busyWarning, busyWarning,
// extractFile: async (path, override, dry) => client.kyros.files_v0.extractFile(path, override, dry),
prefetchDirectory, prefetchDirectory,
prefetchFile, prefetchFile,
showInstallFromUrl: false, showInstallFromUrl: true,
canRestart: canUsePowerActions.value, canRestart: canUsePowerActions.value,
restartServer, restartServer,
canShareToMclogs: true, canShareToMclogs: true,
@@ -58,6 +58,7 @@ const client = injectModrinthClient()
const { const {
server: _serverData, server: _serverData,
serverId, serverId,
worldId,
isConnected, isConnected,
isWsAuthIncorrect, isWsAuthIncorrect,
stats, stats,
@@ -83,9 +84,13 @@ const isDismissed = () => Date.now() < dismissedUntil.value
const inspectError = async () => { const inspectError = async () => {
if (isDismissed()) return if (isDismissed()) return
if (!worldId.value) return
try { 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() const log = await blob.text()
if (!log) return if (!log) return
@@ -180,6 +180,7 @@
v-for="server in ownedFilteredData.filter((s) => !s.is_medal)" v-for="server in ownedFilteredData.filter((s) => !s.is_medal)"
:key="`owned-${server.server_id}`" :key="`owned-${server.server_id}`"
v-bind="server" v-bind="server"
:world-id="getServerWorldId(server.server_id)"
:cancellation-date="serverBillingMap.get(server.server_id)?.cancellationDate" :cancellation-date="serverBillingMap.get(server.server_id)?.cancellationDate"
:is-provisioning="serverBillingMap.get(server.server_id)?.isProvisioning" :is-provisioning="serverBillingMap.get(server.server_id)?.isProvisioning"
:on-resubscribe="serverBillingMap.get(server.server_id)?.onResubscribe" :on-resubscribe="serverBillingMap.get(server.server_id)?.onResubscribe"
@@ -211,6 +212,7 @@
v-for="server in sharedFilteredData.filter((s) => !s.is_medal)" v-for="server in sharedFilteredData.filter((s) => !s.is_medal)"
:key="`shared-${server.server_id}`" :key="`shared-${server.server_id}`"
v-bind="server" v-bind="server"
:world-id="getServerWorldId(server.server_id)"
/> />
</TransitionGroup> </TransitionGroup>
<div v-else class="text-secondary"> <div v-else class="text-secondary">
@@ -762,6 +764,12 @@ const { data: serverFullList } = useQuery({
enabled: loggedIn, 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 = { type ServerBillingInfo = {
cancellationDate?: string | null cancellationDate?: string | null
isProvisioning?: boolean isProvisioning?: boolean
+8 -6
View File
@@ -177,15 +177,17 @@ import { injectModrinthClient, injectModrinthServerContext, ServersManageFilesPa
import { useQueryClient } from '@tanstack/vue-query' import { useQueryClient } from '@tanstack/vue-query'
const client = injectModrinthClient() const client = injectModrinthClient()
const { serverId } = injectModrinthServerContext() const { worldId } = injectModrinthServerContext()
const queryClient = useQueryClient() const queryClient = useQueryClient()
try { try {
await queryClient.ensureQueryData({ if (worldId.value) {
queryKey: ['files', serverId, '/'], await queryClient.ensureQueryData({
queryFn: () => client.kyros.files_v0.listDirectory('/', 1, 2000), queryKey: ['files', 'v1', worldId.value, '/'],
staleTime: 30_000, queryFn: () => client.kyros.files_v1.listDescendants(worldId.value!, '/', 1, 200),
}) staleTime: 30_000,
})
}
} catch { } catch {
// Let the mounted layouts useQuery surface errors; do not fail route setup. // Let the mounted layouts useQuery surface errors; do not fail route setup.
} }