-
+
-
@@ -96,7 +107,16 @@
+
+
+
+ {{ instance.name }}
+
+
diff --git a/apps/app-frontend/src/components/ui/modal/ModalWrapper.vue b/apps/app-frontend/src/components/ui/modal/ModalWrapper.vue
index 867e333b8..fb0ddf3d8 100644
--- a/apps/app-frontend/src/components/ui/modal/ModalWrapper.vue
+++ b/apps/app-frontend/src/components/ui/modal/ModalWrapper.vue
@@ -1,5 +1,5 @@
diff --git a/apps/app-frontend/src/components/ui/skin/SelectCapeModal.vue b/apps/app-frontend/src/components/ui/skin/SelectCapeModal.vue
new file mode 100644
index 000000000..3bb559d60
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/skin/SelectCapeModal.vue
@@ -0,0 +1,140 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ None
+
+
+
+
+
+
+
+
+
+
+ Select
+
+
+
+
+
+ Cancel
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/skin/UploadSkinModal.vue b/apps/app-frontend/src/components/ui/skin/UploadSkinModal.vue
new file mode 100644
index 000000000..818922eff
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/skin/UploadSkinModal.vue
@@ -0,0 +1,140 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/world/InstanceItem.vue b/apps/app-frontend/src/components/ui/world/InstanceItem.vue
new file mode 100644
index 000000000..12fc67468
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/world/InstanceItem.vue
@@ -0,0 +1,230 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ instance.name }}
+
+
+
+
+
+ {{
+ formatMessage(commonMessages.playedLabel, {
+ time: formatRelativeTime(last_played.toISOString?.()),
+ })
+ }}
+
+ Not played yet
+
+ •
+
+
+
+ {{ modpack.title }}
+
+ ({{ loader }} {{ instance.game_version }})
+
+
+
+ Loading modpack...
+
+
+ {{ loader }}
+ {{ instance.game_version }}
+
+
+
+
+
+
+
+ {{ formatMessage(commonMessages.stopButton) }}
+
+
+
+
+
+
+ {{ formatMessage(commonMessages.playButton) }}
+
+
+
+
+
+
+
+ View instance
+
+
+
+ {{ formatMessage(commonMessages.openFolderButton) }}
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/world/RecentWorldsList.vue b/apps/app-frontend/src/components/ui/world/RecentWorldsList.vue
new file mode 100644
index 000000000..a960f805f
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/world/RecentWorldsList.vue
@@ -0,0 +1,304 @@
+
+
+
+
+
+ Jump back in
+
+
+ Jump back in
+
+
+
+
+ item.world.type === 'server'
+ ? refreshServer(item.world.address, item.instance.path)
+ : {}
+ "
+ @update="() => populateJumpBackIn()"
+ @play="
+ () => {
+ currentProfile = item.instance.path
+ currentWorld = getWorldIdentifier(item.world)
+ joinWorld(item.world)
+ }
+ "
+ @play-instance="
+ () => {
+ currentProfile = item.instance.path
+ playInstance(item.instance)
+ }
+ "
+ @stop="() => stopInstance(item.instance.path)"
+ />
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/world/WorldItem.vue b/apps/app-frontend/src/components/ui/world/WorldItem.vue
new file mode 100644
index 000000000..f30aca810
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/world/WorldItem.vue
@@ -0,0 +1,506 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ world.name }}
+
+
+
+ {{ formatMessage(commonMessages.singleplayerLabel) }}
+
+
+
+
+ Loading...
+
+
+
+
+
+ Incompatible version {{ serverStatus.version?.name }}
+
+
+
+
+
+
+ {{ formatNumber(serverStatus.players?.online, false) }} online
+
+
+
+
+ {{ player.name }}
+
+
+
+
+
+
+
+ Offline
+
+
+
+
+
+
+ {{
+ formatMessage(commonMessages.playedLabel, {
+ time: formatRelativeTime(dayjs(world.last_played).toISOString()),
+ })
+ }}
+
+ Not played yet
+
+
+ •
+
+
+ {{ instanceName }}
+
+
+
+
+
+
+
+
+ {{ formatMessage(commonMessages.loadingLabel) }}
+
+
+
+ {{ formatMessage(messages.cantConnect) }}
+
+
+ {{ formatMessage(messages.aMinecraftServer) }}
+
+
+
+
+
+ {{ formatMessage(messages.hardcore) }}
+
+
+
+ {{ formatMessage(gameMode.message) }}
+
+
+
+
+
+
+
+
+ {{ formatMessage(commonMessages.stopButton) }}
+
+
+
+
+
+
+ {{ formatMessage(commonMessages.playButton) }}
+
+
+
+
+
+
+ {{ formatMessage(commonMessages.playButton) }}
+
+
+
+
+
+
+
+ {{ formatMessage(messages.playInstance) }}
+
+
+
+ {{ formatMessage(messages.playAnyway) }}
+
+
+
+ {{ formatMessage(messages.viewInstance) }}
+
+
+ {{ formatMessage(commonMessages.editButton) }}
+
+
+
+ {{ formatMessage(commonMessages.openFolderButton) }}
+
+
+ {{ formatMessage(messages.copyAddress) }}
+
+
+ {{ formatMessage(commonMessages.refreshButton) }}
+
+
+
+ {{ formatMessage(messages.dontShowOnHome) }}
+
+
+
+ {{
+ formatMessage(
+ world.type === 'server'
+ ? commonMessages.removeButton
+ : commonMessages.deleteLabel,
+ )
+ }}
+
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/world/modal/AddServerModal.vue b/apps/app-frontend/src/components/ui/world/modal/AddServerModal.vue
new file mode 100644
index 000000000..00fab96ec
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/world/modal/AddServerModal.vue
@@ -0,0 +1,115 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.addAndPlay) }}
+
+
+
+
+
+ {{ formatMessage(messages.addServer) }}
+
+
+
+
+
+ {{ formatMessage(commonMessages.cancelButton) }}
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/world/modal/EditServerModal.vue b/apps/app-frontend/src/components/ui/world/modal/EditServerModal.vue
new file mode 100644
index 000000000..5e03bbb89
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/world/modal/EditServerModal.vue
@@ -0,0 +1,118 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(commonMessages.saveChangesButton) }}
+
+
+
+
+
+ {{ formatMessage(commonMessages.cancelButton) }}
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/world/modal/EditSingleplayerWorldModal.vue b/apps/app-frontend/src/components/ui/world/modal/EditSingleplayerWorldModal.vue
new file mode 100644
index 000000000..5a01d93ec
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/world/modal/EditSingleplayerWorldModal.vue
@@ -0,0 +1,129 @@
+
+
+
+
+
+ {{ instance.name }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(commonMessages.saveChangesButton) }}
+
+
+
+
+
+ {{ formatMessage(messages.resetIcon) }}
+
+
+
+
+
+ {{ formatMessage(commonMessages.cancelButton) }}
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/world/modal/HideFromHomeOption.vue b/apps/app-frontend/src/components/ui/world/modal/HideFromHomeOption.vue
new file mode 100644
index 000000000..024072b57
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/world/modal/HideFromHomeOption.vue
@@ -0,0 +1,18 @@
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/world/modal/ServerModalBody.vue b/apps/app-frontend/src/components/ui/world/modal/ServerModalBody.vue
new file mode 100644
index 000000000..64c82b27d
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/world/modal/ServerModalBody.vue
@@ -0,0 +1,86 @@
+
+
+
+
diff --git a/apps/app-frontend/src/helpers/analytics.js b/apps/app-frontend/src/helpers/analytics.js
index 90d2c8aef..6fa5ea3ab 100644
--- a/apps/app-frontend/src/helpers/analytics.js
+++ b/apps/app-frontend/src/helpers/analytics.js
@@ -1,8 +1,9 @@
import { posthog } from 'posthog-js'
export const initAnalytics = () => {
- posthog.init('phc_hm2ihMpTAoE86xIm7XzsCB8RPiTRKivViK5biiHedm', {
+ posthog.init('phc_9Iqi6lFs9sr5BSqh9RRNRSJ0mATS9PSgirDiX3iOYJ', {
persistence: 'localStorage',
+ api_host: 'https://posthog.modrinth.com',
})
}
diff --git a/apps/app-frontend/src/helpers/events.js b/apps/app-frontend/src/helpers/events.js
index 0ed288365..81a849b9e 100644
--- a/apps/app-frontend/src/helpers/events.js
+++ b/apps/app-frontend/src/helpers/events.js
@@ -62,7 +62,7 @@ export async function process_listener(callback) {
ProfilePayload {
uuid: unique identification of the process in the state (currently identified by path, but that will change)
name: name of the profile
- profile_path: relative path to profile (used for path identification)
+ profile_path: relative path toprofile_listener profile (used for path identification)
path: path to profile (used for opening the profile in the OS file explorer)
event: event type ("Created", "Added", "Edited", "Removed")
}
diff --git a/apps/app-frontend/src/helpers/fetch.js b/apps/app-frontend/src/helpers/fetch.js
index ff3e8b62e..5c5cf39cf 100644
--- a/apps/app-frontend/src/helpers/fetch.js
+++ b/apps/app-frontend/src/helpers/fetch.js
@@ -1,12 +1,12 @@
-import { ofetch } from 'ofetch'
+import { fetch } from '@tauri-apps/plugin-http'
import { handleError } from '@/store/state.js'
import { getVersion } from '@tauri-apps/api/app'
export const useFetch = async (url, item, isSilent) => {
try {
const version = await getVersion()
-
- return await ofetch(url, {
+ return await fetch(url, {
+ method: 'GET',
headers: { 'User-Agent': `modrinth/theseus/${version} (support@modrinth.com)` },
})
} catch (err) {
diff --git a/apps/app-frontend/src/helpers/pack.js b/apps/app-frontend/src/helpers/pack.js
index c175f9030..2026ec120 100644
--- a/apps/app-frontend/src/helpers/pack.js
+++ b/apps/app-frontend/src/helpers/pack.js
@@ -7,7 +7,13 @@ import { invoke } from '@tauri-apps/api/core'
import { create } from './profile'
// Installs pack from a version ID
-export async function create_profile_and_install(projectId, versionId, packTitle, iconUrl) {
+export async function create_profile_and_install(
+ projectId,
+ versionId,
+ packTitle,
+ iconUrl,
+ createInstanceCallback = () => {},
+) {
const location = {
type: 'fromVersionId',
project_id: projectId,
@@ -24,6 +30,7 @@ export async function create_profile_and_install(projectId, versionId, packTitle
null,
true,
)
+ createInstanceCallback(profile)
return await invoke('plugin:pack|pack_install', { location, profile })
}
diff --git a/apps/app-frontend/src/helpers/rendering/batch-skin-renderer.ts b/apps/app-frontend/src/helpers/rendering/batch-skin-renderer.ts
new file mode 100644
index 000000000..6495837d1
--- /dev/null
+++ b/apps/app-frontend/src/helpers/rendering/batch-skin-renderer.ts
@@ -0,0 +1,354 @@
+import * as THREE from 'three'
+import type { Skin, Cape } from '../skins'
+import { get_normalized_skin_texture, determineModelType } from '../skins'
+import { reactive } from 'vue'
+import { setupSkinModel, disposeCaches } from '@modrinth/utils'
+import { skinPreviewStorage } from '../storage/skin-preview-storage'
+import { CapeModel, ClassicPlayerModel, SlimPlayerModel } from '@modrinth/assets'
+
+export interface RenderResult {
+ forwards: string
+ backwards: string
+}
+
+class BatchSkinRenderer {
+ private renderer: THREE.WebGLRenderer
+ private readonly scene: THREE.Scene
+ private readonly camera: THREE.PerspectiveCamera
+ private currentModel: THREE.Group | null = null
+
+ constructor(width: number = 360, height: number = 504) {
+ const canvas = document.createElement('canvas')
+ canvas.width = width
+ canvas.height = height
+
+ this.renderer = new THREE.WebGLRenderer({
+ canvas: canvas,
+ antialias: true,
+ alpha: true,
+ preserveDrawingBuffer: true,
+ })
+
+ this.renderer.outputColorSpace = THREE.SRGBColorSpace
+ this.renderer.toneMapping = THREE.NoToneMapping
+ this.renderer.toneMappingExposure = 10.0
+ this.renderer.setClearColor(0x000000, 0)
+ this.renderer.setSize(width, height)
+
+ this.scene = new THREE.Scene()
+ this.camera = new THREE.PerspectiveCamera(20, width / height, 0.4, 1000)
+
+ const ambientLight = new THREE.AmbientLight(0xffffff, 2)
+ const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2)
+ directionalLight.castShadow = true
+ directionalLight.position.set(2, 4, 3)
+ this.scene.add(ambientLight)
+ this.scene.add(directionalLight)
+ }
+
+ public async renderSkin(
+ textureUrl: string,
+ modelUrl: string,
+ capeUrl?: string,
+ capeModelUrl?: string,
+ ): Promise
{
+ await this.setupModel(modelUrl, textureUrl, capeModelUrl, capeUrl)
+
+ const headPart = this.currentModel!.getObjectByName('Head')
+ let lookAtTarget: [number, number, number]
+
+ if (headPart) {
+ const headPosition = new THREE.Vector3()
+ headPart.getWorldPosition(headPosition)
+ lookAtTarget = [headPosition.x, headPosition.y - 0.3, headPosition.z]
+ } else {
+ throw new Error("Failed to find 'Head' object in model.")
+ }
+
+ const frontCameraPos: [number, number, number] = [-1.3, 1, 6.3]
+ const backCameraPos: [number, number, number] = [-1.3, 1, -2.5]
+
+ const forwards = await this.renderView(frontCameraPos, lookAtTarget)
+ const backwards = await this.renderView(backCameraPos, lookAtTarget)
+
+ return { forwards, backwards }
+ }
+
+ private async renderView(
+ cameraPosition: [number, number, number],
+ lookAtPosition: [number, number, number],
+ ): Promise {
+ this.camera.position.set(...cameraPosition)
+ this.camera.lookAt(...lookAtPosition)
+
+ this.renderer.render(this.scene, this.camera)
+
+ return new Promise((resolve, reject) => {
+ this.renderer.domElement.toBlob((blob) => {
+ if (blob) {
+ const url = URL.createObjectURL(blob)
+ resolve(url)
+ } else {
+ reject(new Error('Failed to create blob from canvas'))
+ }
+ }, 'image/png')
+ })
+ }
+
+ private async setupModel(
+ modelUrl: string,
+ textureUrl: string,
+ capeModelUrl?: string,
+ capeUrl?: string,
+ ): Promise {
+ if (this.currentModel) {
+ this.scene.remove(this.currentModel)
+ }
+
+ const { model } = await setupSkinModel(modelUrl, textureUrl, capeModelUrl, capeUrl)
+
+ const group = new THREE.Group()
+ group.add(model)
+ group.position.set(0, 0.3, 1.95)
+ group.scale.set(0.8, 0.8, 0.8)
+
+ this.scene.add(group)
+ this.currentModel = group
+ }
+
+ public dispose(): void {
+ this.renderer.dispose()
+ disposeCaches()
+ }
+}
+
+function getModelUrlForVariant(variant: string): string {
+ switch (variant) {
+ case 'SLIM':
+ return SlimPlayerModel
+ case 'CLASSIC':
+ case 'UNKNOWN':
+ default:
+ return ClassicPlayerModel
+ }
+}
+
+export const map = reactive(new Map())
+export const headMap = reactive(new Map())
+const DEBUG_MODE = false
+
+export async function cleanupUnusedPreviews(skins: Skin[]): Promise {
+ const validKeys = new Set()
+ const validHeadKeys = new Set()
+
+ for (const skin of skins) {
+ const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
+ const headKey = `${skin.texture_key}-head`
+ validKeys.add(key)
+ validHeadKeys.add(headKey)
+ }
+
+ try {
+ await skinPreviewStorage.cleanupInvalidKeys(validKeys)
+ await skinPreviewStorage.cleanupInvalidKeys(validHeadKeys)
+ } catch (error) {
+ console.warn('Failed to cleanup unused skin previews:', error)
+ }
+}
+
+export async function generatePlayerHeadBlob(skinUrl: string, size: number = 64): Promise {
+ return new Promise((resolve, reject) => {
+ const img = new Image()
+ img.crossOrigin = 'anonymous'
+
+ img.onload = () => {
+ try {
+ const sourceCanvas = document.createElement('canvas')
+ const sourceCtx = sourceCanvas.getContext('2d')
+
+ if (!sourceCtx) {
+ throw new Error('Could not get 2D context from source canvas')
+ }
+
+ sourceCanvas.width = img.width
+ sourceCanvas.height = img.height
+
+ sourceCtx.drawImage(img, 0, 0)
+
+ const outputCanvas = document.createElement('canvas')
+ const outputCtx = outputCanvas.getContext('2d')
+
+ if (!outputCtx) {
+ throw new Error('Could not get 2D context from output canvas')
+ }
+
+ outputCanvas.width = size
+ outputCanvas.height = size
+
+ outputCtx.imageSmoothingEnabled = false
+
+ const headImageData = sourceCtx.getImageData(8, 8, 8, 8)
+
+ const headCanvas = document.createElement('canvas')
+ const headCtx = headCanvas.getContext('2d')
+
+ if (!headCtx) {
+ throw new Error('Could not get 2D context from head canvas')
+ }
+
+ headCanvas.width = 8
+ headCanvas.height = 8
+ headCtx.putImageData(headImageData, 0, 0)
+
+ outputCtx.drawImage(headCanvas, 0, 0, 8, 8, 0, 0, size, size)
+
+ const hatImageData = sourceCtx.getImageData(40, 8, 8, 8)
+
+ const hatCanvas = document.createElement('canvas')
+ const hatCtx = hatCanvas.getContext('2d')
+
+ if (!hatCtx) {
+ throw new Error('Could not get 2D context from hat canvas')
+ }
+
+ hatCanvas.width = 8
+ hatCanvas.height = 8
+ hatCtx.putImageData(hatImageData, 0, 0)
+
+ const hatPixels = hatImageData.data
+ let hasHat = false
+
+ for (let i = 3; i < hatPixels.length; i += 4) {
+ if (hatPixels[i] > 0) {
+ hasHat = true
+ break
+ }
+ }
+
+ if (hasHat) {
+ outputCtx.drawImage(hatCanvas, 0, 0, 8, 8, 0, 0, size, size)
+ }
+
+ outputCanvas.toBlob((blob) => {
+ if (blob) {
+ resolve(blob)
+ } else {
+ reject(new Error('Failed to create blob from canvas'))
+ }
+ }, 'image/png')
+ } catch (error) {
+ reject(error)
+ }
+ }
+
+ img.onerror = () => {
+ reject(new Error('Failed to load skin texture image'))
+ }
+
+ img.src = skinUrl
+ })
+}
+
+async function generateHeadRender(skin: Skin): Promise {
+ const headKey = `${skin.texture_key}-head`
+
+ if (headMap.has(headKey)) {
+ if (DEBUG_MODE) {
+ const url = headMap.get(headKey)!
+ URL.revokeObjectURL(url)
+ headMap.delete(headKey)
+ } else {
+ return headMap.get(headKey)!
+ }
+ }
+
+ try {
+ const cached = await skinPreviewStorage.retrieve(headKey)
+ if (cached && typeof cached === 'string') {
+ headMap.set(headKey, cached)
+ return cached
+ }
+ } catch (error) {
+ console.warn('Failed to retrieve cached head render:', error)
+ }
+
+ const skinUrl = await get_normalized_skin_texture(skin)
+ const headBlob = await generatePlayerHeadBlob(skinUrl, 64)
+ const headUrl = URL.createObjectURL(headBlob)
+
+ headMap.set(headKey, headUrl)
+
+ try {
+ // @ts-expect-error - skinPreviewStorage.store expects a RenderResult, but we are storing a string url.
+ await skinPreviewStorage.store(headKey, headUrl)
+ } catch (error) {
+ console.warn('Failed to store head render in persistent storage:', error)
+ }
+
+ return headUrl
+}
+
+export async function getPlayerHeadUrl(skin: Skin): Promise {
+ return await generateHeadRender(skin)
+}
+
+export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promise {
+ const renderer = new BatchSkinRenderer()
+
+ try {
+ for (const skin of skins) {
+ const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
+
+ if (map.has(key)) {
+ if (DEBUG_MODE) {
+ const result = map.get(key)!
+ URL.revokeObjectURL(result.forwards)
+ URL.revokeObjectURL(result.backwards)
+ map.delete(key)
+ } else continue
+ }
+
+ try {
+ const cached = await skinPreviewStorage.retrieve(key)
+ if (cached) {
+ map.set(key, cached)
+ continue
+ }
+ } catch (error) {
+ console.warn('Failed to retrieve cached skin preview:', error)
+ }
+
+ let variant = skin.variant
+ if (variant === 'UNKNOWN') {
+ try {
+ variant = await determineModelType(skin.texture)
+ } catch (error) {
+ console.error(`Failed to determine model type for skin ${key}:`, error)
+ variant = 'CLASSIC'
+ }
+ }
+
+ const modelUrl = getModelUrlForVariant(variant)
+ const cape: Cape | undefined = capes.find((_cape) => _cape.id === skin.cape_id)
+ const renderResult = await renderer.renderSkin(
+ await get_normalized_skin_texture(skin),
+ modelUrl,
+ cape?.texture,
+ CapeModel,
+ )
+
+ map.set(key, renderResult)
+
+ try {
+ await skinPreviewStorage.store(key, renderResult)
+ } catch (error) {
+ console.warn('Failed to store skin preview in persistent storage:', error)
+ }
+
+ await generateHeadRender(skin)
+ }
+ } finally {
+ renderer.dispose()
+ await cleanupUnusedPreviews(skins)
+ }
+}
diff --git a/apps/app-frontend/src/helpers/settings.js b/apps/app-frontend/src/helpers/settings.js
deleted file mode 100644
index b27bfe90b..000000000
--- a/apps/app-frontend/src/helpers/settings.js
+++ /dev/null
@@ -1,43 +0,0 @@
-/**
- * All theseus API calls return serialized values (both return values and errors);
- * So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
- * and deserialized into a usable JS object.
- */
-import { invoke } from '@tauri-apps/api/core'
-
-// Settings object
-/*
-
-Settings {
- "memory": MemorySettings,
- "game_resolution": [int int],
- "custom_java_args": [String ...],
- "custom_env_args" : [(string, string) ... ]>,
- "java_globals": Hash of (string, Path),
- "default_user": Uuid string (can be null),
- "hooks": Hooks,
- "max_concurrent_downloads": uint,
- "version": u32,
- "collapsed_navigation": bool,
-}
-
-Memorysettings {
- "min": u32, can be null,
- "max": u32,
-}
-
-*/
-
-// Get full settings object
-export async function get() {
- return await invoke('plugin:settings|settings_get')
-}
-
-// Set full settings object
-export async function set(settings) {
- return await invoke('plugin:settings|settings_set', { settings })
-}
-
-export async function cancel_directory_change() {
- return await invoke('plugin:settings|cancel_directory_change')
-}
diff --git a/apps/app-frontend/src/helpers/settings.ts b/apps/app-frontend/src/helpers/settings.ts
new file mode 100644
index 000000000..c256575a4
--- /dev/null
+++ b/apps/app-frontend/src/helpers/settings.ts
@@ -0,0 +1,79 @@
+/**
+ * All theseus API calls return serialized values (both return values and errors);
+ * So, for example, addDefaultInstance creates a blank Profile object, where the Rust struct is serialized,
+ * and deserialized into a usable JS object.
+ */
+import { invoke } from '@tauri-apps/api/core'
+import type { ColorTheme, FeatureFlag } from '@/store/theme.ts'
+import type { Hooks, MemorySettings, WindowSize } from '@/helpers/types'
+
+// Settings object
+/*
+
+Settings {
+ "memory": MemorySettings,
+ "game_resolution": [int int],
+ "custom_java_args": [String ...],
+ "custom_env_args" : [(string, string) ... ]>,
+ "java_globals": Hash of (string, Path),
+ "default_user": Uuid string (can be null),
+ "hooks": Hooks,
+ "max_concurrent_downloads": uint,
+ "version": u32,
+ "collapsed_navigation": bool,
+}
+
+Memorysettings {
+ "min": u32, can be null,
+ "max": u32,
+}
+
+*/
+
+export type AppSettings = {
+ max_concurrent_downloads: number
+ max_concurrent_writes: number
+
+ theme: ColorTheme
+ default_page: 'home' | 'library'
+ collapsed_navigation: boolean
+ hide_nametag_skins_page: boolean
+ advanced_rendering: boolean
+ native_decorations: boolean
+ toggle_sidebar: boolean
+
+ telemetry: boolean
+ discord_rpc: boolean
+ personalized_ads: boolean
+
+ onboarded: boolean
+
+ extra_launch_args: string[]
+ custom_env_vars: [string, string][]
+ memory: MemorySettings
+ force_fullscreen: boolean
+ game_resolution: WindowSize
+ hide_on_process_start: boolean
+ hooks: Hooks
+
+ custom_dir?: string | null
+ prev_custom_dir?: string | null
+ migrated: boolean
+
+ developer_mode: boolean
+ feature_flags: Record
+}
+
+// Get full settings object
+export async function get() {
+ return (await invoke('plugin:settings|settings_get')) as AppSettings
+}
+
+// Set full settings object
+export async function set(settings: AppSettings) {
+ return await invoke('plugin:settings|settings_set', { settings })
+}
+
+export async function cancel_directory_change(): Promise {
+ return await invoke('plugin:settings|cancel_directory_change')
+}
diff --git a/apps/app-frontend/src/helpers/skins.ts b/apps/app-frontend/src/helpers/skins.ts
new file mode 100644
index 000000000..28a29ba1a
--- /dev/null
+++ b/apps/app-frontend/src/helpers/skins.ts
@@ -0,0 +1,167 @@
+import { invoke } from '@tauri-apps/api/core'
+import { handleError } from '@/store/notifications'
+import { arrayBufferToBase64 } from '@modrinth/utils'
+
+export interface Cape {
+ id: string
+ name: string
+ texture: string
+ is_default: boolean
+ is_equipped: boolean
+}
+
+export type SkinModel = 'CLASSIC' | 'SLIM' | 'UNKNOWN'
+export type SkinSource = 'default' | 'custom_external' | 'custom'
+
+export interface Skin {
+ texture_key: string
+ name?: string
+ variant: SkinModel
+ cape_id?: string
+ texture: string
+ source: SkinSource
+ is_equipped: boolean
+}
+
+export const DEFAULT_MODEL_SORTING = ['Steve', 'Alex'] as string[]
+
+export const DEFAULT_MODELS: Record = {
+ Steve: 'CLASSIC',
+ Alex: 'SLIM',
+ Zuri: 'CLASSIC',
+ Sunny: 'CLASSIC',
+ Noor: 'SLIM',
+ Makena: 'SLIM',
+ Kai: 'CLASSIC',
+ Efe: 'SLIM',
+ Ari: 'CLASSIC',
+}
+
+export function filterSavedSkins(list: Skin[]) {
+ const customSkins = list.filter((s) => s.source !== 'default')
+ fixUnknownSkins(customSkins).catch(handleError)
+ return customSkins
+}
+
+export async function determineModelType(texture: string): Promise<'SLIM' | 'CLASSIC'> {
+ return new Promise((resolve, reject) => {
+ const canvas = document.createElement('canvas')
+ const context = canvas.getContext('2d')
+
+ if (!context) {
+ return reject(new Error('Failed to create canvas rendering context.'))
+ }
+
+ const image = new Image()
+ image.crossOrigin = 'anonymous'
+ image.src = texture
+
+ image.onload = () => {
+ canvas.width = image.width
+ canvas.height = image.height
+
+ context.drawImage(image, 0, 0)
+
+ const armX = 44
+ const armY = 16
+ const armWidth = 4
+ const armHeight = 12
+
+ const imageData = context.getImageData(armX, armY, armWidth, armHeight).data
+
+ for (let y = 0; y < armHeight; y++) {
+ const alphaIndex = (3 + y * armWidth) * 4 + 3
+ if (imageData[alphaIndex] !== 0) {
+ resolve('CLASSIC')
+ return
+ }
+ }
+
+ canvas.remove()
+ resolve('SLIM')
+ }
+
+ image.onerror = () => {
+ canvas.remove()
+ reject(new Error('Failed to load the image.'))
+ }
+ })
+}
+
+export async function fixUnknownSkins(list: Skin[]) {
+ const unknownSkins = list.filter((s) => s.variant === 'UNKNOWN')
+ for (const unknownSkin of unknownSkins) {
+ unknownSkin.variant = await determineModelType(unknownSkin.texture)
+ }
+}
+
+export function filterDefaultSkins(list: Skin[]) {
+ return list
+ .filter(
+ (s) =>
+ s.source === 'default' &&
+ (!s.name || !(s.name in DEFAULT_MODELS) || s.variant === DEFAULT_MODELS[s.name]),
+ )
+ .sort((a, b) => {
+ const aIndex = a.name ? DEFAULT_MODEL_SORTING.indexOf(a.name) : -1
+ const bIndex = b.name ? DEFAULT_MODEL_SORTING.indexOf(b.name) : -1
+ return (aIndex === -1 ? Infinity : aIndex) - (bIndex === -1 ? Infinity : bIndex)
+ })
+}
+
+export async function get_available_capes(): Promise {
+ return invoke('plugin:minecraft-skins|get_available_capes', {})
+}
+
+export async function get_available_skins(): Promise {
+ return invoke('plugin:minecraft-skins|get_available_skins', {})
+}
+
+export async function add_and_equip_custom_skin(
+ textureBlob: Uint8Array,
+ variant: SkinModel,
+ capeOverride?: Cape,
+): Promise {
+ await invoke('plugin:minecraft-skins|add_and_equip_custom_skin', {
+ textureBlob,
+ variant,
+ capeOverride,
+ })
+}
+
+export async function set_default_cape(cape?: Cape): Promise {
+ await invoke('plugin:minecraft-skins|set_default_cape', {
+ cape,
+ })
+}
+
+export async function equip_skin(skin: Skin): Promise {
+ await invoke('plugin:minecraft-skins|equip_skin', {
+ skin,
+ })
+}
+
+export async function remove_custom_skin(skin: Skin): Promise {
+ await invoke('plugin:minecraft-skins|remove_custom_skin', {
+ skin,
+ })
+}
+
+export async function get_normalized_skin_texture(skin: Skin): Promise {
+ const data = await normalize_skin_texture(skin.texture)
+ const base64 = arrayBufferToBase64(data)
+ return `data:image/png;base64,${base64}`
+}
+
+export async function normalize_skin_texture(texture: Uint8Array | string): Promise {
+ return await invoke('plugin:minecraft-skins|normalize_skin_texture', { texture })
+}
+
+export async function unequip_skin(): Promise {
+ await invoke('plugin:minecraft-skins|unequip_skin')
+}
+
+export async function get_dragged_skin_data(path: string): Promise {
+ const data = await invoke('plugin:minecraft-skins|get_dragged_skin_data', { path })
+ return new Uint8Array(data)
+}
diff --git a/apps/app-frontend/src/helpers/storage/skin-preview-storage.ts b/apps/app-frontend/src/helpers/storage/skin-preview-storage.ts
new file mode 100644
index 000000000..2e4990850
--- /dev/null
+++ b/apps/app-frontend/src/helpers/storage/skin-preview-storage.ts
@@ -0,0 +1,118 @@
+import type { RenderResult } from '../rendering/batch-skin-renderer'
+
+interface StoredPreview {
+ forwards: Blob
+ backwards: Blob
+ timestamp: number
+}
+
+export class SkinPreviewStorage {
+ private dbName = 'skin-previews'
+ private version = 1
+ private db: IDBDatabase | null = null
+
+ async init(): Promise {
+ return new Promise((resolve, reject) => {
+ const request = indexedDB.open(this.dbName, this.version)
+
+ request.onerror = () => reject(request.error)
+ request.onsuccess = () => {
+ this.db = request.result
+ resolve()
+ }
+
+ request.onupgradeneeded = () => {
+ const db = request.result
+ if (!db.objectStoreNames.contains('previews')) {
+ db.createObjectStore('previews')
+ }
+ }
+ })
+ }
+
+ async store(key: string, result: RenderResult): Promise {
+ if (!this.db) await this.init()
+
+ const forwardsBlob = await fetch(result.forwards).then((r) => r.blob())
+ const backwardsBlob = await fetch(result.backwards).then((r) => r.blob())
+
+ const transaction = this.db!.transaction(['previews'], 'readwrite')
+ const store = transaction.objectStore('previews')
+
+ const storedPreview: StoredPreview = {
+ forwards: forwardsBlob,
+ backwards: backwardsBlob,
+ timestamp: Date.now(),
+ }
+
+ return new Promise((resolve, reject) => {
+ const request = store.put(storedPreview, key)
+
+ request.onsuccess = () => resolve()
+ request.onerror = () => reject(request.error)
+ })
+ }
+
+ async retrieve(key: string): Promise {
+ if (!this.db) await this.init()
+
+ const transaction = this.db!.transaction(['previews'], 'readonly')
+ const store = transaction.objectStore('previews')
+
+ return new Promise((resolve, reject) => {
+ const request = store.get(key)
+
+ request.onsuccess = () => {
+ const result = request.result as StoredPreview | undefined
+
+ if (!result) {
+ resolve(null)
+ return
+ }
+
+ const forwards = URL.createObjectURL(result.forwards)
+ const backwards = URL.createObjectURL(result.backwards)
+ resolve({ forwards, backwards })
+ }
+ request.onerror = () => reject(request.error)
+ })
+ }
+
+ async cleanupInvalidKeys(validKeys: Set): Promise {
+ if (!this.db) await this.init()
+
+ const transaction = this.db!.transaction(['previews'], 'readwrite')
+ const store = transaction.objectStore('previews')
+ let deletedCount = 0
+
+ return new Promise((resolve, reject) => {
+ const request = store.openCursor()
+
+ request.onsuccess = (event) => {
+ const cursor = (event.target as IDBRequest).result
+
+ if (cursor) {
+ const key = cursor.primaryKey as string
+
+ if (!validKeys.has(key)) {
+ const deleteRequest = cursor.delete()
+ deleteRequest.onsuccess = () => {
+ deletedCount++
+ }
+ deleteRequest.onerror = () => {
+ console.warn('Failed to delete invalid entry:', key)
+ }
+ }
+
+ cursor.continue()
+ } else {
+ resolve(deletedCount)
+ }
+ }
+
+ request.onerror = () => reject(request.error)
+ })
+ }
+}
+
+export const skinPreviewStorage = new SkinPreviewStorage()
diff --git a/apps/app-frontend/src/helpers/types.d.ts b/apps/app-frontend/src/helpers/types.d.ts
index 1007744d0..aa60ec2f7 100644
--- a/apps/app-frontend/src/helpers/types.d.ts
+++ b/apps/app-frontend/src/helpers/types.d.ts
@@ -48,6 +48,32 @@ type LinkedData = {
type InstanceLoader = 'vanilla' | 'forge' | 'fabric' | 'quilt' | 'neoforge'
+type ContentFile = {
+ hash: string
+ file_name: string
+ size: number
+ metadata?: FileMetadata
+ update_version_id?: string
+ project_type: ContentFileProjectType
+}
+
+type FileMetadata = {
+ project_id: string
+ version_id: string
+}
+
+type ContentFileProjectType = 'mod' | 'datapack' | 'resourcepack' | 'shaderpack'
+
+type CacheBehaviour =
+ // Serve expired data. If fetch fails / launcher is offline, errors are ignored
+ | 'stale_while_revalidate_skip_offline'
+ // Serve expired data, revalidate in background
+ | 'stale_while_revalidate'
+ // Must revalidate if data is expired
+ | 'must_revalidate'
+ // Ignore cache- always fetch updated data from origin
+ | 'bypass'
+
type MemorySettings = {
maximum: number
}
@@ -88,6 +114,7 @@ type AppSettings = {
collapsed_navigation: boolean
advanced_rendering: boolean
native_decorations: boolean
+ worlds_in_home: boolean
telemetry: boolean
discord_rpc: boolean
diff --git a/apps/app-frontend/src/helpers/update.js b/apps/app-frontend/src/helpers/update.js
index 223689512..6f3d87a13 100644
--- a/apps/app-frontend/src/helpers/update.js
+++ b/apps/app-frontend/src/helpers/update.js
@@ -2,23 +2,19 @@ import { ref } from 'vue'
import { getVersion } from '@tauri-apps/api/app'
import { getArtifact, getOS } from '@/helpers/utils.js'
-
export const allowState = ref(false)
export const installState = ref(false)
export const updateState = ref(false)
-export const latestBetaCommitTruncatedSha = ref('')
-export const latestBetaCommitLink = ref('')
-export const launcherUrl = 'https://www.astralium.su/get/ar'
-const os = ref('')
-const releaseLink = `https://api.github.com/repos/DIDIRUS4/AstralRinth/releases/latest`
-const branchesLink = `https://api.github.com/repos/DIDIRUS4/AstralRinth/branches`
+const currentOS = ref('')
+const releaseLink = `https://git.astralium.su/api/v1/repos/didirus/AstralRinth/releases/latest`
const failedFetch = [`Failed to fetch remote releases:`, `Failed to fetch remote commits:`]
-const betaBranch = `beta` // Github repository beta branch
-const osNames = ['macos', 'windows', 'linux']
-const macExtension = `.dmg` // MacOS file type for download
-const windowsExtension = `.msi` // Windows file type for download
-const blacklistedBuilds = [
+
+const osList = ['macos', 'windows', 'linux']
+const macExtensionList = ['.app', '.dmg']
+const windowsExtensionList = ['.exe', '.msi']
+
+const blacklistPrefixes = [
`dev`,
`nightly`,
`dirty`,
@@ -28,149 +24,73 @@ const blacklistedBuilds = [
`dirty_nightly`,
] // This is blacklisted builds for download. For example, file.startsWith('dev') is not allowed.
-/**
- * Asynchronously fetches branches and their latest commit information from the specified URLs.
- *
- * @return {Promise} This function does not return anything directly but updates the latestBetaCommitTruncatedSha and latestBetaCommitLink values.
- */
-export async function getBranches() {
- fetch(branchesLink)
- .then(async (response) => {
- if (response.ok) {
- response.json().then((data) => {
- const branches = data.map((branch) => branch)
- branches.forEach((branch) => {
- fetch(branch.commit.url).then(async (data) => {
- if (data.ok) {
- data.json().then((data) => {
- const truncatedSha = data.sha.slice(0, 7)
- const commitLink = data.html_url
- if (branch.name.toLowerCase() == betaBranch) {
- latestBetaCommitTruncatedSha.value = truncatedSha
- latestBetaCommitLink.value = commitLink
- }
- })
- } else {
- throw new Error(data.status)
- }
- })
- })
- })
- } else {
- throw new Error(response.status)
- }
- })
- .catch((error) => {
- latestBetaCommitTruncatedSha.value = error.message
- latestBetaCommitLink.value = undefined
- console.error(failedFetch[1], error)
- })
-}
+export async function getRemote(isDownloadState) {
+ var releaseData = null;
+ var result = false;
+ try {
+ const response = await fetch(releaseLink);
+ if (!response.ok) {
+ throw new Error(response.status);
+ }
+ const remoteData = await response.json();
+ currentOS.value = await getOS();
+ const remoteLatestReleaseTag = remoteData.tag_name;
+ releaseData = document.getElementById('releaseData');
+ const remoteVersion = releaseData ? (releaseData.textContent = remoteLatestReleaseTag) : remoteLatestReleaseTag;
-/**
- * Asynchronous function to get remote data and handle updates and downloads.
- *
- * @param {boolean} elementIdBool - Indicates whether to disable an element ID.
- * @param {boolean} downloadArtifactBool - Indicates whether to download an artifact.
- */
-export async function getRemote(elementIdBool, downloadArtifactBool) {
- fetch(releaseLink)
- .then((response) => {
- if (!response.ok) {
- throw new Error(response.status)
- }
- return response.json()
- })
- .then(async (data) => {
- os.value = await getOS()
- const latestRelease = data.name
- let remoteVersion = undefined
+ if (osList.includes(currentOS.value.toLowerCase())) {
+ const localVersion = await getVersion();
+ const isUpdateAvailable = !remoteVersion.includes(localVersion);
- if (!elementIdBool) {
- const releaseData = document.getElementById('releaseData')
- if (releaseData == null) {
- console.error('Release data element not found.')
- return false
- }
- releaseData.textContent = latestRelease
- remoteVersion = `${releaseData.textContent}`
- } else {
- remoteVersion = latestRelease
- }
- if (osNames.includes(os.value.toLowerCase())) {
- if (remoteVersion.startsWith('v' + await getVersion())) {
- updateState.value = false
- allowState.value = false
- } else {
- updateState.value = true
- allowState.value = true
- }
- } else {
- updateState.value = false
- allowState.value = false
- }
- console.log('Update available state is', updateState.value)
- console.log('Remote version is', remoteVersion)
- console.log('Local version is', await getVersion())
- console.log('Operating System is', os.value)
+ updateState.value = isUpdateAvailable;
+ allowState.value = isUpdateAvailable;
+ } else {
+ updateState.value = false;
+ allowState.value = false;
+ }
+ if (isDownloadState) {
+ installState.value = true;
+ const builds = remoteData.assets;
+ const fileName = getInstaller(getExtension(), builds);
+ result = fileName ? await getArtifact(fileName[1], fileName[0], currentOS.value, true) : false;
+ installState.value = false;
+ }
- if (downloadArtifactBool) {
- installState.value = true
- const builds = data.assets
- const fileName = getInstaller(getExtension(), builds)
- if (fileName != null) {
- await getArtifact(fileName[1], fileName[0], os.value, true)
- }
- installState.value = false
+ console.log('Update available state is', updateState.value);
+ console.log('Remote version is', remoteVersion);
+ console.log('Local version is', await getVersion());
+ console.log('Operating System is', currentOS.value);
+ return result;
+ } catch (error) {
+ console.error(failedFetch[0], error);
+ if (!releaseData) {
+ const errorData = document.getElementById('releaseData');
+ if (errorData) {
+ errorData.textContent = `${error.message}`;
}
- })
- .catch((error) => {
- console.error(failedFetch[0], error)
- if (!elementIdBool) {
- const errorData = document.getElementById('releaseData')
- if (errorData) {
- errorData.textContent = `${error.message}`
- }
- updateState.value = false
- allowState.value = false
- installState.value = false
- }
- })
-}
-
-/**
- * Retrieves the installer for a specific operating system.
- *
- * @param {string} osExtension - The file extension of the installer.
- * @param {Array} builds - The list of builds.
- * @return {Array|null} An array containing the installer name and URL if found, or null if not found.
- */
-function getInstaller(osExtension, builds) {
- for (let i of builds) {
- let blacklistedItem = false
- blacklistedBuilds.forEach((item) => {
- if (i.name.startsWith(item)) {
- return (blacklistedItem = true)
- }
- })
- if (i.name.endsWith(osExtension) && !blacklistedItem) {
- console.log(i.browser_download_url)
- return [i.name, i.browser_download_url]
+ updateState.value = false;
+ allowState.value = false;
+ installState.value = false;
}
}
- return null
}
-/**
- * A function to get the extension based on the operating system.
- *
- * @return {string} The extension based on the operating system.
- */
-function getExtension() {
- if (os.value.toLowerCase() == osNames[0]) {
- return macExtension
- } else if (os.value.toLowerCase() == osNames[1]) {
- return windowsExtension
+function getInstaller(osExtension, builds) {
+ console.log(osExtension, builds)
+ for (const build of builds) {
+ if (blacklistPrefixes.some(prefix => build.name.startsWith(prefix))) {
+ continue;
+ }
+ if (osExtension.some(ext => build.name.endsWith(ext))) {
+ console.log(build.name, build.browser_download_url);
+ return [build.name, build.browser_download_url];
+ }
}
- return null
-}
\ No newline at end of file
+ return null;
+}
+
+function getExtension() {
+ return osList.find(osName => osName === currentOS.value.toLowerCase())?.endsWith('macos')
+ ? macExtensionList
+ : windowsExtensionList;
+}
diff --git a/apps/app-frontend/src/helpers/utils.js b/apps/app-frontend/src/helpers/utils.js
index 53256838b..a6353504d 100644
--- a/apps/app-frontend/src/helpers/utils.js
+++ b/apps/app-frontend/src/helpers/utils.js
@@ -42,6 +42,13 @@ export async function restartApp() {
return await invoke('restart_app')
}
+/**
+ * @deprecated This method is no longer needed, and just returns its parameter
+ */
+export function sanitizePotentialFileUrl(url) {
+ return url
+}
+
export const releaseColor = (releaseType) => {
switch (releaseType) {
case 'release':
@@ -54,3 +61,7 @@ export const releaseColor = (releaseType) => {
return ''
}
}
+
+export async function copyToClipboard(text) {
+ await navigator.clipboard.writeText(text)
+}
diff --git a/apps/app-frontend/src/helpers/worlds.ts b/apps/app-frontend/src/helpers/worlds.ts
new file mode 100644
index 000000000..89f98d7d7
--- /dev/null
+++ b/apps/app-frontend/src/helpers/worlds.ts
@@ -0,0 +1,327 @@
+import { invoke } from '@tauri-apps/api/core'
+import { get_full_path } from '@/helpers/profile'
+import { openPath } from '@/helpers/utils'
+import { autoToHTML } from '@geometrically/minecraft-motd-parser'
+import dayjs from 'dayjs'
+import type { GameVersion } from '@modrinth/ui'
+
+type BaseWorld = {
+ name: string
+ last_played?: string
+ icon?: string
+ display_status: DisplayStatus
+ type: WorldType
+}
+
+export type WorldType = 'singleplayer' | 'server'
+export type DisplayStatus = 'normal' | 'hidden' | 'favorite'
+
+export type SingleplayerWorld = BaseWorld & {
+ type: 'singleplayer'
+ path: string
+ game_mode: SingleplayerGameMode
+ hardcore: boolean
+ locked: boolean
+}
+
+export type ServerWorld = BaseWorld & {
+ type: 'server'
+ index: number
+ address: string
+ pack_status: ServerPackStatus
+}
+
+export type World = SingleplayerWorld | ServerWorld
+
+export type WorldWithProfile = {
+ profile: string
+} & World
+
+export type SingleplayerGameMode = 'survival' | 'creative' | 'adventure' | 'spectator'
+export type ServerPackStatus = 'enabled' | 'disabled' | 'prompt'
+
+export type ServerStatus = {
+ // https://minecraft.wiki/w/Text_component_format
+ description?: string | Chat
+ players?: {
+ max: number
+ online: number
+ sample: { name: string; id: string }[]
+ }
+ version?: {
+ name: string
+ protocol: number
+ }
+ favicon?: string
+ enforces_secure_chat: boolean
+ ping?: number
+}
+
+export interface Chat {
+ text: string
+ bold: boolean
+ italic: boolean
+ underlined: boolean
+ strikethrough: boolean
+ obfuscated: boolean
+ color?: string
+ extra: Chat[]
+}
+
+export type ServerData = {
+ refreshing: boolean
+ status?: ServerStatus
+ rawMotd?: string | Chat
+ renderedMotd?: string
+}
+
+export async function get_recent_worlds(
+ limit: number,
+ displayStatuses?: DisplayStatus[],
+): Promise {
+ return await invoke('plugin:worlds|get_recent_worlds', { limit, displayStatuses })
+}
+
+export async function get_profile_worlds(path: string): Promise {
+ return await invoke('plugin:worlds|get_profile_worlds', { path })
+}
+
+export async function get_singleplayer_world(
+ instance: string,
+ world: string,
+): Promise {
+ return await invoke('plugin:worlds|get_singleplayer_world', { instance, world })
+}
+
+export async function set_world_display_status(
+ instance: string,
+ worldType: WorldType,
+ worldId: string,
+ displayStatus: DisplayStatus,
+): Promise {
+ return await invoke('plugin:worlds|set_world_display_status', {
+ instance,
+ worldType,
+ worldId,
+ displayStatus,
+ })
+}
+
+export async function rename_world(
+ instance: string,
+ world: string,
+ newName: string,
+): Promise {
+ return await invoke('plugin:worlds|rename_world', { instance, world, newName })
+}
+
+export async function reset_world_icon(instance: string, world: string): Promise {
+ return await invoke('plugin:worlds|reset_world_icon', { instance, world })
+}
+
+export async function backup_world(instance: string, world: string): Promise {
+ return await invoke('plugin:worlds|backup_world', { instance, world })
+}
+
+export async function delete_world(instance: string, world: string): Promise {
+ return await invoke('plugin:worlds|delete_world', { instance, world })
+}
+
+export async function add_server_to_profile(
+ path: string,
+ name: string,
+ address: string,
+ packStatus: ServerPackStatus,
+): Promise {
+ return await invoke('plugin:worlds|add_server_to_profile', { path, name, address, packStatus })
+}
+
+export async function edit_server_in_profile(
+ path: string,
+ index: number,
+ name: string,
+ address: string,
+ packStatus: ServerPackStatus,
+): Promise {
+ return await invoke('plugin:worlds|edit_server_in_profile', {
+ path,
+ index,
+ name,
+ address,
+ packStatus,
+ })
+}
+
+export async function remove_server_from_profile(path: string, index: number): Promise {
+ return await invoke('plugin:worlds|remove_server_from_profile', { path, index })
+}
+
+export async function get_profile_protocol_version(path: string): Promise {
+ return await invoke('plugin:worlds|get_profile_protocol_version', { path })
+}
+
+export async function get_server_status(
+ address: string,
+ protocolVersion: number | null = null,
+): Promise {
+ return await invoke('plugin:worlds|get_server_status', { address, protocolVersion })
+}
+
+export async function start_join_singleplayer_world(path: string, world: string): Promise {
+ return await invoke('plugin:worlds|start_join_singleplayer_world', { path, world })
+}
+
+export async function start_join_server(path: string, address: string): Promise {
+ return await invoke('plugin:worlds|start_join_server', { path, address })
+}
+
+export async function showWorldInFolder(instancePath: string, worldPath: string) {
+ const fullPath = await get_full_path(instancePath)
+ return await openPath(fullPath + '/saves/' + worldPath)
+}
+
+export function getWorldIdentifier(world: World) {
+ return world.type === 'singleplayer' ? world.path : world.address
+}
+
+export function sortWorlds(worlds: World[]) {
+ worlds.sort((a, b) => {
+ if (!a.last_played) {
+ return 1
+ }
+ if (!b.last_played) {
+ return -1
+ }
+ return dayjs(b.last_played).diff(dayjs(a.last_played))
+ })
+}
+
+export function isSingleplayerWorld(world: World): world is SingleplayerWorld {
+ return world.type === 'singleplayer'
+}
+
+export function isServerWorld(world: World): world is ServerWorld {
+ return world.type === 'server'
+}
+
+export async function refreshServerData(
+ serverData: ServerData,
+ protocolVersion: number | null,
+ address: string,
+): Promise {
+ serverData.refreshing = true
+ await get_server_status(address, protocolVersion)
+ .then((status) => {
+ serverData.status = status
+ if (status.description) {
+ serverData.rawMotd = status.description
+ serverData.renderedMotd = autoToHTML(status.description)
+ }
+ })
+ .catch((err) => {
+ console.error(`Refreshing addr: ${address}`, err)
+ })
+ .finally(() => {
+ serverData.refreshing = false
+ })
+}
+
+export async function refreshServers(
+ worlds: World[],
+ serverData: Record,
+ protocolVersion: number | null,
+) {
+ const servers = worlds.filter(isServerWorld)
+ servers.forEach((server) => {
+ if (!serverData[server.address]) {
+ serverData[server.address] = {
+ refreshing: true,
+ }
+ } else {
+ serverData[server.address].refreshing = true
+ }
+ })
+
+ // noinspection ES6MissingAwait - handled with .then by refreshServerData already
+ Promise.all(
+ Object.keys(serverData).map((address) =>
+ refreshServerData(serverData[address], protocolVersion, address),
+ ),
+ )
+}
+
+export async function refreshWorld(worlds: World[], instancePath: string, worldPath: string) {
+ const index = worlds.findIndex((w) => w.type === 'singleplayer' && w.path === worldPath)
+ const newWorld = await get_singleplayer_world(instancePath, worldPath)
+ if (index !== -1) {
+ worlds[index] = newWorld
+ } else {
+ console.info(`Adding new world at path: ${worldPath}.`)
+ worlds.push(newWorld)
+ }
+ sortWorlds(worlds)
+}
+
+export async function handleDefaultProfileUpdateEvent(
+ worlds: World[],
+ instancePath: string,
+ e: ProfileEvent,
+) {
+ if (e.event === 'world_updated') {
+ await refreshWorld(worlds, instancePath, e.world)
+ }
+
+ if (e.event === 'server_joined') {
+ const world = worlds.find(
+ (w) =>
+ w.type === 'server' &&
+ (w.address === `${e.host}:${e.port}` || (e.port == 25565 && w.address == e.host)),
+ )
+ if (world) {
+ world.last_played = e.timestamp
+ sortWorlds(worlds)
+ } else {
+ console.error(`Could not find world for server join event: ${e.host}:${e.port}`)
+ }
+ }
+}
+
+export async function refreshWorlds(instancePath: string): Promise {
+ const worlds = await get_profile_worlds(instancePath).catch((err) => {
+ console.error(`Error refreshing worlds for instance: ${instancePath}`, err)
+ })
+ if (worlds) {
+ sortWorlds(worlds)
+ }
+
+ return worlds ?? []
+}
+
+const FIRST_QUICK_PLAY_VERSION = '23w14a'
+
+export function hasQuickPlaySupport(gameVersions: GameVersion[], currentVersion: string) {
+ if (!gameVersions.length) {
+ return false
+ }
+
+ const versionIndex = gameVersions.findIndex((v) => v.version === currentVersion)
+ const targetIndex = gameVersions.findIndex((v) => v.version === FIRST_QUICK_PLAY_VERSION)
+
+ return versionIndex !== -1 && targetIndex !== -1 && versionIndex <= targetIndex
+}
+
+export type ProfileEvent = { profile_path_id: string } & (
+ | {
+ event: 'servers_updated'
+ }
+ | {
+ event: 'world_updated'
+ world: string
+ }
+ | {
+ event: 'server_joined'
+ host: string
+ port: number
+ timestamp: string
+ }
+)
diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json
index 515e4e71a..fa2563da9 100644
--- a/apps/app-frontend/src/locales/en-US/index.json
+++ b/apps/app-frontend/src/locales/en-US/index.json
@@ -20,12 +20,60 @@
"app.settings.tabs.resource-management": {
"message": "Resource management"
},
+ "instance.add-server.add-and-play": {
+ "message": "Add and play"
+ },
+ "instance.add-server.add-server": {
+ "message": "Add server"
+ },
+ "instance.add-server.resource-pack.disabled": {
+ "message": "Disabled"
+ },
+ "instance.add-server.resource-pack.enabled": {
+ "message": "Enabled"
+ },
+ "instance.add-server.resource-pack.prompt": {
+ "message": "Prompt"
+ },
+ "instance.add-server.title": {
+ "message": "Add a server"
+ },
+ "instance.edit-server.title": {
+ "message": "Edit server"
+ },
+ "instance.edit-world.hide-from-home": {
+ "message": "Hide from the Home page"
+ },
+ "instance.edit-world.name": {
+ "message": "Name"
+ },
+ "instance.edit-world.placeholder-name": {
+ "message": "Minecraft World"
+ },
+ "instance.edit-world.reset-icon": {
+ "message": "Reset icon"
+ },
+ "instance.edit-world.title": {
+ "message": "Edit world"
+ },
"instance.filter.disabled": {
"message": "Disabled projects"
},
"instance.filter.updates-available": {
"message": "Updates available"
},
+ "instance.server-modal.address": {
+ "message": "Address"
+ },
+ "instance.server-modal.name": {
+ "message": "Name"
+ },
+ "instance.server-modal.placeholder-name": {
+ "message": "Minecraft Server"
+ },
+ "instance.server-modal.resource-pack": {
+ "message": "Resource pack"
+ },
"instance.settings.tabs.general": {
"message": "General"
},
@@ -308,6 +356,48 @@
"instance.settings.title": {
"message": "Settings"
},
+ "instance.worlds.a_minecraft_server": {
+ "message": "A Minecraft Server"
+ },
+ "instance.worlds.cant_connect": {
+ "message": "Can't connect to server"
+ },
+ "instance.worlds.copy_address": {
+ "message": "Copy address"
+ },
+ "instance.worlds.dont_show_on_home": {
+ "message": "Don't show on Home"
+ },
+ "instance.worlds.filter.available": {
+ "message": "Available"
+ },
+ "instance.worlds.game_already_open": {
+ "message": "Instance is already open"
+ },
+ "instance.worlds.hardcore": {
+ "message": "Hardcore mode"
+ },
+ "instance.worlds.no_quick_play": {
+ "message": "You can only jump straight into worlds on Minecraft 1.20+"
+ },
+ "instance.worlds.play_anyway": {
+ "message": "Play anyway"
+ },
+ "instance.worlds.play_instance": {
+ "message": "Play instance"
+ },
+ "instance.worlds.type.server": {
+ "message": "Server"
+ },
+ "instance.worlds.type.singleplayer": {
+ "message": "Singleplayer"
+ },
+ "instance.worlds.view_instance": {
+ "message": "View instance"
+ },
+ "instance.worlds.world_in_use": {
+ "message": "World is in use"
+ },
"search.filter.locked.instance": {
"message": "Provided by the instance"
},
diff --git a/apps/app-frontend/src/main.js b/apps/app-frontend/src/main.js
index ba6d3f49b..a37a7018f 100644
--- a/apps/app-frontend/src/main.js
+++ b/apps/app-frontend/src/main.js
@@ -6,6 +6,7 @@ import FloatingVue from 'floating-vue'
import 'floating-vue/dist/style.css'
import { createPlugin } from '@vintl/vintl/plugin'
import * as Sentry from '@sentry/vue'
+import { VueScanPlugin } from '@taijased/vue-render-tracker'
const VIntlPlugin = createPlugin({
controllerOpts: {
@@ -24,6 +25,13 @@ const VIntlPlugin = createPlugin({
injectInto: [],
})
+const vueScan = new VueScanPlugin({
+ enabled: false, // Enable or disable the tracker
+ showOverlay: true, // Show overlay to visualize renders
+ log: false, // Log render events to the console
+ playSound: false, // Play sound on each render
+})
+
const pinia = createPinia()
let app = createApp(App)
@@ -35,6 +43,7 @@ Sentry.init({
tracesSampleRate: 0.1,
})
+app.use(vueScan)
app.use(router)
app.use(pinia)
app.use(FloatingVue, {
diff --git a/apps/app-frontend/src/pages/Browse.vue b/apps/app-frontend/src/pages/Browse.vue
index a57b2fddd..f11e2c044 100644
--- a/apps/app-frontend/src/pages/Browse.vue
+++ b/apps/app-frontend/src/pages/Browse.vue
@@ -220,6 +220,7 @@ async function refreshSearch() {
}
}
results.value = rawResults.result
+ currentPage.value = Math.max(1, Math.min(pageCount.value, currentPage.value))
const persistentParams: LocationQuery = {}
diff --git a/apps/app-frontend/src/pages/Index.vue b/apps/app-frontend/src/pages/Index.vue
index fcf8607c4..30ea5d957 100644
--- a/apps/app-frontend/src/pages/Index.vue
+++ b/apps/app-frontend/src/pages/Index.vue
@@ -1,4 +1,4 @@
-
+
+
+ loadSkins()"
+ @open-upload-modal="openUploadSkinModal"
+ />
+
+
+
+
+
+
+
+ Skins
+ Beta
+
+
+
+
+
+
+ selectCapeModal?.show(
+ e,
+ selectedSkin?.texture_key,
+ currentCape,
+ skinTexture,
+ skinVariant,
+ )
+ "
+ >
+
+ Change cape
+
+
+
+
+
+
+
+
+
+ Saved skins
+
+
+
+
+
+ Add a skin
+
+
+
+
+ editSkinModal?.show(e, skin)"
+ >
+ Edit
+
+ confirmDeleteSkin(skin)"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
![Excited Modrinth Bot]()
+
+
+
+
+
+ Please sign into your Minecraft account to use the skin management features of the
+ Modrinth app.
+
+
+
+
+
+ Sign In
+
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/pages/Worlds.vue b/apps/app-frontend/src/pages/Worlds.vue
new file mode 100644
index 000000000..8c1f57bf3
--- /dev/null
+++ b/apps/app-frontend/src/pages/Worlds.vue
@@ -0,0 +1,4 @@
+
+
+ Worlds
+
diff --git a/apps/app-frontend/src/pages/index.js b/apps/app-frontend/src/pages/index.js
index 6c4866b46..2e0361cd5 100644
--- a/apps/app-frontend/src/pages/index.js
+++ b/apps/app-frontend/src/pages/index.js
@@ -1,4 +1,6 @@
import Index from './Index.vue'
import Browse from './Browse.vue'
+import Worlds from './Worlds.vue'
+import Skins from './Skins.vue'
-export { Index, Browse }
+export { Index, Browse, Worlds, Skins }
diff --git a/apps/app-frontend/src/pages/instance/Index.vue b/apps/app-frontend/src/pages/instance/Index.vue
index d2f3d52e0..8b6d63bfa 100644
--- a/apps/app-frontend/src/pages/instance/Index.vue
+++ b/apps/app-frontend/src/pages/instance/Index.vue
@@ -1,152 +1,160 @@
- handleRightClick(event, instance.path)"
- >
-
-
-
-
-
-
-
- {{ instance.name }}
-
-
-
-
-
- {{ instance.loader }} {{ instance.game_version }}
-
-
-
-
- {{ timePlayedHumanized }}
-
- Never played
-
-
-
-
-
- Installing...
-
-
-
-
- Repair
-
-
-
-
-
- Stop
-
-
-
-
-
- Play
-
-
-
- Loading...
-
-
-
-
-
-
-
-
-
- Share instance
- Create a server
- Open folder
- Export modpack
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Play
- Stop
- Add content
- Edit
- Copy path
- Open folder
- Copy link
- Open in Modrinth
- Copy names
- Copy slugs
- Copy links
- Toggle selected
- Disable selected
- Enable selected
- Show/Hide unselected
- Update {{ selected.length > 0 ? 'selected' : 'all' }}
+ handleRightClick(event, instance.path)"
>
-
Select Updatable
-
+
+
+
+
+
+
+
+ {{ instance.name }}
+
+
+
+
+
+ {{ instance.loader }} {{ instance.game_version }}
+
+
+
+
+ {{ timePlayedHumanized }}
+
+ Never played
+
+
+
+
+
+ Installing...
+
+
+
+
+ Repair
+
+
+
+
+
+ Stop
+
+
+
+
+
+ Play
+
+
+
+ Loading...
+
+
+
+
+
+
+
+
+
+ Share instance
+ Create a server
+ Open folder
+ Export modpack
+
+
+
+
+
+
+
+
+
+
+
+
+
+ stopInstance('InstanceSubpage')"
+ >
+
+
+
+
+
+
+
+
+ Play
+ Stop
+ Add content
+ Edit
+ Copy path
+ Open folder
+ Copy link
+ Open in Modrinth
+ Copy names
+ Copy slugs
+ Copy links
+ Toggle selected
+ Disable selected
+ Enable selected
+ Show/Hide unselected
+ Update {{ selected.length > 0 ? 'selected' : 'all' }}
+ Select Updatable
+
+
diff --git a/apps/app-frontend/src/pages/instance/Worlds.vue b/apps/app-frontend/src/pages/instance/Worlds.vue
new file mode 100644
index 000000000..8490269e3
--- /dev/null
+++ b/apps/app-frontend/src/pages/instance/Worlds.vue
@@ -0,0 +1,465 @@
+
+ {
+ addServer(server)
+ if (start) {
+ joinWorld(server)
+ }
+ }
+ "
+ />
+
+
+
+
+
+
+
+
+
+ (searchFilter = '')">
+
+
+
+
+
+
+
+ Refreshing...
+
+
+
+ Refresh
+
+
+
+
+
+
+ Add a server
+
+
+
+
+
+ joinWorld(world)"
+ @stop="() => emit('stop')"
+ @refresh="() => refreshServer((world as ServerWorld).address)"
+ @edit="
+ () =>
+ world.type === 'server' ? editServerModal?.show(world) : editWorldModal?.show(world)
+ "
+ @delete="() => promptToRemoveWorld(world)"
+ @open-folder="(world: SingleplayerWorld) => showWorldInFolder(instance.path, world.path)"
+ />
+
+
+
+
+
+

+
You don't have any worlds yet.
+
+
+
+
+
+
+ Add a server
+
+
+
+
+
+
+ Refreshing...
+
+
+
+ Refresh
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/pages/instance/index.js b/apps/app-frontend/src/pages/instance/index.js
index e433570eb..fa77df524 100644
--- a/apps/app-frontend/src/pages/instance/index.js
+++ b/apps/app-frontend/src/pages/instance/index.js
@@ -1,5 +1,7 @@
import Index from './Index.vue'
+import Overview from './Overview.vue'
+import Worlds from './Worlds.vue'
import Mods from './Mods.vue'
import Logs from './Logs.vue'
-export { Index, Mods, Logs }
+export { Index, Overview, Worlds, Mods, Logs }
diff --git a/apps/app-frontend/src/pages/project/Index.vue b/apps/app-frontend/src/pages/project/Index.vue
index 1f5082b13..941ab0d08 100644
--- a/apps/app-frontend/src/pages/project/Index.vue
+++ b/apps/app-frontend/src/pages/project/Index.vue
@@ -155,7 +155,7 @@ import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import { get as getInstance, get_projects as getInstanceProjects } from '@/helpers/profile'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
-import { useRoute } from 'vue-router'
+import { useRoute, useRouter } from 'vue-router'
import { ref, shallowRef, watch } from 'vue'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { handleError } from '@/store/notifications.js'
@@ -170,6 +170,7 @@ import { openUrl } from '@tauri-apps/plugin-opener'
dayjs.extend(relativeTime)
const route = useRoute()
+const router = useRouter()
const breadcrumbs = useBreadcrumbs()
const themeStore = useTheming()
@@ -192,6 +193,11 @@ const [allLoaders, allGameVersions] = await Promise.all([
async function fetchProjectData() {
const project = await get_project(route.params.id, 'must_revalidate').catch(handleError)
+ if (!project) {
+ handleError('Error loading project')
+ return
+ }
+
data.value = project
;[versions.value, members.value, categories.value, instance.value, instanceProjects.value] =
await Promise.all([
@@ -242,6 +248,9 @@ async function install(version) {
installedVersion.value = version
}
},
+ (profile) => {
+ router.push(`/instance/${profile}`)
+ },
)
}
diff --git a/apps/app-frontend/src/routes.js b/apps/app-frontend/src/routes.js
index 49eae8461..67172e68d 100644
--- a/apps/app-frontend/src/routes.js
+++ b/apps/app-frontend/src/routes.js
@@ -18,6 +18,14 @@ export default new createRouter({
breadcrumb: [{ name: 'Home' }],
},
},
+ {
+ path: '/worlds',
+ name: 'Worlds',
+ component: Pages.Worlds,
+ meta: {
+ breadcrumb: [{ name: 'Worlds' }],
+ },
+ },
{
path: '/browse/:projectType',
name: 'Discover content',
@@ -26,6 +34,14 @@ export default new createRouter({
breadcrumb: [{ name: 'Discover content' }],
},
},
+ {
+ path: '/skins',
+ name: 'Skins',
+ component: Pages.Skins,
+ meta: {
+ breadcrumb: [{ name: 'Skins' }],
+ },
+ },
{
path: '/library',
name: 'Library',
@@ -106,13 +122,31 @@ export default new createRouter({
component: Instance.Index,
props: true,
children: [
+ // {
+ // path: '',
+ // name: 'Overview',
+ // component: Instance.Overview,
+ // meta: {
+ // useRootContext: true,
+ // breadcrumb: [{ name: '?Instance' }],
+ // },
+ // },
+ {
+ path: 'worlds',
+ name: 'InstanceWorlds',
+ component: Instance.Worlds,
+ meta: {
+ useRootContext: true,
+ breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Worlds' }],
+ },
+ },
{
path: '',
name: 'Mods',
component: Instance.Mods,
meta: {
useRootContext: true,
- breadcrumb: [{ name: '?Instance' }],
+ breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Content' }],
},
},
{
@@ -121,7 +155,7 @@ export default new createRouter({
component: Instance.Mods,
meta: {
useRootContext: true,
- breadcrumb: [{ name: '?Instance' }],
+ breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Content' }],
},
},
{
diff --git a/apps/app-frontend/src/store/install.js b/apps/app-frontend/src/store/install.js
index 90ca66cd0..4746b9070 100644
--- a/apps/app-frontend/src/store/install.js
+++ b/apps/app-frontend/src/store/install.js
@@ -23,8 +23,8 @@ export const useInstall = defineStore('installStore', {
setInstallConfirmModal(ref) {
this.installConfirmModal = ref
},
- showInstallConfirmModal(project, version_id, onInstall) {
- this.installConfirmModal.show(project, version_id, onInstall)
+ showInstallConfirmModal(project, version_id, onInstall, createInstanceCallback) {
+ this.installConfirmModal.show(project, version_id, onInstall, createInstanceCallback)
},
setIncompatibilityWarningModal(ref) {
this.incompatibilityWarningModal = ref
@@ -41,7 +41,14 @@ export const useInstall = defineStore('installStore', {
},
})
-export const install = async (projectId, versionId, instancePath, source, callback = () => {}) => {
+export const install = async (
+ projectId,
+ versionId,
+ instancePath,
+ source,
+ callback = () => {},
+ createInstanceCallback = () => {},
+) => {
const project = await get_project(projectId, 'must_revalidate').catch(handleError)
if (project.project_type === 'modpack') {
@@ -49,7 +56,13 @@ export const install = async (projectId, versionId, instancePath, source, callba
const packs = await list().catch(handleError)
if (packs.length === 0 || !packs.find((pack) => pack.linked_data?.project_id === project.id)) {
- await packInstall(project.id, version, project.title, project.icon_url).catch(handleError)
+ await packInstall(
+ project.id,
+ version,
+ project.title,
+ project.icon_url,
+ createInstanceCallback,
+ ).catch(handleError)
trackEvent('PackInstall', {
id: project.id,
@@ -61,7 +74,7 @@ export const install = async (projectId, versionId, instancePath, source, callba
callback(version)
} else {
const install = useInstall()
- install.showInstallConfirmModal(project, version, callback)
+ install.showInstallConfirmModal(project, version, callback, createInstanceCallback)
}
} else {
if (instancePath) {
diff --git a/apps/app-frontend/src/store/state.js b/apps/app-frontend/src/store/state.js
index dd68a6eec..e9811e623 100644
--- a/apps/app-frontend/src/store/state.js
+++ b/apps/app-frontend/src/store/state.js
@@ -1,4 +1,4 @@
-import { useTheming } from './theme'
+import { useTheming } from './theme.ts'
import { useBreadcrumbs } from './breadcrumbs'
import { useLoading } from './loading'
import { useNotifications, handleError } from './notifications'
diff --git a/apps/app-frontend/src/store/theme.js b/apps/app-frontend/src/store/theme.js
deleted file mode 100644
index d9111f50c..000000000
--- a/apps/app-frontend/src/store/theme.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import { defineStore } from 'pinia'
-
-export const useTheming = defineStore('themeStore', {
- state: () => ({
- themeOptions: ['dark', 'light', 'oled', 'system'],
- advancedRendering: true,
- selectedTheme: 'dark',
- toggleSidebar: false,
-
- devMode: false,
- featureFlags: {},
- }),
- actions: {
- setThemeState(newTheme) {
- if (this.themeOptions.includes(newTheme)) this.selectedTheme = newTheme
- else console.warn('Selected theme is not present. Check themeOptions.')
-
- this.setThemeClass()
- },
- setThemeClass() {
- for (const theme of this.themeOptions) {
- document.getElementsByTagName('html')[0].classList.remove(`${theme}-mode`)
- }
-
- let theme = this.selectedTheme
- if (this.selectedTheme === 'system') {
- const darkThemeMq = window.matchMedia('(prefers-color-scheme: dark)')
- if (darkThemeMq.matches) {
- theme = 'dark'
- } else {
- theme = 'light'
- }
- }
-
- document.getElementsByTagName('html')[0].classList.add(`${theme}-mode`)
- },
- },
-})
diff --git a/apps/app-frontend/src/store/theme.ts b/apps/app-frontend/src/store/theme.ts
new file mode 100644
index 000000000..a79094167
--- /dev/null
+++ b/apps/app-frontend/src/store/theme.ts
@@ -0,0 +1,70 @@
+import { defineStore } from 'pinia'
+
+export const DEFAULT_FEATURE_FLAGS = {
+ project_background: false,
+ page_path: false,
+ worlds_tab: false,
+ worlds_in_home: true,
+}
+
+export const THEME_OPTIONS = ['dark', 'light', 'oled', 'system'] as const
+
+export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
+export type FeatureFlags = Record
+export type ColorTheme = (typeof THEME_OPTIONS)[number]
+
+export type ThemeStore = {
+ selectedTheme: ColorTheme
+ advancedRendering: boolean
+ toggleSidebar: boolean
+
+ devMode: boolean
+ featureFlags: FeatureFlags
+}
+
+export const DEFAULT_THEME_STORE: ThemeStore = {
+ selectedTheme: 'dark',
+ advancedRendering: true,
+ toggleSidebar: false,
+
+ devMode: false,
+ featureFlags: DEFAULT_FEATURE_FLAGS,
+}
+
+export const useTheming = defineStore('themeStore', {
+ state: () => DEFAULT_THEME_STORE,
+ actions: {
+ setThemeState(newTheme: ColorTheme) {
+ if (THEME_OPTIONS.includes(newTheme)) {
+ this.selectedTheme = newTheme
+ } else {
+ console.warn('Selected theme is not present. Check themeOptions.')
+ }
+
+ this.setThemeClass()
+ },
+ setThemeClass() {
+ for (const theme of THEME_OPTIONS) {
+ document.getElementsByTagName('html')[0].classList.remove(`${theme}-mode`)
+ }
+
+ let theme = this.selectedTheme
+ if (this.selectedTheme === 'system') {
+ const darkThemeMq = window.matchMedia('(prefers-color-scheme: dark)')
+ if (darkThemeMq.matches) {
+ theme = 'dark'
+ } else {
+ theme = 'light'
+ }
+ }
+
+ document.getElementsByTagName('html')[0].classList.add(`${theme}-mode`)
+ },
+ getFeatureFlag(key: FeatureFlag) {
+ return this.featureFlags[key] ?? DEFAULT_FEATURE_FLAGS[key]
+ },
+ getThemeOptions() {
+ return THEME_OPTIONS
+ },
+ },
+})
diff --git a/apps/app-frontend/tailwind.config.js b/apps/app-frontend/tailwind.config.js
index 0d0fab4bf..b5196b368 100644
--- a/apps/app-frontend/tailwind.config.js
+++ b/apps/app-frontend/tailwind.config.js
@@ -41,6 +41,7 @@ export default {
green: 'var(--color-green-highlight)',
blue: 'var(--color-blue-highlight)',
purple: 'var(--color-purple-highlight)',
+ gray: 'var(--color-gray-highlight)',
},
divider: {
DEFAULT: 'var(--color-divider)',
diff --git a/apps/app-frontend/tsconfig.node.json b/apps/app-frontend/tsconfig.node.json
index e5a932a9e..ac300be84 100644
--- a/apps/app-frontend/tsconfig.node.json
+++ b/apps/app-frontend/tsconfig.node.json
@@ -10,6 +10,7 @@
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
+ "resolveJsonModule": true,
"strict": true
},
diff --git a/apps/app-frontend/vite.config.ts b/apps/app-frontend/vite.config.ts
index 3f88715a9..8adf5fb2d 100644
--- a/apps/app-frontend/vite.config.ts
+++ b/apps/app-frontend/vite.config.ts
@@ -4,6 +4,8 @@ import svgLoader from 'vite-svg-loader'
import vue from '@vitejs/plugin-vue'
+import tauriConf from '../app/tauri.conf.json'
+
const projectRootDir = resolve(__dirname)
// https://vitejs.dev/config/
@@ -41,17 +43,32 @@ export default defineConfig({
server: {
port: 1420,
strictPort: true,
+ headers: {
+ 'content-security-policy': Object.entries(tauriConf.app.security.csp)
+ .map(([directive, sources]) => {
+ // An additional websocket connect-src is required for Vite dev tools to work
+ if (directive === 'connect-src') {
+ sources = Array.isArray(sources) ? sources : [sources]
+ sources.push('ws://localhost:1420')
+ }
+
+ return Array.isArray(sources)
+ ? `${directive} ${sources.join(' ')}`
+ : `${directive} ${sources}`
+ })
+ .join('; '),
+ },
},
// to make use of `TAURI_ENV_DEBUG` and other env variables
// https://v2.tauri.app/reference/environment-variables/#tauri-cli-hook-commands
envPrefix: ['VITE_', 'TAURI_'],
build: {
// Tauri supports es2021
- target: process.env.TAURI_PLATFORM == 'windows' ? 'chrome105' : 'safari13', // eslint-disable-line turbo/no-undeclared-env-vars
+ target: process.env.TAURI_ENV_PLATFORM == 'windows' ? 'chrome105' : 'safari13', // eslint-disable-line turbo/no-undeclared-env-vars
// don't minify for debug builds
- minify: !process.env.TAURI_DEBUG ? 'esbuild' : false, // eslint-disable-line turbo/no-undeclared-env-vars
+ minify: !process.env.TAURI_ENV_DEBUG ? 'esbuild' : false, // eslint-disable-line turbo/no-undeclared-env-vars
// produce sourcemaps for debug builds
- sourcemap: !!process.env.TAURI_DEBUG, // eslint-disable-line turbo/no-undeclared-env-vars
+ sourcemap: !!process.env.TAURI_ENV_DEBUG, // eslint-disable-line turbo/no-undeclared-env-vars
commonjsOptions: {
esmExternals: true,
},
diff --git a/apps/app-playground/Cargo.toml b/apps/app-playground/Cargo.toml
index a37251612..691c9d3b7 100644
--- a/apps/app-playground/Cargo.toml
+++ b/apps/app-playground/Cargo.toml
@@ -1,24 +1,14 @@
[package]
name = "theseus_playground"
version = "0.0.0"
-edition = "2021"
+edition.workspace = true
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
-theseus = { path = "../../packages/app-lib", features = ["cli"] }
+theseus = { workspace = true, features = ["cli"] }
+tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
+enumset.workspace = true
-serde_json = "1.0"
-serde = { version = "1.0", features = ["derive"] }
-tokio = { version = "1", features = ["full"] }
-thiserror = "1.0"
-url = "2.2"
-webbrowser = "0.8.13"
-dunce = "1.0.3"
-
-futures = "0.3"
-uuid = { version = "1.1", features = ["serde", "v4"] }
-
-tracing = "0.1.37"
-tracing-subscriber = "0.3.18"
-tracing-error = "0.2.0"
+[lints]
+workspace = true
diff --git a/apps/app-playground/package.json b/apps/app-playground/package.json
index 0d76eaed8..342b3cecb 100644
--- a/apps/app-playground/package.json
+++ b/apps/app-playground/package.json
@@ -2,9 +2,9 @@
"name": "@modrinth/app-playground",
"scripts": {
"build": "cargo build --release",
- "lint": "cargo fmt --check && cargo clippy --all-targets --all-features -- -D warnings",
- "fix": "cargo fmt && cargo clippy --fix",
+ "lint": "cargo fmt --check && cargo clippy --all-targets",
+ "fix": "cargo clippy --all-targets --fix --allow-dirty && cargo fmt",
"dev": "cargo run",
- "test": "cargo test"
+ "test": "cargo nextest run --all-targets --no-fail-fast"
}
}
diff --git a/apps/app-playground/src/main.rs b/apps/app-playground/src/main.rs
index f8d943938..a2c2b8922 100644
--- a/apps/app-playground/src/main.rs
+++ b/apps/app-playground/src/main.rs
@@ -3,9 +3,9 @@
windows_subsystem = "windows"
)]
-use std::time::Duration;
+use enumset::EnumSet;
use theseus::prelude::*;
-use tokio::signal::ctrl_c;
+use theseus::worlds::get_recent_worlds;
// A simple Rust implementation of the authentication run
// 1) call the authenticate_begin_flow() function to get the URL to open (like you would in the frontend)
@@ -15,8 +15,7 @@ pub async fn authenticate_run() -> theseus::Result {
println!("A browser window will now open, follow the login flow there.");
let login = minecraft_auth::begin_login().await?;
- println!("URL {}", login.redirect_uri.as_str());
- webbrowser::open(login.redirect_uri.as_str())?;
+ println!("Open URL {} in a browser", login.redirect_uri.as_str());
println!("Please enter URL code: ");
let mut input = String::new();
@@ -28,7 +27,10 @@ pub async fn authenticate_run() -> theseus::Result {
let credentials = minecraft_auth::finish_login(&input, login).await?;
- println!("Logged in user {}.", credentials.username);
+ println!(
+ "Logged in user {}.",
+ credentials.maybe_online_profile().await.name
+ );
Ok(credentials)
}
@@ -41,21 +43,16 @@ async fn main() -> theseus::Result<()> {
// Initialize state
State::init().await?;
- loop {
- if State::get().await?.friends_socket.is_connected().await {
- break;
- }
- tokio::time::sleep(Duration::from_millis(500)).await;
+ let worlds = get_recent_worlds(4, EnumSet::all()).await?;
+ for world in worlds {
+ println!(
+ "World: {:?}/{:?} played at {:?}: {:#?}",
+ world.profile,
+ world.world.name,
+ world.world.last_played,
+ world.world.details
+ );
}
- tracing::info!("Starting host");
-
- let socket = State::get().await?.friends_socket.open_port(25565).await?;
- tracing::info!("Running host on socket {}", socket.socket_id());
-
- ctrl_c().await?;
- tracing::info!("Stopping host");
- socket.shutdown().await?;
-
Ok(())
}
diff --git a/apps/app/.gitignore b/apps/app/.gitignore
index d887d6c0b..f73fca36c 100644
--- a/apps/app/.gitignore
+++ b/apps/app/.gitignore
@@ -1,6 +1,2 @@
-# Generated by Cargo
-# will have compiled files and executables
-/target/
-
# Generated by tauri, metadata generated at compile time
/gen/
diff --git a/apps/app/Cargo.toml b/apps/app/Cargo.toml
index 0e3bc20b3..d1c67affc 100644
--- a/apps/app/Cargo.toml
+++ b/apps/app/Cargo.toml
@@ -1,66 +1,52 @@
[package]
name = "theseus_gui"
-version = "0.9.3"
+version = "1.0.0-local" # The actual version is set by the theseus-build workflow on tagging
description = "The Modrinth App is a desktop application for managing your Minecraft mods"
license = "GPL-3.0-only"
repository = "https://github.com/modrinth/code/apps/app/"
-edition = "2021"
-build = "build.rs"
+edition.workspace = true
[build-dependencies]
-tauri-build = { version = "2.0.3", features = ["codegen"] }
+tauri-build = { workspace = true, features = ["codegen"] }
[dependencies]
-theseus = { path = "../../packages/app-lib", features = ["tauri"] }
+theseus = { workspace = true, features = ["tauri"] }
-serde_json = "1.0"
-serde = { version = "1.0", features = ["derive"] }
-serde_with = "3.0.0"
+serde_json.workspace = true
+serde = { workspace = true, features = ["derive"] }
+serde_with.workspace = true
-tauri = { version = "2.1.1", features = ["devtools", "macos-private-api", "protocol-asset", "unstable"] }
-tauri-plugin-window-state = "2.2.0"
-tauri-plugin-deep-link = "2.2.0"
-tauri-plugin-os = "2.2.0"
-tauri-plugin-opener = "2.2.1"
-tauri-plugin-dialog = "2.2.0"
-tauri-plugin-updater = { version = "2.3.0" }
-tauri-plugin-single-instance = { version = "2.2.0" }
+tauri = { workspace = true, features = ["devtools", "macos-private-api", "protocol-asset"] }
+tauri-plugin-deep-link.workspace = true
+tauri-plugin-dialog.workspace = true
+tauri-plugin-http.workspace = true
+tauri-plugin-opener.workspace = true
+tauri-plugin-os.workspace = true
+tauri-plugin-single-instance.workspace = true
+tauri-plugin-updater.workspace = true
+tauri-plugin-window-state.workspace = true
-tokio = { version = "1", features = ["full"] }
-thiserror = "1.0"
-futures = "0.3"
-daedalus = { path = "../../packages/daedalus" }
-chrono = "0.4.26"
+tokio = { workspace = true, features = ["time"] }
+thiserror.workspace = true
+daedalus.workspace = true
+chrono.workspace = true
+either.workspace = true
-dirs = "5.0.1"
+url.workspace = true
+urlencoding.workspace = true
+uuid = { workspace = true, features = ["serde", "v4"] }
-url = "2.2"
-uuid = { version = "1.1", features = ["serde", "v4"] }
-os_info = "3.7.0"
+tracing.workspace = true
+tracing-error.workspace = true
-tracing = "0.1.37"
-tracing-error = "0.2.0"
+dashmap.workspace = true
+paste.workspace = true
+enumset = { workspace = true, features = ["serde"] }
-lazy_static = "1"
-once_cell = "1"
-
-dashmap = "6.0.1"
-paste = "1.0.15"
-
-opener = { version = "0.7.2", features = ["reveal", "dbus-vendored"] }
-
-native-dialog = "0.7.0"
-
-[target.'cfg(not(target_os = "linux"))'.dependencies]
-window-shadows = "0.2.1"
-
-[target.'cfg(target_os = "macos")'.dependencies]
-cocoa = "0.25.0"
-objc = "0.2.7"
-rand = "0.8.5"
+native-dialog.workspace = true
[target.'cfg(target_os = "linux")'.dependencies]
-tauri-plugin-updater = { version = "2.3.0", optional = true, features = ["native-tls-vendored", "zip"], default-features = false }
+tauri-plugin-updater = { workspace = true, optional = true }
[features]
# by default Tauri runs in production mode
@@ -70,3 +56,6 @@ default = ["custom-protocol"]
# DO NOT remove this
custom-protocol = ["tauri/custom-protocol"]
updater = []
+
+[lints]
+workspace = true
diff --git a/apps/app/Info.plist b/apps/app/Info.plist
index a3069984e..18e7a26a4 100644
--- a/apps/app/Info.plist
+++ b/apps/app/Info.plist
@@ -18,5 +18,25 @@
A Minecraft mod wants to access your camera.
NSMicrophoneUsageDescription
A Minecraft mod wants to access your microphone.
+ NSAppTransportSecurity
+
+ NSExceptionDomains
+
+ asset.localhost
+
+ NSExceptionAllowsInsecureHTTPLoads
+
+ NSIncludesSubdomains
+
+
+ textures.minecraft.net
+
+ NSExceptionAllowsInsecureHTTPLoads
+
+ NSIncludesSubdomains
+
+
+
+
diff --git a/apps/app/build.rs b/apps/app/build.rs
index 3c00b6d8f..59f78131a 100644
--- a/apps/app/build.rs
+++ b/apps/app/build.rs
@@ -100,6 +100,24 @@ fn main() {
DefaultPermissionRule::AllowAllCommands,
),
)
+ .plugin(
+ "minecraft-skins",
+ InlinedPlugin::new()
+ .commands(&[
+ "get_available_capes",
+ "get_available_skins",
+ "add_and_equip_custom_skin",
+ "set_default_cape",
+ "equip_skin",
+ "remove_custom_skin",
+ "unequip_skin",
+ "normalize_skin_texture",
+ "get_dragged_skin_data",
+ ])
+ .default_permission(
+ DefaultPermissionRule::AllowAllCommands,
+ ),
+ )
.plugin(
"mr-auth",
InlinedPlugin::new()
@@ -152,7 +170,6 @@ fn main() {
"profile_update_managed_modrinth_version",
"profile_repair_managed_modrinth",
"profile_run",
- "profile_run_credentials",
"profile_kill",
"profile_edit",
"profile_edit_icon",
@@ -226,6 +243,30 @@ fn main() {
.default_permission(
DefaultPermissionRule::AllowAllCommands,
),
+ )
+ .plugin(
+ "worlds",
+ InlinedPlugin::new()
+ .commands(&[
+ "get_recent_worlds",
+ "get_profile_worlds",
+ "get_singleplayer_world",
+ "set_world_display_status",
+ "rename_world",
+ "reset_world_icon",
+ "backup_world",
+ "delete_world",
+ "add_server_to_profile",
+ "edit_server_in_profile",
+ "remove_server_from_profile",
+ "get_profile_protocol_version",
+ "get_server_status",
+ "start_join_singleplayer_world",
+ "start_join_server",
+ ])
+ .default_permission(
+ DefaultPermissionRule::AllowAllCommands,
+ ),
),
)
.expect("Failed to run tauri-build");
diff --git a/apps/app/capabilities/plugins.json b/apps/app/capabilities/plugins.json
index 6c53b8e3d..c78dd9716 100644
--- a/apps/app/capabilities/plugins.json
+++ b/apps/app/capabilities/plugins.json
@@ -19,12 +19,21 @@
"window-state:default",
"window-state:allow-restore-state",
"window-state:allow-save-window-state",
+
+ {
+ "identifier": "http:default",
+ "allow": [
+ { "url": "https://modrinth.com/*" },
+ { "url": "https://*.modrinth.com/*" }
+ ]
+ },
"auth:default",
"import:default",
"jre:default",
"logs:default",
"metadata:default",
+ "minecraft-skins:default",
"mr-auth:default",
"profile-create:default",
"pack:default",
@@ -34,6 +43,7 @@
"settings:default",
"tags:default",
"utils:default",
- "friends:default"
+ "friends:default",
+ "worlds:default"
]
}
diff --git a/apps/app/package.json b/apps/app/package.json
index 168ac3454..43f017203 100644
--- a/apps/app/package.json
+++ b/apps/app/package.json
@@ -1,15 +1,15 @@
{
"name": "@modrinth/app",
"scripts": {
- "build": "tauri build",
"tauri": "tauri",
+ "build": "tauri build",
"dev": "tauri dev",
- "test": "cargo test",
- "lint": "cargo fmt --check && cargo clippy --all-targets -- -D warnings",
- "fix": "cargo fmt && cargo clippy --fix"
+ "test": "cargo nextest run --all-targets --no-fail-fast",
+ "lint": "cargo fmt --check && cargo clippy --all-targets",
+ "fix": "cargo clippy --all-targets --fix --allow-dirty && cargo fmt"
},
"devDependencies": {
- "@tauri-apps/cli": "2.1.0"
+ "@tauri-apps/cli": "2.5.0"
},
"dependencies": {
"@modrinth/app-frontend": "workspace:*",
diff --git a/apps/app/src/api/jre.rs b/apps/app/src/api/jre.rs
index 036d5889b..71c72257c 100644
--- a/apps/app/src/api/jre.rs
+++ b/apps/app/src/api/jre.rs
@@ -41,8 +41,8 @@ pub async fn jre_find_filtered_jres(
// Validates JRE at a given path
// Returns None if the path is not a valid JRE
#[tauri::command]
-pub async fn jre_get_jre(path: PathBuf) -> Result