-
-
-
-
+
+
-
- Texture
+
-
- Arm style
+
+
+ {{ formatMessage(messages.armStyleSection) }}
+
- {{ item === 'CLASSIC' ? 'Wide' : 'Slim' }}
+ {{
+ formatMessage(item === 'CLASSIC' ? messages.wideArmStyle : messages.slimArmStyle)
+ }}
- Cape
-
-
{{ formatMessage(messages.capeSection) }}
+
+
- Use default cape
-
-
- Use default cape
-
+
+
-
-
-
-
- More
-
+
+
+ {{ formatMessage(messages.noneCapeOption) }}
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/skin/SelectCapeModal.vue b/apps/app-frontend/src/components/ui/skin/SelectCapeModal.vue
deleted file mode 100644
index 573582294..000000000
--- a/apps/app-frontend/src/components/ui/skin/SelectCapeModal.vue
+++ /dev/null
@@ -1,141 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- None
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/apps/app-frontend/src/components/ui/skin/UploadSkinModal.vue b/apps/app-frontend/src/components/ui/skin/UploadSkinModal.vue
deleted file mode 100644
index 7a35e63e7..000000000
--- a/apps/app-frontend/src/components/ui/skin/UploadSkinModal.vue
+++ /dev/null
@@ -1,141 +0,0 @@
-
-
-
-
-
-
-
-
-
-
diff --git a/apps/app-frontend/src/components/ui/skin/VirtualSkinSectionList.vue b/apps/app-frontend/src/components/ui/skin/VirtualSkinSectionList.vue
new file mode 100644
index 000000000..65b7f1e2e
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/skin/VirtualSkinSectionList.vue
@@ -0,0 +1,410 @@
+
+
+
+
+
+
+
+ {{ section.title }}
+
+
+
+
+ {{ section.title }}
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.addSkinButton) }}
+ {{ formatMessage(messages.dragAndDropSubtitle) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/helpers/rendering/batch-skin-renderer.ts b/apps/app-frontend/src/helpers/rendering/batch-skin-renderer.ts
index bd7d43fc0..77467cd5a 100644
--- a/apps/app-frontend/src/helpers/rendering/batch-skin-renderer.ts
+++ b/apps/app-frontend/src/helpers/rendering/batch-skin-renderer.ts
@@ -3,9 +3,8 @@ import {
applyCapeTexture,
createTransparentTexture,
disposeCaches,
- loadTexture,
setupSkinModel,
-} from '@modrinth/utils'
+} from '@modrinth/ui'
import * as THREE from 'three'
import { reactive } from 'vue'
@@ -29,6 +28,7 @@ class BatchSkinRenderer {
private scene: THREE.Scene | null = null
private camera: THREE.PerspectiveCamera | null = null
private currentModel: THREE.Group | null = null
+ private transparentTexture: THREE.Texture | null = null
private readonly width: number
private readonly height: number
@@ -52,6 +52,7 @@ class BatchSkinRenderer {
})
this.renderer.outputColorSpace = THREE.SRGBColorSpace
+ this.renderer.shadowMap.enabled = false
this.renderer.toneMapping = THREE.NoToneMapping
this.renderer.toneMappingExposure = 10.0
this.renderer.setClearColor(0x000000, 0)
@@ -62,7 +63,7 @@ class BatchSkinRenderer {
const ambientLight = new THREE.AmbientLight(0xffffff, 2)
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2)
- directionalLight.castShadow = true
+ directionalLight.castShadow = false
directionalLight.position.set(2, 4, 3)
this.scene.add(ambientLight)
this.scene.add(directionalLight)
@@ -112,9 +113,19 @@ class BatchSkinRenderer {
this.renderer.render(this.scene, this.camera)
- const dataUrl = this.renderer.domElement.toDataURL('image/webp', 0.9)
- const response = await fetch(dataUrl)
- return await response.blob()
+ return await new Promise
((resolve, reject) => {
+ this.renderer!.domElement.toBlob(
+ (blob) => {
+ if (blob) {
+ resolve(blob)
+ } else {
+ reject(new Error('Failed to create blob from rendered canvas'))
+ }
+ },
+ 'image/webp',
+ 0.9,
+ )
+ })
}
private async setupModel(modelUrl: string, textureUrl: string, capeUrl?: string): Promise {
@@ -122,14 +133,10 @@ class BatchSkinRenderer {
throw new Error('Renderer not initialized')
}
- const { model } = await setupSkinModel(modelUrl, textureUrl)
+ const { model } = await setupSkinModel(modelUrl, textureUrl, capeUrl)
- if (capeUrl) {
- const capeTexture = await loadTexture(capeUrl)
- applyCapeTexture(model, capeTexture)
- } else {
- const transparentTexture = createTransparentTexture()
- applyCapeTexture(model, null, transparentTexture)
+ if (!capeUrl) {
+ applyCapeTexture(model, null, this.getTransparentTexture())
}
const group = new THREE.Group()
@@ -141,39 +148,38 @@ class BatchSkinRenderer {
this.currentModel = group
}
- private clearScene(): void {
- if (!this.scene) return
-
- while (this.scene.children.length > 0) {
- const child = this.scene.children[0]
- this.scene.remove(child)
-
- if (child instanceof THREE.Mesh) {
- if (child.geometry) child.geometry.dispose()
- if (child.material) {
- if (Array.isArray(child.material)) {
- child.material.forEach((material) => material.dispose())
- } else {
- child.material.dispose()
- }
- }
- }
+ private getTransparentTexture(): THREE.Texture {
+ if (!this.transparentTexture) {
+ this.transparentTexture = createTransparentTexture()
}
- 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)
+ return this.transparentTexture
+ }
+ private clearScene(): void {
+ if (!this.scene || !this.currentModel) return
+
+ this.scene.remove(this.currentModel)
+ this.currentModel.clear()
this.currentModel = null
}
public dispose(): void {
+ this.clearScene()
+
+ if (this.transparentTexture) {
+ this.transparentTexture.dispose()
+ this.transparentTexture = null
+ }
+
if (this.renderer) {
this.renderer.dispose()
}
+
+ this.renderer = null
+ this.scene = null
+ this.camera = null
+
disposeCaches()
}
}
@@ -194,6 +200,9 @@ export const headBlobUrlMap = reactive(new Map())
const DEBUG_MODE = false
let sharedRenderer: BatchSkinRenderer | null = null
+let latestPreviewGeneration = 0
+let previewGenerationQueue: Promise = Promise.resolve()
+
function getSharedRenderer(): BatchSkinRenderer {
if (!sharedRenderer) {
sharedRenderer = new BatchSkinRenderer()
@@ -356,7 +365,27 @@ export async function getPlayerHeadUrl(skin: Skin): Promise {
return await generateHeadRender(skin)
}
-export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promise {
+export function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promise {
+ const generation = ++latestPreviewGeneration
+ const skinsSnapshot = [...skins]
+ const capesSnapshot = [...capes]
+
+ const generationPromise = previewGenerationQueue.then(() =>
+ generateSkinPreviewsForGeneration(skinsSnapshot, capesSnapshot, generation),
+ )
+
+ previewGenerationQueue = generationPromise.catch(() => {})
+
+ return generationPromise
+}
+
+async function generateSkinPreviewsForGeneration(
+ skins: Skin[],
+ capes: Cape[],
+ generation: number,
+): Promise {
+ const isCurrentGeneration = () => generation === latestPreviewGeneration
+
try {
const skinKeys = skins.map(
(skin) => `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`,
@@ -368,6 +397,8 @@ export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promis
headStorage.batchRetrieve(headKeys),
])
+ if (!isCurrentGeneration()) return
+
for (let i = 0; i < skins.length; i++) {
const skinKey = skinKeys[i]
const headKey = headKeys[i]
@@ -388,6 +419,8 @@ export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promis
}
for (const skin of skins) {
+ if (!isCurrentGeneration()) return
+
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
if (skinBlobUrlMap.has(key)) {
@@ -419,6 +452,8 @@ export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promis
cape?.texture,
)
+ if (!isCurrentGeneration()) return
+
const renderResult: RenderResult = {
forwards: URL.createObjectURL(rawRenderResult.forwards),
backwards: URL.createObjectURL(rawRenderResult.backwards),
@@ -439,9 +474,12 @@ export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promis
}
} finally {
disposeSharedRenderer()
- await cleanupUnusedPreviews(skins)
- await skinPreviewStorage.debugCalculateStorage()
- await headStorage.debugCalculateStorage()
+ if (isCurrentGeneration()) {
+ await cleanupUnusedPreviews(skins)
+
+ await skinPreviewStorage.debugCalculateStorage()
+ await headStorage.debugCalculateStorage()
+ }
}
}
diff --git a/apps/app-frontend/src/helpers/skins.ts b/apps/app-frontend/src/helpers/skins.ts
index 87f7286cc..9a05df55d 100644
--- a/apps/app-frontend/src/helpers/skins.ts
+++ b/apps/app-frontend/src/helpers/skins.ts
@@ -5,7 +5,6 @@ export interface Cape {
id: string
name: string
texture: string
- is_default: boolean
is_equipped: boolean
}
@@ -15,6 +14,7 @@ export type SkinSource = 'default' | 'custom_external' | 'custom'
export interface Skin {
texture_key: string
name?: string
+ section?: string
variant: SkinModel
cape_id?: string
texture: string
@@ -121,17 +121,11 @@ export async function get_available_skins(): Promise {
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', {
+ cape?: Cape,
+): Promise {
+ return 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,
})
}
@@ -148,6 +142,22 @@ export async function remove_custom_skin(skin: Skin): Promise {
})
}
+export async function save_custom_skin(
+ skin: Skin,
+ textureBlob: Uint8Array,
+ variant: SkinModel,
+ cape: Cape | undefined,
+ replaceTexture: boolean,
+): Promise {
+ return await invoke('plugin:minecraft-skins|save_custom_skin', {
+ skin,
+ textureBlob,
+ variant,
+ cape,
+ replaceTexture,
+ })
+}
+
export async function get_normalized_skin_texture(skin: Skin): Promise {
const data = await normalize_skin_texture(skin.texture)
const base64 = arrayBufferToBase64(data)
@@ -162,6 +172,16 @@ export async function unequip_skin(): Promise {
await invoke('plugin:minecraft-skins|unequip_skin')
}
+export async function flush_pending_skin_change(): Promise {
+ await invoke('plugin:minecraft-skins|flush_pending_skin_change')
+}
+
+export async function flush_pending_skin_change_for_profile(profileId: string): Promise {
+ await invoke('plugin:minecraft-skins|flush_pending_skin_change_for_profile', {
+ profileId,
+ })
+}
+
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/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json
index e6362c6de..8b04360fe 100644
--- a/apps/app-frontend/src/locales/en-US/index.json
+++ b/apps/app-frontend/src/locales/en-US/index.json
@@ -332,6 +332,138 @@
"app.settings.tabs.resource-management": {
"message": "Resource management"
},
+ "app.skins.add-button": {
+ "message": "Add skin"
+ },
+ "app.skins.add-button.drag-and-drop": {
+ "message": "Drag and drop"
+ },
+ "app.skins.apply-button": {
+ "message": "Apply"
+ },
+ "app.skins.delete-button": {
+ "message": "Delete skin"
+ },
+ "app.skins.delete-modal.description": {
+ "message": "This will permanently delete the selected skin. This action cannot be undone."
+ },
+ "app.skins.delete-modal.title": {
+ "message": "Are you sure you want to delete this skin?"
+ },
+ "app.skins.dropped-file-error.text": {
+ "message": "Failed to read the dropped file."
+ },
+ "app.skins.dropped-file-error.title": {
+ "message": "Error processing file"
+ },
+ "app.skins.edit-button": {
+ "message": "Edit skin"
+ },
+ "app.skins.modal.add-skin-button": {
+ "message": "Add skin"
+ },
+ "app.skins.modal.add-title": {
+ "message": "Adding a skin"
+ },
+ "app.skins.modal.arm-style-section": {
+ "message": "Arm style"
+ },
+ "app.skins.modal.arm-style-slim": {
+ "message": "Slim"
+ },
+ "app.skins.modal.arm-style-wide": {
+ "message": "Wide"
+ },
+ "app.skins.modal.cape-fallback-name": {
+ "message": "Cape"
+ },
+ "app.skins.modal.cape-section": {
+ "message": "Cape"
+ },
+ "app.skins.modal.edit-title": {
+ "message": "Editing skin"
+ },
+ "app.skins.modal.make-edit-first-tooltip": {
+ "message": "Make an edit to the skin first!"
+ },
+ "app.skins.modal.no-cape-tooltip": {
+ "message": "No cape"
+ },
+ "app.skins.modal.none-cape-option": {
+ "message": "None"
+ },
+ "app.skins.modal.replace-texture-button": {
+ "message": "Replace texture"
+ },
+ "app.skins.modal.save-skin-button": {
+ "message": "Save skin"
+ },
+ "app.skins.modal.saving-tooltip": {
+ "message": "Saving..."
+ },
+ "app.skins.modal.texture-section": {
+ "message": "Texture"
+ },
+ "app.skins.modal.upload-skin-first-tooltip": {
+ "message": "Upload a skin first!"
+ },
+ "app.skins.preview.edit-button": {
+ "message": "Edit skin"
+ },
+ "app.skins.previewing-badge": {
+ "message": "Previewing"
+ },
+ "app.skins.rate-limit.text": {
+ "message": "You're changing your skin too frequently. Mojang's servers have temporarily blocked further requests. Please wait a moment before trying again."
+ },
+ "app.skins.rate-limit.title": {
+ "message": "Slow down!"
+ },
+ "app.skins.section.builders-and-biomes": {
+ "message": "Builders & Biomes"
+ },
+ "app.skins.section.chase-the-skies": {
+ "message": "Chase the Skies"
+ },
+ "app.skins.section.default-skins": {
+ "message": "Default skins"
+ },
+ "app.skins.section.minecon-earth-2017": {
+ "message": "MINECON Earth 2017"
+ },
+ "app.skins.section.mounts-of-mayhem": {
+ "message": "Mounts of Mayhem"
+ },
+ "app.skins.section.saved-skins": {
+ "message": "Saved skins"
+ },
+ "app.skins.section.striding-hero": {
+ "message": "Striding Hero"
+ },
+ "app.skins.section.the-copper-age": {
+ "message": "The Copper Age"
+ },
+ "app.skins.section.the-garden-awakens": {
+ "message": "The Garden Awakens"
+ },
+ "app.skins.section.tiny-takeover": {
+ "message": "Tiny Takeover"
+ },
+ "app.skins.sign-in.button": {
+ "message": "Sign In"
+ },
+ "app.skins.sign-in.description": {
+ "message": "Please sign into your Minecraft account to use the skin management features of the Modrinth app."
+ },
+ "app.skins.sign-in.rinthbot-alt": {
+ "message": "Excited Modrinth Bot"
+ },
+ "app.skins.sign-in.title": {
+ "message": "Please sign in"
+ },
+ "app.skins.title": {
+ "message": "Skin selector"
+ },
"app.update-popup.body": {
"message": "Modrinth App v{version} is ready to install! Reload to update now, or automatically when you close Modrinth App."
},
diff --git a/apps/app-frontend/src/pages/Skins.vue b/apps/app-frontend/src/pages/Skins.vue
index 6c03b1773..9b4490a72 100644
--- a/apps/app-frontend/src/pages/Skins.vue
+++ b/apps/app-frontend/src/pages/Skins.vue
@@ -1,56 +1,164 @@
loadSkins()"
- @open-upload-modal="openUploadSkinModal"
/>
-
-
-
-
+
+
- Skins
- Beta
+ {{ formatMessage(messages.skinSelectorTitle) }}
-
+
+
+
+
+ {{ formatMessage(messages.previewingBadge) }}
+
+
-
+
-
+
+
+
-
-
- Saved skins
-
-
-
-
-
- Add a skin
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+ editSkinModal?.show(event, skin)"
+ @delete="confirmDeleteSkin"
+ @add-skin="openAddSkinFileBrowser"
+ @add-skin-dragenter="onAddSkinDragOver"
+ @add-skin-dragover="onAddSkinDragOver"
+ @add-skin-dragleave="onAddSkinDragLeave"
+ @add-skin-drop="onAddSkinDrop"
+ />
-
+
-
+
- Please sign into your Minecraft account to use the skin management features of the
- Modrinth app.
+ {{ formatMessage(messages.signInDescription) }}
@@ -465,9 +867,6 @@ await Promise.all([loadCapes(), loadSkins(), loadCurrentUser()])
diff --git a/apps/app-frontend/src/routes.js b/apps/app-frontend/src/routes.js
index c12306b5a..f01df0670 100644
--- a/apps/app-frontend/src/routes.js
+++ b/apps/app-frontend/src/routes.js
@@ -86,10 +86,10 @@ export default new createRouter({
},
{
path: '/skins',
- name: 'Skins',
+ name: 'Skin selector',
component: Pages.Skins,
meta: {
- breadcrumb: [{ name: 'Skins' }],
+ breadcrumb: [{ name: 'Skin selector' }],
},
},
{
diff --git a/apps/app-frontend/src/store/theme.ts b/apps/app-frontend/src/store/theme.ts
index 0d5d0c9b3..3eaa07337 100644
--- a/apps/app-frontend/src/store/theme.ts
+++ b/apps/app-frontend/src/store/theme.ts
@@ -21,6 +21,7 @@ export type ColorTheme = (typeof THEME_OPTIONS)[number]
export type ThemeStore = {
selectedTheme: ColorTheme
advancedRendering: boolean
+ hideNametagSkinsPage: boolean
toggleSidebar: boolean
devMode: boolean
@@ -30,6 +31,7 @@ export type ThemeStore = {
export const DEFAULT_THEME_STORE: ThemeStore = {
selectedTheme: 'dark',
advancedRendering: true,
+ hideNametagSkinsPage: false,
toggleSidebar: false,
devMode: false,
diff --git a/apps/app/build.rs b/apps/app/build.rs
index 769d7db70..f4d8c14a7 100644
--- a/apps/app/build.rs
+++ b/apps/app/build.rs
@@ -114,10 +114,12 @@ fn main() {
"get_available_capes",
"get_available_skins",
"add_and_equip_custom_skin",
- "set_default_cape",
"equip_skin",
"remove_custom_skin",
+ "save_custom_skin",
"unequip_skin",
+ "flush_pending_skin_change",
+ "flush_pending_skin_change_for_profile",
"normalize_skin_texture",
"get_dragged_skin_data",
])
diff --git a/apps/app/src/api/minecraft_skins.rs b/apps/app/src/api/minecraft_skins.rs
index a6d138fbd..55a67e12e 100644
--- a/apps/app/src/api/minecraft_skins.rs
+++ b/apps/app/src/api/minecraft_skins.rs
@@ -11,10 +11,12 @@ pub fn init
() -> tauri::plugin::TauriPlugin {
get_available_capes,
get_available_skins,
add_and_equip_custom_skin,
- set_default_cape,
equip_skin,
remove_custom_skin,
+ save_custom_skin,
unequip_skin,
+ flush_pending_skin_change,
+ flush_pending_skin_change_for_profile,
normalize_skin_texture,
get_dragged_skin_data,
])
@@ -37,29 +39,19 @@ pub async fn get_available_skins() -> Result> {
Ok(minecraft_skins::get_available_skins().await?)
}
-/// `invoke('plugin:minecraft-skins|add_and_equip_custom_skin', texture_blob, variant, cape_override)`
+/// `invoke('plugin:minecraft-skins|add_and_equip_custom_skin', texture_blob, variant, cape)`
///
/// See also: [minecraft_skins::add_and_equip_custom_skin]
#[tauri::command]
pub async fn add_and_equip_custom_skin(
texture_blob: Bytes,
variant: MinecraftSkinVariant,
- cape_override: Option,
-) -> Result<()> {
- Ok(minecraft_skins::add_and_equip_custom_skin(
- texture_blob,
- variant,
- cape_override,
+ cape: Option,
+) -> Result {
+ Ok(
+ minecraft_skins::add_and_equip_custom_skin(texture_blob, variant, cape)
+ .await?,
)
- .await?)
-}
-
-/// `invoke('plugin:minecraft-skins|set_default_cape', cape)`
-///
-/// See also: [minecraft_skins::set_default_cape]
-#[tauri::command]
-pub async fn set_default_cape(cape: Option) -> Result<()> {
- Ok(minecraft_skins::set_default_cape(cape).await?)
}
/// `invoke('plugin:minecraft-skins|equip_skin', skin)`
@@ -78,6 +70,27 @@ pub async fn remove_custom_skin(skin: Skin) -> Result<()> {
Ok(minecraft_skins::remove_custom_skin(skin).await?)
}
+/// `invoke('plugin:minecraft-skins|save_custom_skin', skin, texture_blob, variant, cape, replace_texture)`
+///
+/// See also: [minecraft_skins::save_custom_skin]
+#[tauri::command]
+pub async fn save_custom_skin(
+ skin: Skin,
+ texture_blob: Bytes,
+ variant: MinecraftSkinVariant,
+ cape: Option,
+ replace_texture: bool,
+) -> Result {
+ Ok(minecraft_skins::save_custom_skin(
+ skin,
+ texture_blob,
+ variant,
+ cape,
+ replace_texture,
+ )
+ .await?)
+}
+
/// `invoke('plugin:minecraft-skins|unequip_skin')`
///
/// See also: [minecraft_skins::unequip_skin]
@@ -86,6 +99,27 @@ pub async fn unequip_skin() -> Result<()> {
Ok(minecraft_skins::unequip_skin().await?)
}
+/// `invoke('plugin:minecraft-skins|flush_pending_skin_change')`
+///
+/// See also: [minecraft_skins::flush_pending_skin_change]
+#[tauri::command]
+pub async fn flush_pending_skin_change() -> Result<()> {
+ Ok(minecraft_skins::flush_pending_skin_change().await?)
+}
+
+/// `invoke('plugin:minecraft-skins|flush_pending_skin_change_for_profile', profile_id)`
+///
+/// See also: [minecraft_skins::flush_pending_skin_change_for_profile]
+#[tauri::command]
+pub async fn flush_pending_skin_change_for_profile(
+ profile_id: uuid::Uuid,
+) -> Result<()> {
+ Ok(
+ minecraft_skins::flush_pending_skin_change_for_profile(profile_id)
+ .await?,
+ )
+}
+
/// `invoke('plugin:minecraft-skins|normalize_skin_texture')`
///
/// See also: [minecraft_skins::normalize_skin_texture]
diff --git a/apps/app/src/main.rs b/apps/app/src/main.rs
index c77a1ac73..4df5dcd8e 100644
--- a/apps/app/src/main.rs
+++ b/apps/app/src/main.rs
@@ -270,10 +270,20 @@ fn main() {
Ok(app) => {
app.run(|app, event| {
#[cfg(not(any(feature = "updater", target_os = "macos")))]
- drop((app, event));
+ let _ = app;
+
+ if matches!(&event, tauri::RunEvent::ExitRequested { .. })
+ && let Err(error) = tauri::async_runtime::block_on(
+ theseus::minecraft_skins::flush_pending_skin_change(),
+ )
+ {
+ tracing::warn!(
+ "Failed to flush pending Minecraft skin change before exit: {error}"
+ );
+ }
#[cfg(feature = "updater")]
- if matches!(event, tauri::RunEvent::Exit) {
+ if matches!(&event, tauri::RunEvent::Exit) {
let update_data = app.state::().inner();
let should_restart = State::get_if_initialized()
.map(|s| {
diff --git a/packages/app-lib/.sqlx/query-faa8437519571b147b0135054847674be5035f385e0d85e759d4bbf9bca54f20.json b/packages/app-lib/.sqlx/query-08908e54884b79705500501389344f3dc52fc81d34b0e9a44f5b9bede487cfa6.json
similarity index 52%
rename from packages/app-lib/.sqlx/query-faa8437519571b147b0135054847674be5035f385e0d85e759d4bbf9bca54f20.json
rename to packages/app-lib/.sqlx/query-08908e54884b79705500501389344f3dc52fc81d34b0e9a44f5b9bede487cfa6.json
index ad8564624..d8ecc032c 100644
--- a/packages/app-lib/.sqlx/query-faa8437519571b147b0135054847674be5035f385e0d85e759d4bbf9bca54f20.json
+++ b/packages/app-lib/.sqlx/query-08908e54884b79705500501389344f3dc52fc81d34b0e9a44f5b9bede487cfa6.json
@@ -1,12 +1,12 @@
{
"db_name": "SQLite",
- "query": "DELETE FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ? AND variant = ? AND cape_id IS ?",
+ "query": "DELETE FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ?",
"describe": {
"columns": [],
"parameters": {
- "Right": 4
+ "Right": 2
},
"nullable": []
},
- "hash": "faa8437519571b147b0135054847674be5035f385e0d85e759d4bbf9bca54f20"
+ "hash": "08908e54884b79705500501389344f3dc52fc81d34b0e9a44f5b9bede487cfa6"
}
diff --git a/packages/app-lib/.sqlx/query-27a4ca00ab9d1647bf63287169f6dd3eed86ba421c83e74fe284609a8020bd22.json b/packages/app-lib/.sqlx/query-27a4ca00ab9d1647bf63287169f6dd3eed86ba421c83e74fe284609a8020bd22.json
deleted file mode 100644
index 26c250c78..000000000
--- a/packages/app-lib/.sqlx/query-27a4ca00ab9d1647bf63287169f6dd3eed86ba421c83e74fe284609a8020bd22.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "db_name": "SQLite",
- "query": "DELETE FROM default_minecraft_capes WHERE minecraft_user_uuid = ?",
- "describe": {
- "columns": [],
- "parameters": {
- "Right": 1
- },
- "nullable": []
- },
- "hash": "27a4ca00ab9d1647bf63287169f6dd3eed86ba421c83e74fe284609a8020bd22"
-}
diff --git a/packages/app-lib/.sqlx/query-3d15e7eb66971e70500e8718236fbdbd066d51f88cd2bcfed613f756edbd2944.json b/packages/app-lib/.sqlx/query-3d15e7eb66971e70500e8718236fbdbd066d51f88cd2bcfed613f756edbd2944.json
deleted file mode 100644
index cf3645df1..000000000
--- a/packages/app-lib/.sqlx/query-3d15e7eb66971e70500e8718236fbdbd066d51f88cd2bcfed613f756edbd2944.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "db_name": "SQLite",
- "query": "INSERT OR REPLACE INTO default_minecraft_capes (minecraft_user_uuid, id) VALUES (?, ?)",
- "describe": {
- "columns": [],
- "parameters": {
- "Right": 2
- },
- "nullable": []
- },
- "hash": "3d15e7eb66971e70500e8718236fbdbd066d51f88cd2bcfed613f756edbd2944"
-}
diff --git a/packages/app-lib/.sqlx/query-957f184e28e4921ff3922f3e74aae58e2d7a414e76906700518806e494cd0246.json b/packages/app-lib/.sqlx/query-957f184e28e4921ff3922f3e74aae58e2d7a414e76906700518806e494cd0246.json
deleted file mode 100644
index 2c946cb4e..000000000
--- a/packages/app-lib/.sqlx/query-957f184e28e4921ff3922f3e74aae58e2d7a414e76906700518806e494cd0246.json
+++ /dev/null
@@ -1,20 +0,0 @@
-{
- "db_name": "SQLite",
- "query": "SELECT id AS 'id: Hyphenated' FROM default_minecraft_capes WHERE minecraft_user_uuid = ?",
- "describe": {
- "columns": [
- {
- "name": "id: Hyphenated",
- "ordinal": 0,
- "type_info": "Text"
- }
- ],
- "parameters": {
- "Right": 1
- },
- "nullable": [
- false
- ]
- },
- "hash": "957f184e28e4921ff3922f3e74aae58e2d7a414e76906700518806e494cd0246"
-}
diff --git a/packages/app-lib/.sqlx/query-a0b0ff0ae4b88d5df9d15d3427ab4e9a6ff21cffdc9c2f3d6860e245949d313d.json b/packages/app-lib/.sqlx/query-a0b0ff0ae4b88d5df9d15d3427ab4e9a6ff21cffdc9c2f3d6860e245949d313d.json
new file mode 100644
index 000000000..53263ede9
--- /dev/null
+++ b/packages/app-lib/.sqlx/query-a0b0ff0ae4b88d5df9d15d3427ab4e9a6ff21cffdc9c2f3d6860e245949d313d.json
@@ -0,0 +1,32 @@
+{
+ "db_name": "SQLite",
+ "query": "SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated' FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ?",
+ "describe": {
+ "columns": [
+ {
+ "name": "texture_key",
+ "ordinal": 0,
+ "type_info": "Text"
+ },
+ {
+ "name": "variant: MinecraftSkinVariant",
+ "ordinal": 1,
+ "type_info": "Text"
+ },
+ {
+ "name": "cape_id: Hyphenated",
+ "ordinal": 2,
+ "type_info": "Text"
+ }
+ ],
+ "parameters": {
+ "Right": 2
+ },
+ "nullable": [
+ false,
+ false,
+ true
+ ]
+ },
+ "hash": "a0b0ff0ae4b88d5df9d15d3427ab4e9a6ff21cffdc9c2f3d6860e245949d313d"
+}
diff --git a/packages/app-lib/.sqlx/query-e9449930a74c6a6151c3d868042b878b6789927df5adf50986fe642c8afcb681.json b/packages/app-lib/.sqlx/query-e9449930a74c6a6151c3d868042b878b6789927df5adf50986fe642c8afcb681.json
deleted file mode 100644
index a09ac2ff7..000000000
--- a/packages/app-lib/.sqlx/query-e9449930a74c6a6151c3d868042b878b6789927df5adf50986fe642c8afcb681.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "db_name": "SQLite",
- "query": "DELETE FROM default_minecraft_capes WHERE minecraft_user_uuid NOT IN (SELECT uuid FROM minecraft_users)",
- "describe": {
- "columns": [],
- "parameters": {
- "Right": 0
- },
- "nullable": []
- },
- "hash": "e9449930a74c6a6151c3d868042b878b6789927df5adf50986fe642c8afcb681"
-}
diff --git a/packages/app-lib/migrations/20260526120000_fix-skin-selector-identity.sql b/packages/app-lib/migrations/20260526120000_fix-skin-selector-identity.sql
new file mode 100644
index 000000000..8875e1972
--- /dev/null
+++ b/packages/app-lib/migrations/20260526120000_fix-skin-selector-identity.sql
@@ -0,0 +1,13 @@
+DROP TABLE IF EXISTS default_minecraft_capes;
+
+-- Keep only one saved skin per Minecraft account and texture.
+-- variant and cape_id are settings on that saved skin, not part of the skin identity.
+DELETE FROM custom_minecraft_skins
+WHERE rowid NOT IN (
+ SELECT MAX(rowid)
+ FROM custom_minecraft_skins
+ GROUP BY minecraft_user_uuid, texture_key
+);
+
+CREATE UNIQUE INDEX custom_minecraft_skins_one_per_texture
+ ON custom_minecraft_skins (minecraft_user_uuid, texture_key);
diff --git a/packages/app-lib/src/api/minecraft_skins.rs b/packages/app-lib/src/api/minecraft_skins.rs
index 2a869a22d..58c71aa93 100644
--- a/packages/app-lib/src/api/minecraft_skins.rs
+++ b/packages/app-lib/src/api/minecraft_skins.rs
@@ -1,13 +1,62 @@
-//! Theseus skin management interface
+//! # Minecraft Skins API
+//!
+//! ## Data Flow
+//!
+//! 1. Frontend calls `get_available_skins()` and `get_available_capes()`
+//! 2. Backend gets the selected Minecraft account and a recent Mojang profile.
+//! If skins and capes load at the same time, they share the recent profile
+//! instead of sending the same request twice.
+//! 3. The skin list is built from three places:
+//! - saved skin rows in the local app database
+//! - bundled Minecraft default skins
+//! - the active Mojang skin, if its texture is not represented by a saved
+//! skin or matching bundled default
+//! 4. While building the list, any saved skin with Mojang's active texture is
+//! updated to Mojang's current model variant and cape, then returned as the
+//! equipped skin.
+//! 5. Before changing a skin, the current non-default Mojang skin is preserved
+//! locally so switching away from an external skin does not lose it.
+//! 6. After a Mojang change, the returned profile is saved in memory when
+//! possible. If that response cannot be read, or a later step fails, the
+//! backend asks Mojang for the profile again.
+//!
+//! ## Ownership
+//!
+//! Mojang decides which skin and cape are currently equipped. The local database
+//! stores saved skin rows. A saved skin is the same saved skin when its
+//! `texture_key` matches; changing its model variant or cape updates that saved
+//! skin instead of creating another row.
+//! When a refreshed Mojang profile reports the same texture as a saved skin but
+//! a different cape or model variant, the saved skin is updated to match Mojang
+//! and returned as the equipped skin.
+//! A bundled default skin with no cape is redundant, so it is removed from the
+//! saved-skin database and represented by the default skin list instead. A
+//! bundled default skin with a cape is stored so the cape stays associated with
+//! that default card, but it is still returned as a default skin rather than a
+//! saved custom skin.
+//!
+//! `cape_id = Some(_)` means a skin should apply that specific cape.
+//! `cape_id = None` means the skin should have no cape.
+//!
+//! ## Consistency
+//!
+//! A Mojang request and a SQLite write cannot be one all-or-nothing operation.
+//! The backend handles this by reconciling refreshed Mojang profile data with
+//! saved rows, saving skins that might be lost before changing Mojang, saving
+//! uploaded skins with the texture key Mojang returns, and asking Mojang for the
+//! latest profile again whenever the result is unclear.
-use std::sync::{
- Arc,
- atomic::{AtomicBool, Ordering},
+use std::{
+ collections::HashMap,
+ sync::{Arc, LazyLock},
+ time::Duration,
};
pub use bytes::Bytes;
use futures::{StreamExt, TryStreamExt, stream};
use serde::{Deserialize, Serialize};
+use sha2::Digest;
+use tokio::sync::Mutex;
use url::Url;
use uuid::Uuid;
@@ -16,9 +65,7 @@ use crate::{
ErrorKind, State,
state::{
MinecraftCharacterExpressionState, MinecraftProfile,
- minecraft_skins::{
- CustomMinecraftSkin, DefaultMinecraftCape, mojang_api,
- },
+ minecraft_skins::{CustomMinecraftSkin, mojang_api},
},
};
@@ -34,7 +81,128 @@ mod assets {
mod png_util;
-#[derive(Deserialize, Serialize, Debug)]
+const SKIN_CHANGE_DEBOUNCE: Duration = Duration::from_secs(10);
+
+static PENDING_SKIN_CHANGE: LazyLock> =
+ LazyLock::new(|| Mutex::new(PendingSkinChangeState::default()));
+static SKIN_CHANGE_FLUSH_LOCK: LazyLock> =
+ LazyLock::new(|| Mutex::new(()));
+
+#[derive(Debug, Default)]
+struct PendingSkinChangeState {
+ pending: HashMap,
+}
+
+#[derive(Debug)]
+struct PendingSkinChangeEntry {
+ change: PendingSkinChange,
+ generation: u64,
+}
+
+enum PendingEffectiveSkinChange {
+ AddAndEquipCustom {
+ texture_key: Arc,
+ texture_blob: Bytes,
+ variant: MinecraftSkinVariant,
+ cape_id: Option,
+ },
+ Equip {
+ skin: Skin,
+ },
+ Unequip,
+}
+
+impl PendingEffectiveSkinChange {
+ fn is_unequip(&self) -> bool {
+ matches!(self, Self::Unequip)
+ }
+
+ fn cape_id(&self) -> Option {
+ match self {
+ Self::AddAndEquipCustom { cape_id, .. } => *cape_id,
+ Self::Equip { skin } => skin.cape_id,
+ Self::Unequip => None,
+ }
+ }
+
+ fn skin(&self) -> Option {
+ match self {
+ Self::AddAndEquipCustom {
+ texture_key,
+ texture_blob,
+ variant,
+ cape_id,
+ } => Some(Skin {
+ texture_key: Arc::clone(texture_key),
+ name: None,
+ section: None,
+ variant: *variant,
+ cape_id: *cape_id,
+ texture: png_util::blob_to_data_url(texture_blob)
+ .or_else(|| {
+ png_util::blob_to_data_url(include_bytes!(
+ "minecraft_skins/assets/default/MissingNo.png"
+ ))
+ })
+ .unwrap(),
+ source: SkinSource::Custom,
+ is_equipped: true,
+ }),
+ Self::Equip { skin } => Some(skin.clone()),
+ Self::Unequip => None,
+ }
+ }
+}
+
+#[derive(Debug)]
+enum PendingSkinChange {
+ AddAndEquipCustom {
+ selected_credentials: Credentials,
+ texture_blob: Bytes,
+ variant: MinecraftSkinVariant,
+ cape_id: Option,
+ local_texture_key: Arc,
+ },
+ Equip {
+ selected_credentials: Credentials,
+ skin: Skin,
+ },
+ Unequip {
+ selected_credentials: Credentials,
+ },
+}
+
+impl PendingSkinChange {
+ fn profile_id(&self) -> Uuid {
+ match self {
+ Self::AddAndEquipCustom {
+ selected_credentials,
+ ..
+ }
+ | Self::Equip {
+ selected_credentials,
+ ..
+ }
+ | Self::Unequip {
+ selected_credentials,
+ } => selected_credentials.offline_profile.id,
+ }
+ }
+
+ fn matches_skin(&self, skin: &Skin) -> bool {
+ match self {
+ Self::AddAndEquipCustom {
+ local_texture_key, ..
+ } => local_texture_key.as_ref() == skin.texture_key.as_ref(),
+ Self::Equip {
+ skin: pending_skin, ..
+ } => pending_skin.texture_key == skin.texture_key,
+ Self::Unequip { .. } => false,
+ }
+ }
+}
+
+#[derive(Clone, Deserialize, Serialize, Debug)]
pub struct Cape {
/// An identifier for this cape, potentially unique to the owning player.
pub id: Uuid,
@@ -42,26 +210,25 @@ pub struct Cape {
pub name: Arc,
/// The URL of the cape PNG texture.
pub texture: Arc,
- /// Whether the cape is the default one, used when the currently selected cape does not
- /// override it.
- pub is_default: bool,
/// Whether the cape is currently equipped in the Minecraft profile of its corresponding
/// player.
pub is_equipped: bool,
}
-#[derive(Deserialize, Serialize, Debug)]
+#[derive(Clone, Deserialize, Serialize, Debug)]
pub struct Skin {
- /// An opaque identifier for the skin texture, which can be used to identify it.
+ /// A key used to recognize this skin texture.
pub texture_key: Arc,
/// The name of the skin, if available.
pub name: Option>,
+ /// The section this skin should be grouped under, if available.
+ #[serde(default)]
+ pub section: Option>,
/// The variant of the skin model.
pub variant: MinecraftSkinVariant,
/// The UUID of the cape that this skin uses, if any.
///
- /// If `None`, the skin does not have an explicit cape set, and the default cape for
- /// this player, if any, should be used.
+ /// If `None`, this skin uses no cape.
pub cape_id: Option,
/// The URL of the skin PNG texture. Can also be a data URL.
pub texture: Arc,
@@ -72,7 +239,7 @@ pub struct Skin {
pub is_equipped: bool,
}
-#[derive(Deserialize, Serialize, Debug)]
+#[derive(Clone, Deserialize, Serialize, Debug)]
#[serde(rename_all = "snake_case")]
pub enum SkinSource {
/// A default Minecraft skin, which may be assigned to players at random by default.
@@ -91,8 +258,8 @@ pub enum UrlOrBlob {
Blob(Bytes),
}
-/// Retrieves the available capes for the currently selected Minecraft profile. At most one cape
-/// can be equipped at a time. Also, at most one cape can be set as the default cape.
+/// Gets the capes for the selected Minecraft profile.
+/// Only one cape can be equipped.
#[tracing::instrument]
pub async fn get_available_capes() -> crate::Result> {
let state = State::get().await?;
@@ -101,16 +268,16 @@ pub async fn get_available_capes() -> crate::Result> {
.await?
.ok_or(ErrorKind::NoCredentialsError)?;
- let profile =
- selected_credentials.online_profile().await.ok_or_else(|| {
- ErrorKind::OnlineMinecraftProfileUnavailable {
- user_name: selected_credentials.offline_profile.name.clone(),
- }
+ let profile = selected_credentials
+ .online_profile_fresh()
+ .await
+ .ok_or_else(|| ErrorKind::OnlineMinecraftProfileUnavailable {
+ user_name: selected_credentials.offline_profile.name.clone(),
})?;
-
- let default_cape_id = DefaultMinecraftCape::get(profile.id, &state.pool)
- .await?
- .map(|cape| cape.id);
+ let pending_skin_change = pending_effective_skin_change(profile.id).await;
+ let pending_cape_id = pending_skin_change
+ .as_ref()
+ .map(PendingEffectiveSkinChange::cape_id);
Ok(profile
.capes
@@ -119,18 +286,21 @@ pub async fn get_available_capes() -> crate::Result> {
id: cape.id,
name: Arc::clone(&cape.name),
texture: Arc::clone(&cape.url),
- is_default: default_cape_id
- .is_some_and(|default_cape_id| default_cape_id == cape.id),
- is_equipped: cape.state
- == MinecraftCharacterExpressionState::Active,
+ is_equipped: pending_cape_id.map_or_else(
+ || cape.state == MinecraftCharacterExpressionState::Active,
+ |cape_id| cape_id == Some(cape.id),
+ ),
})
.collect())
}
-/// Retrieves the available skins for the currently selected Minecraft profile. At the moment,
-/// this includes custom skins stored in the app database, default Mojang skins, and the currently
-/// equipped skin, if different from the previous skins. Exactly one of the returned skins is
-/// marked as equipped.
+/// Gets the skins for the selected Minecraft profile.
+/// Returns saved custom skins, bundled default skins, and the active Mojang skin if its texture
+/// is not represented by a saved skin or matching bundled default.
+///
+/// Saved skins are identified by texture key. If Mojang reports that a saved skin is active with
+/// a different model variant or cape, the saved row is updated and returned as equipped.
+/// Exactly one returned skin is marked as equipped.
#[tracing::instrument]
pub async fn get_available_skins() -> crate::Result> {
let state = State::get().await?;
@@ -139,241 +309,396 @@ pub async fn get_available_skins() -> crate::Result> {
.await?
.ok_or(ErrorKind::NoCredentialsError)?;
- let profile =
- selected_credentials.online_profile().await.ok_or_else(|| {
- ErrorKind::OnlineMinecraftProfileUnavailable {
- user_name: selected_credentials.offline_profile.name.clone(),
- }
+ let profile = selected_credentials
+ .online_profile_fresh()
+ .await
+ .ok_or_else(|| ErrorKind::OnlineMinecraftProfileUnavailable {
+ user_name: selected_credentials.offline_profile.name.clone(),
})?;
let current_skin = profile.current_skin()?;
let current_cape_id = profile.current_cape().map(|cape| cape.id);
- let default_cape_id = DefaultMinecraftCape::get(profile.id, &state.pool)
- .await?
- .map(|cape| cape.id);
+ let pending_skin_change = pending_effective_skin_change(profile.id).await;
+ let pending_unequip = pending_skin_change
+ .as_ref()
+ .is_some_and(PendingEffectiveSkinChange::is_unequip);
+ let pending_skin = pending_skin_change
+ .as_ref()
+ .and_then(PendingEffectiveSkinChange::skin);
- // Keep track of whether we have found the currently equipped skin, to potentially avoid marking
- // several skins as equipped, and know if the equipped skin was found (see below)
- let found_equipped_skin = Arc::new(AtomicBool::new(false));
-
- let custom_skins = CustomMinecraftSkin::get_all(profile.id, &state.pool)
- .await?
- .then(|custom_skin| {
- let found_equipped_skin = Arc::clone(&found_equipped_skin);
- let state = Arc::clone(&state);
- async move {
- // Several custom skins may reuse the same texture for different cape or skin model
- // variations, so check all attributes for correctness
- let is_equipped = !found_equipped_skin.load(Ordering::Acquire)
- && custom_skin.texture_key == *current_skin.texture_key()
- && custom_skin.variant == current_skin.variant
- && custom_skin.cape_id
- == if custom_skin.cape_id.is_some() {
- current_cape_id
- } else {
- default_cape_id
- };
-
- found_equipped_skin.fetch_or(is_equipped, Ordering::AcqRel);
-
- Ok::<_, crate::Error>(Skin {
- name: None,
- variant: custom_skin.variant,
- cape_id: custom_skin.cape_id,
- texture: png_util::blob_to_data_url(
- custom_skin.texture_blob(&state.pool).await?,
- )
- .or_else(|| {
- // Fall back to a placeholder texture if the DB somehow contains corrupt data
- png_util::blob_to_data_url(include_bytes!(
- "minecraft_skins/assets/default/MissingNo.png"
- ))
- })
- .unwrap(),
- source: SkinSource::Custom,
- is_equipped,
- texture_key: custom_skin.texture_key.into(),
- })
+ let fallback_default_skin = assets::DEFAULT_SKINS.first();
+ let current_skin_texture_key = pending_skin.as_ref().map_or_else(
+ || {
+ if pending_unequip {
+ fallback_default_skin.map_or_else(
+ || current_skin.texture_key(),
+ |skin| Arc::clone(&skin.texture_key),
+ )
+ } else {
+ current_skin.texture_key()
}
- });
+ },
+ |skin| skin.texture_key.clone(),
+ );
+ let current_skin_variant = pending_skin.as_ref().map_or_else(
+ || {
+ if pending_unequip {
+ fallback_default_skin
+ .map_or(current_skin.variant, |skin| skin.variant)
+ } else {
+ current_skin.variant
+ }
+ },
+ |skin| skin.variant,
+ );
+ let current_cape_id = pending_skin.as_ref().map_or(
+ if pending_unequip {
+ None
+ } else {
+ current_cape_id
+ },
+ |skin| skin.cape_id,
+ );
+ let mut found_equipped_skin = false;
+ let mut available_skins = Vec::new();
+ let mut saved_default_skins = Vec::new();
- let default_skins =
- stream::iter(assets::DEFAULT_SKINS.iter().map(|default_skin| {
- let is_equipped = !found_equipped_skin.load(Ordering::Acquire)
- && default_skin.texture_key == current_skin.texture_key()
- && default_skin.variant == current_skin.variant;
+ for mut custom_skin in CustomMinecraftSkin::get_all(profile.id, &state.pool)
+ .await?
+ .collect::>()
+ .await
+ {
+ let is_saved_default_skin =
+ is_bundled_skin(&custom_skin.texture_key, custom_skin.variant);
+ let current_skin_sync = if pending_skin.is_some() {
+ SavedSkinSync {
+ is_current_skin: custom_skin.texture_key
+ == current_skin_texture_key.as_ref()
+ && custom_skin.variant == current_skin_variant
+ && custom_skin.cape_id == current_cape_id,
+ settings_changed: false,
+ }
+ } else {
+ sync_saved_skin_with_current_profile(
+ &mut custom_skin,
+ ¤t_skin_texture_key,
+ current_skin_variant,
+ current_cape_id,
+ )
+ };
- found_equipped_skin.fetch_or(is_equipped, Ordering::AcqRel);
+ let synced_texture_blob = if current_skin_sync.settings_changed {
+ let texture_blob = custom_skin.texture_blob(&state.pool).await?;
- Ok::<_, crate::Error>(Skin {
- texture_key: Arc::clone(&default_skin.texture_key),
- name: default_skin.name.as_ref().cloned(),
- variant: default_skin.variant,
- cape_id: None,
- texture: Arc::clone(&default_skin.texture),
- source: SkinSource::Default,
- is_equipped,
- })
- }));
+ if is_saved_default_skin && custom_skin.cape_id.is_none() {
+ custom_skin.remove(profile.id, &state.pool).await?;
+ } else {
+ CustomMinecraftSkin::add(
+ profile.id,
+ &custom_skin.texture_key,
+ &texture_blob,
+ custom_skin.variant,
+ custom_skin.cape_id,
+ &state.pool,
+ )
+ .await?;
+ }
- let mut available_skins = custom_skins
- .chain(default_skins)
- .try_collect::>()
- .await?;
+ Some(texture_blob)
+ } else {
+ None
+ };
+
+ if is_saved_default_skin {
+ if custom_skin.cape_id.is_some() {
+ saved_default_skins.push(custom_skin);
+ }
+ continue;
+ }
+
+ let is_equipped =
+ !found_equipped_skin && current_skin_sync.is_current_skin;
+
+ found_equipped_skin |= is_equipped;
+
+ let texture_blob = match synced_texture_blob {
+ Some(texture_blob) => texture_blob,
+ None => custom_skin.texture_blob(&state.pool).await?,
+ };
- // If the currently equipped skin does not match any of the skins we know about,
- // add it to the list of available skins as a custom external skin, set by an
- // external service (e.g., the Minecraft launcher or website). This way we guarantee
- // that the currently equipped skin is always returned as available
- if !found_equipped_skin.load(Ordering::Acquire) {
available_skins.push(Skin {
- texture_key: current_skin.texture_key(),
- name: current_skin.name.as_deref().map(Arc::from),
- variant: current_skin.variant,
- cape_id: current_cape_id,
- texture: Arc::clone(¤t_skin.url),
- source: SkinSource::CustomExternal,
- is_equipped: true,
+ name: None,
+ section: None,
+ variant: custom_skin.variant,
+ cape_id: custom_skin.cape_id,
+ texture: png_util::blob_to_data_url(texture_blob)
+ .or_else(|| {
+ png_util::blob_to_data_url(include_bytes!(
+ "minecraft_skins/assets/default/MissingNo.png"
+ ))
+ })
+ .unwrap(),
+ source: SkinSource::Custom,
+ is_equipped,
+ texture_key: custom_skin.texture_key.into(),
});
}
+ for default_skin in assets::DEFAULT_SKINS.iter() {
+ let is_equipped = !found_equipped_skin
+ && default_skin.texture_key == current_skin_texture_key
+ && default_skin.variant == current_skin_variant;
+ let saved_cape_id = saved_default_skins
+ .iter()
+ .find(|skin| {
+ skin.texture_key.as_str() == default_skin.texture_key.as_ref()
+ && skin.variant == default_skin.variant
+ })
+ .and_then(|skin| skin.cape_id);
+
+ found_equipped_skin |= is_equipped;
+
+ available_skins.push(Skin {
+ texture_key: Arc::clone(&default_skin.texture_key),
+ name: default_skin.name.as_ref().cloned(),
+ section: default_skin.section.as_ref().cloned(),
+ variant: default_skin.variant,
+ cape_id: if is_equipped {
+ current_cape_id
+ } else {
+ saved_cape_id
+ },
+ texture: Arc::clone(&default_skin.texture),
+ source: SkinSource::Default,
+ is_equipped,
+ });
+ }
+
+ // Keep the active Mojang skin visible even if the app has never saved it.
+ if !found_equipped_skin {
+ if let Some(mut skin) = pending_skin {
+ skin.is_equipped = true;
+ available_skins.push(skin);
+ } else {
+ available_skins.push(Skin {
+ texture_key: current_skin_texture_key,
+ name: current_skin.name.as_deref().map(Arc::from),
+ section: None,
+ variant: current_skin_variant,
+ cape_id: current_cape_id,
+ texture: Arc::clone(¤t_skin.url),
+ source: SkinSource::CustomExternal,
+ is_equipped: true,
+ });
+ }
+ }
+
Ok(available_skins)
}
-/// Adds a custom skin to the app database and equips it for the currently selected
-/// Minecraft profile.
+/// Adds or updates a skin in the app database and equips it for the current profile.
+/// Bundled default skins are only persisted when they have an associated cape.
#[tracing::instrument(skip(texture_blob))]
pub async fn add_and_equip_custom_skin(
texture_blob: Bytes,
variant: MinecraftSkinVariant,
- cape_override: Option,
-) -> crate::Result<()> {
+ cape: Option,
+) -> crate::Result {
let (skin_width, skin_height) = png_util::dimensions(&texture_blob)?;
if skin_width != 64 || ![32, 64].contains(&skin_height) {
return Err(ErrorKind::InvalidSkinTexture)?;
}
- let cape_override = cape_override.map(|cape| cape.id);
let state = State::get().await?;
-
let selected_credentials = Credentials::get_default_credential(&state.pool)
.await?
.ok_or(ErrorKind::NoCredentialsError)?;
+ let cape_id = cape.map(|cape| cape.id);
+ let local_texture_key = local_skin_texture_key(&texture_blob);
- // We have to equip the skin first, as it's the Mojang API backend who knows
- // how to compute the texture key we require, which we can then read from the
- // updated player profile
- mojang_api::MinecraftSkinOperation::equip(
- &selected_credentials,
+ CustomMinecraftSkin::add(
+ selected_credentials.offline_profile.id,
+ &local_texture_key,
+ &texture_blob,
+ variant,
+ cape_id,
+ &state.pool,
+ )
+ .await?;
+
+ set_pending_skin_change(PendingSkinChange::AddAndEquipCustom {
+ selected_credentials,
+ texture_blob: Bytes::clone(&texture_blob),
+ variant,
+ cape_id,
+ local_texture_key: Arc::clone(&local_texture_key),
+ })
+ .await;
+
+ Ok(Skin {
+ texture_key: local_texture_key,
+ name: None,
+ section: None,
+ variant,
+ cape_id,
+ texture: png_util::blob_to_data_url(texture_blob)
+ .or_else(|| {
+ png_util::blob_to_data_url(include_bytes!(
+ "minecraft_skins/assets/default/MissingNo.png"
+ ))
+ })
+ .unwrap(),
+ source: SkinSource::Custom,
+ is_equipped: true,
+ })
+}
+
+async fn add_and_equip_custom_skin_now(
+ selected_credentials: &Credentials,
+ texture_blob: Bytes,
+ variant: MinecraftSkinVariant,
+ cape_id: Option,
+ local_texture_key: &str,
+) -> crate::Result<()> {
+ let state = State::get().await?;
+
+ let previous_profile = selected_credentials
+ .online_profile_fresh()
+ .await
+ .ok_or_else(|| ErrorKind::OnlineMinecraftProfileUnavailable {
+ user_name: selected_credentials.offline_profile.name.clone(),
+ })?;
+
+ preserve_current_profile_skin(&state, &previous_profile).await?;
+
+ // Mojang only gives us the new texture key after accepting the uploaded skin.
+ // Use the profile from that response when possible, and fetch it only if that
+ // response cannot be read.
+ let profile = mojang_api::MinecraftSkinOperation::equip(
+ selected_credentials,
stream::iter([Ok::<_, String>(Bytes::clone(&texture_blob))]),
variant,
)
.await?;
- let profile =
- selected_credentials.online_profile().await.ok_or_else(|| {
- ErrorKind::OnlineMinecraftProfileUnavailable {
+ let profile = match profile {
+ Some(profile) => profile,
+ None => selected_credentials
+ .refresh_online_profile()
+ .await
+ .ok_or_else(|| ErrorKind::OnlineMinecraftProfileUnavailable {
user_name: selected_credentials.offline_profile.name.clone(),
- }
- })?;
+ })?,
+ };
- sync_cape(&state, &selected_credentials, &profile, cape_override).await?;
+ let equipped_skin = profile.current_skin()?;
+ let equipped_skin_texture_key = equipped_skin.texture_key();
+ let equipped_skin_variant = equipped_skin.variant;
- CustomMinecraftSkin::add(
- profile.id,
- &profile.current_skin()?.texture_key(),
- &texture_blob,
- variant,
- cape_override,
- &state.pool,
- )
- .await?;
-
- Ok(())
-}
-
-/// Sets the default cape for the currently selected Minecraft profile. If `None`,
-/// the default cape will be removed.
-///
-/// This cape will be used by any custom skin that does not have a cape override
-/// set. If the currently equipped skin does not have a cape override set, the equipped
-/// cape will also be changed to the new default cape. When neither the equipped skin
-/// defines a cape override nor the default cape is set, the player will have no
-/// cape equipped.
-#[tracing::instrument]
-pub async fn set_default_cape(cape: Option) -> crate::Result<()> {
- let state = State::get().await?;
-
- let selected_credentials = Credentials::get_default_credential(&state.pool)
- .await?
- .ok_or(ErrorKind::NoCredentialsError)?;
-
- let profile =
- selected_credentials.online_profile().await.ok_or_else(|| {
- ErrorKind::OnlineMinecraftProfileUnavailable {
- user_name: selected_credentials.offline_profile.name.clone(),
- }
- })?;
- let current_skin = get_available_skins()
- .await?
- .into_iter()
- .find(|skin| skin.is_equipped)
- .unwrap();
-
- if let Some(cape) = cape {
- // Synchronize the equipped cape with the new default cape, if the current skin uses
- // the default cape
- if current_skin.cape_id.is_none() {
- mojang_api::MinecraftCapeOperation::equip(
- &selected_credentials,
- cape.id,
- )
- .await?;
+ let persistence_result = if cape_id.is_none()
+ && is_bundled_skin(&equipped_skin_texture_key, equipped_skin_variant)
+ {
+ CustomMinecraftSkin {
+ texture_key: equipped_skin_texture_key.to_string(),
+ variant: equipped_skin_variant,
+ cape_id: None,
}
-
- DefaultMinecraftCape::set(profile.id, cape.id, &state.pool).await?;
+ .remove(profile.id, &state.pool)
+ .await
} else {
- if current_skin.cape_id.is_none() {
- mojang_api::MinecraftCapeOperation::unequip_any(
- &selected_credentials,
- )
- .await?;
- }
+ CustomMinecraftSkin::add(
+ profile.id,
+ &equipped_skin_texture_key,
+ &texture_blob,
+ variant,
+ cape_id,
+ &state.pool,
+ )
+ .await
+ };
- DefaultMinecraftCape::remove(profile.id, &state.pool).await?;
+ if let Err(error) = persistence_result {
+ refresh_profile_cache(selected_credentials).await;
+ return Err(error);
+ }
+
+ if local_texture_key != equipped_skin_texture_key.as_ref() {
+ CustomMinecraftSkin {
+ texture_key: local_texture_key.to_string(),
+ variant,
+ cape_id,
+ }
+ .remove(profile.id, &state.pool)
+ .await?;
+ }
+
+ if let Err(error) = sync_cape(selected_credentials, &profile, cape_id).await
+ {
+ refresh_profile_cache(selected_credentials).await;
+ return Err(error);
}
Ok(())
}
-/// Equips the given skin for the currently selected Minecraft profile. If the skin is already
-/// equipped, it will be re-equipped.
+/// Equips the given skin for the currently selected Minecraft profile, then applies its cape.
+/// If the skin is already equipped, it will be re-equipped.
///
-/// This function does not check that the passed skin, if custom, exists in the app database,
-/// giving the caller complete freedom to equip any skin at any time.
+/// This does not check whether a custom skin exists in the app database.
#[tracing::instrument]
pub async fn equip_skin(skin: Skin) -> crate::Result<()> {
let state = State::get().await?;
-
let selected_credentials = Credentials::get_default_credential(&state.pool)
.await?
.ok_or(ErrorKind::NoCredentialsError)?;
- let profile =
- selected_credentials.online_profile().await.ok_or_else(|| {
- ErrorKind::OnlineMinecraftProfileUnavailable {
- user_name: selected_credentials.offline_profile.name.clone(),
- }
+ set_pending_skin_change(PendingSkinChange::Equip {
+ selected_credentials,
+ skin,
+ })
+ .await;
+
+ Ok(())
+}
+
+async fn equip_skin_now(
+ selected_credentials: &Credentials,
+ skin: &Skin,
+) -> crate::Result<()> {
+ let state = State::get().await?;
+
+ let profile = selected_credentials
+ .online_profile_fresh()
+ .await
+ .ok_or_else(|| ErrorKind::OnlineMinecraftProfileUnavailable {
+ user_name: selected_credentials.offline_profile.name.clone(),
})?;
- mojang_api::MinecraftSkinOperation::equip(
- &selected_credentials,
+ preserve_current_profile_skin(&state, &profile).await?;
+
+ let profile = mojang_api::MinecraftSkinOperation::equip(
+ selected_credentials,
png_util::url_to_data_stream(&skin.texture).await?,
skin.variant,
)
.await?;
- sync_cape(&state, &selected_credentials, &profile, skin.cape_id).await?;
+ let profile = match profile {
+ Some(profile) => profile,
+ None => selected_credentials
+ .refresh_online_profile()
+ .await
+ .ok_or_else(|| ErrorKind::OnlineMinecraftProfileUnavailable {
+ user_name: selected_credentials.offline_profile.name.clone(),
+ })?,
+ };
+
+ if let Err(error) =
+ sync_cape(selected_credentials, &profile, skin.cape_id).await
+ {
+ refresh_profile_cache(selected_credentials).await;
+ return Err(error);
+ }
Ok(())
}
@@ -397,37 +722,131 @@ pub async fn remove_custom_skin(skin: Skin) -> crate::Result<()> {
variant: skin.variant,
cape_id: skin.cape_id,
}
- .remove(
- selected_credentials.maybe_online_profile().await.id,
- &state.pool,
- )
+ .remove(selected_credentials.offline_profile.id, &state.pool)
.await?;
+ cancel_pending_skin_change_for_skin(
+ selected_credentials.offline_profile.id,
+ &skin,
+ )
+ .await;
+
Ok(())
}
-/// Unequips the currently equipped skin for the currently selected Minecraft profile, resetting
-/// it to one of the default skins. The cape will be set to the default cape, or unequipped if
-/// no default cape is set.
-#[tracing::instrument]
-pub async fn unequip_skin() -> crate::Result<()> {
+/// Adds or updates a saved skin locally without applying it to Mojang.
+///
+/// This is used by the skin editor. If the edited skin is currently equipped, the caller should
+/// queue a separate equip operation after saving the local row.
+#[tracing::instrument(skip(texture_blob))]
+pub async fn save_custom_skin(
+ mut skin: Skin,
+ texture_blob: Bytes,
+ variant: MinecraftSkinVariant,
+ cape: Option,
+ replace_texture: bool,
+) -> crate::Result {
+ let (skin_width, skin_height) = png_util::dimensions(&texture_blob)?;
+ if skin_width != 64 || ![32, 64].contains(&skin_height) {
+ return Err(ErrorKind::InvalidSkinTexture)?;
+ }
+
let state = State::get().await?;
let selected_credentials = Credentials::get_default_credential(&state.pool)
.await?
.ok_or(ErrorKind::NoCredentialsError)?;
- let profile =
- selected_credentials.online_profile().await.ok_or_else(|| {
- ErrorKind::OnlineMinecraftProfileUnavailable {
- user_name: selected_credentials.offline_profile.name.clone(),
- }
+ let old_texture_key = Arc::clone(&skin.texture_key);
+ let texture_key = if replace_texture {
+ local_skin_texture_key(&texture_blob)
+ } else {
+ Arc::clone(&skin.texture_key)
+ };
+ let cape_id = cape.map(|cape| cape.id);
+
+ if cape_id.is_none() && is_bundled_skin(&texture_key, variant) {
+ CustomMinecraftSkin {
+ texture_key: texture_key.to_string(),
+ variant,
+ cape_id: None,
+ }
+ .remove(selected_credentials.offline_profile.id, &state.pool)
+ .await?;
+ } else {
+ CustomMinecraftSkin::add(
+ selected_credentials.offline_profile.id,
+ &texture_key,
+ &texture_blob,
+ variant,
+ cape_id,
+ &state.pool,
+ )
+ .await?;
+ }
+
+ if replace_texture && old_texture_key != texture_key {
+ CustomMinecraftSkin {
+ texture_key: old_texture_key.to_string(),
+ variant: skin.variant,
+ cape_id: skin.cape_id,
+ }
+ .remove(selected_credentials.offline_profile.id, &state.pool)
+ .await?;
+ }
+
+ skin.texture_key = texture_key;
+ skin.variant = variant;
+ skin.cape_id = cape_id;
+ skin.texture = png_util::blob_to_data_url(texture_blob)
+ .or_else(|| {
+ png_util::blob_to_data_url(include_bytes!(
+ "minecraft_skins/assets/default/MissingNo.png"
+ ))
+ })
+ .unwrap();
+
+ Ok(skin)
+}
+
+/// Unequips the currently equipped skin for the currently selected Minecraft profile, resetting
+/// it to one of the default skins and unequipping any cape.
+#[tracing::instrument]
+pub async fn unequip_skin() -> crate::Result<()> {
+ let state = State::get().await?;
+ let selected_credentials = Credentials::get_default_credential(&state.pool)
+ .await?
+ .ok_or(ErrorKind::NoCredentialsError)?;
+
+ set_pending_skin_change(PendingSkinChange::Unequip {
+ selected_credentials,
+ })
+ .await;
+
+ Ok(())
+}
+
+async fn unequip_skin_now(
+ selected_credentials: &Credentials,
+) -> crate::Result<()> {
+ let state = State::get().await?;
+
+ let profile = selected_credentials
+ .online_profile_fresh()
+ .await
+ .ok_or_else(|| ErrorKind::OnlineMinecraftProfileUnavailable {
+ user_name: selected_credentials.offline_profile.name.clone(),
})?;
- mojang_api::MinecraftSkinOperation::unequip_any(&selected_credentials)
+ preserve_current_profile_skin(&state, &profile).await?;
+
+ mojang_api::MinecraftSkinOperation::unequip_any(selected_credentials)
.await?;
- sync_cape(&state, &selected_credentials, &profile, None).await?;
+ if let Err(error) = sync_cape(selected_credentials, &profile, None).await {
+ refresh_profile_cache(selected_credentials).await;
+ return Err(error);
+ }
Ok(())
}
@@ -437,7 +856,7 @@ pub async fn unequip_skin() -> crate::Result<()> {
/// PNG encoding speed over compression density, so the resulting textures are better
/// suited for display purposes, not persistent storage or transmission.
///
-/// The normalized, processed is returned texture as a byte array in PNG format.
+/// The normalized texture is returned as PNG bytes.
#[tracing::instrument]
pub async fn normalize_skin_texture(
texture: &UrlOrBlob,
@@ -445,6 +864,199 @@ pub async fn normalize_skin_texture(
png_util::normalize_skin_texture(texture).await
}
+/// Sends any pending skin change immediately.
+///
+/// This is used before launching Minecraft and before closing the app so the debounced
+/// skin selection is still applied for those boundary cases.
+#[tracing::instrument]
+pub async fn flush_pending_skin_change() -> crate::Result<()> {
+ flush_pending_skin_change_inner(None).await
+}
+
+/// Sends any pending skin change for a specific Minecraft account immediately.
+#[tracing::instrument]
+pub async fn flush_pending_skin_change_for_profile(
+ profile_id: Uuid,
+) -> crate::Result<()> {
+ flush_pending_skin_change_inner(Some(PendingSkinChangeFilter::Profile(
+ profile_id,
+ )))
+ .await
+}
+
+async fn set_pending_skin_change(change: PendingSkinChange) {
+ let profile_id = change.profile_id();
+ let generation = {
+ let mut state = PENDING_SKIN_CHANGE.lock().await;
+ let generation = state
+ .pending
+ .get(&profile_id)
+ .map_or(1, |entry| entry.generation.wrapping_add(1));
+
+ state
+ .pending
+ .insert(profile_id, PendingSkinChangeEntry { change, generation });
+
+ generation
+ };
+
+ schedule_pending_skin_change_flush(profile_id, generation);
+}
+
+fn schedule_pending_skin_change_flush(profile_id: Uuid, generation: u64) {
+ tokio::spawn(async move {
+ tokio::time::sleep(SKIN_CHANGE_DEBOUNCE).await;
+
+ if let Err(error) = flush_pending_skin_change_inner(Some(
+ PendingSkinChangeFilter::Generation {
+ profile_id,
+ generation,
+ },
+ ))
+ .await
+ {
+ let _ = crate::event::emit::emit_warning(&format!(
+ "Failed to apply pending Minecraft skin change: {error}"
+ ))
+ .await;
+ }
+ });
+}
+
+async fn pending_effective_skin_change(
+ profile_id: Uuid,
+) -> Option {
+ let state = PENDING_SKIN_CHANGE.lock().await;
+
+ state
+ .pending
+ .get(&profile_id)
+ .map(|entry| match &entry.change {
+ PendingSkinChange::AddAndEquipCustom {
+ texture_blob,
+ variant,
+ cape_id,
+ local_texture_key,
+ ..
+ } => PendingEffectiveSkinChange::AddAndEquipCustom {
+ texture_key: Arc::clone(local_texture_key),
+ texture_blob: Bytes::clone(texture_blob),
+ variant: *variant,
+ cape_id: *cape_id,
+ },
+ PendingSkinChange::Equip { skin, .. } => {
+ PendingEffectiveSkinChange::Equip { skin: skin.clone() }
+ }
+ PendingSkinChange::Unequip { .. } => {
+ PendingEffectiveSkinChange::Unequip
+ }
+ })
+}
+
+async fn cancel_pending_skin_change_for_skin(profile_id: Uuid, skin: &Skin) {
+ let mut state = PENDING_SKIN_CHANGE.lock().await;
+ let should_cancel = state
+ .pending
+ .get(&profile_id)
+ .is_some_and(|entry| entry.change.matches_skin(skin));
+
+ if should_cancel {
+ state.pending.remove(&profile_id);
+ }
+}
+
+#[derive(Clone, Copy, Debug)]
+enum PendingSkinChangeFilter {
+ Generation { profile_id: Uuid, generation: u64 },
+ Profile(Uuid),
+}
+
+async fn flush_pending_skin_change_inner(
+ filter: Option,
+) -> crate::Result<()> {
+ let _guard = SKIN_CHANGE_FLUSH_LOCK.lock().await;
+
+ loop {
+ let entry = {
+ let mut state = PENDING_SKIN_CHANGE.lock().await;
+
+ match filter {
+ Some(PendingSkinChangeFilter::Generation {
+ profile_id,
+ generation,
+ }) => {
+ let Some(entry) = state.pending.get(&profile_id) else {
+ return Ok(());
+ };
+
+ if entry.generation != generation {
+ return Ok(());
+ }
+
+ state.pending.remove(&profile_id)
+ }
+ Some(PendingSkinChangeFilter::Profile(profile_id)) => {
+ state.pending.remove(&profile_id)
+ }
+ None => {
+ let profile_id = state.pending.keys().next().copied();
+ profile_id.and_then(|profile_id| {
+ state.pending.remove(&profile_id)
+ })
+ }
+ }
+ };
+
+ let Some(entry) = entry else {
+ return Ok(());
+ };
+
+ if let Err(error) = execute_pending_skin_change(&entry.change).await {
+ let profile_id = entry.change.profile_id();
+ let generation = entry.generation;
+ let mut state = PENDING_SKIN_CHANGE.lock().await;
+ state.pending.entry(profile_id).or_insert(entry);
+ schedule_pending_skin_change_flush(profile_id, generation);
+
+ return Err(error);
+ }
+
+ if filter.is_some() {
+ return Ok(());
+ }
+ }
+}
+
+async fn execute_pending_skin_change(
+ change: &PendingSkinChange,
+) -> crate::Result<()> {
+ match change {
+ PendingSkinChange::AddAndEquipCustom {
+ selected_credentials,
+ texture_blob,
+ variant,
+ cape_id,
+ local_texture_key,
+ } => {
+ add_and_equip_custom_skin_now(
+ selected_credentials,
+ Bytes::clone(texture_blob),
+ *variant,
+ *cape_id,
+ local_texture_key,
+ )
+ .await
+ }
+ PendingSkinChange::Equip {
+ selected_credentials,
+ skin,
+ } => equip_skin_now(selected_credentials, skin).await,
+ PendingSkinChange::Unequip {
+ selected_credentials,
+ } => unequip_skin_now(selected_credentials).await,
+ }
+}
+
/// Reads and validates a skin texture file from the given path.
/// Returns the file content as bytes if it's a valid skin texture (PNG with 64x64 or 64x32 dimensions).
#[tracing::instrument]
@@ -491,22 +1103,127 @@ pub async fn get_dragged_skin_data(
}
}
-/// Synchronizes the equipped cape with the selected cape if necessary, taking into
-/// account the currently equipped cape, the default cape for the player, and if a
-/// cape override is provided.
-async fn sync_cape(
+async fn preserve_current_profile_skin(
state: &State,
+ profile: &MinecraftProfile,
+) -> crate::Result<()> {
+ let current_skin = profile.current_skin()?;
+ let current_skin_texture_key = current_skin.texture_key();
+ let current_cape_id = profile.current_cape().map(|cape| cape.id);
+
+ if is_bundled_skin_texture(¤t_skin_texture_key) {
+ return Ok(());
+ }
+
+ if let Some(saved_skin) = CustomMinecraftSkin::get_by_texture(
+ profile.id,
+ ¤t_skin_texture_key,
+ &state.pool,
+ )
+ .await?
+ {
+ if saved_skin.variant == current_skin.variant
+ && saved_skin.cape_id == current_cape_id
+ {
+ return Ok(());
+ }
+
+ let texture = saved_skin.texture_blob(&state.pool).await?;
+ CustomMinecraftSkin::add(
+ profile.id,
+ ¤t_skin_texture_key,
+ &texture,
+ current_skin.variant,
+ current_cape_id,
+ &state.pool,
+ )
+ .await?;
+
+ return Ok(());
+ }
+
+ let texture = png_util::url_to_data_stream(¤t_skin.url)
+ .await?
+ .try_fold(Vec::new(), |mut texture, chunk| async move {
+ texture.extend_from_slice(&chunk);
+ Ok(texture)
+ })
+ .await?;
+
+ CustomMinecraftSkin::add(
+ profile.id,
+ ¤t_skin_texture_key,
+ &texture,
+ current_skin.variant,
+ current_cape_id,
+ &state.pool,
+ )
+ .await?;
+
+ Ok(())
+}
+
+async fn refresh_profile_cache(selected_credentials: &Credentials) {
+ let _ = selected_credentials.refresh_online_profile().await;
+}
+
+fn is_bundled_skin_texture(texture_key: &str) -> bool {
+ assets::DEFAULT_SKINS
+ .iter()
+ .any(|default_skin| default_skin.texture_key.as_ref() == texture_key)
+}
+
+fn is_bundled_skin(texture_key: &str, variant: MinecraftSkinVariant) -> bool {
+ assets::DEFAULT_SKINS.iter().any(|default_skin| {
+ default_skin.texture_key.as_ref() == texture_key
+ && default_skin.variant == variant
+ })
+}
+
+fn local_skin_texture_key(texture_blob: &[u8]) -> Arc {
+ Arc::from(format!("local-{:x}", sha2::Sha256::digest(texture_blob)))
+}
+
+#[derive(Debug, PartialEq, Eq)]
+struct SavedSkinSync {
+ is_current_skin: bool,
+ settings_changed: bool,
+}
+
+fn sync_saved_skin_with_current_profile(
+ saved_skin: &mut CustomMinecraftSkin,
+ current_skin_texture_key: &str,
+ current_skin_variant: MinecraftSkinVariant,
+ current_cape_id: Option,
+) -> SavedSkinSync {
+ if saved_skin.texture_key != current_skin_texture_key {
+ return SavedSkinSync {
+ is_current_skin: false,
+ settings_changed: false,
+ };
+ }
+
+ let settings_changed = saved_skin.variant != current_skin_variant
+ || saved_skin.cape_id != current_cape_id;
+
+ if settings_changed {
+ saved_skin.variant = current_skin_variant;
+ saved_skin.cape_id = current_cape_id;
+ }
+
+ SavedSkinSync {
+ is_current_skin: true,
+ settings_changed,
+ }
+}
+
+/// Sets the equipped cape to the skin's associated cape, or no cape.
+async fn sync_cape(
selected_credentials: &Credentials,
profile: &MinecraftProfile,
- cape_override: Option,
+ target_cape_id: Option,
) -> crate::Result<()> {
let current_cape_id = profile.current_cape().map(|cape| cape.id);
- let target_cape_id = match cape_override {
- Some(cape_id) => Some(cape_id),
- None => DefaultMinecraftCape::get(profile.id, &state.pool)
- .await?
- .map(|cape| cape.id),
- };
if current_cape_id != target_cape_id {
match target_cape_id {
diff --git a/packages/app-lib/src/api/minecraft_skins/assets/default/default_skins.rs b/packages/app-lib/src/api/minecraft_skins/assets/default/default_skins.rs
index af9709913..669fa24ec 100644
--- a/packages/app-lib/src/api/minecraft_skins/assets/default/default_skins.rs
+++ b/packages/app-lib/src/api/minecraft_skins/assets/default/default_skins.rs
@@ -6,6 +6,16 @@ use crate::{minecraft_skins::SkinSource, state::MinecraftSkinVariant};
use super::super::super::Skin;
+const DEFAULT_SKINS_SECTION: &str = "Default skins";
+const MINECON_EARTH_2017_SKIN_PACK_SECTION: &str = "MINECON Earth 2017";
+const BUILDERS_AND_BIOMES_SKIN_PACK_SECTION: &str = "Builders & Biomes";
+const STRIDING_HERO_SKIN_PACK_SECTION: &str = "Striding Hero";
+const THE_GARDEN_AWAKENS_SKIN_PACK_SECTION: &str = "The Garden Awakens";
+const CHASE_THE_SKIES_SKIN_PACK_SECTION: &str = "Chase the Skies";
+const THE_COPPER_AGE_SKIN_PACK_SECTION: &str = "The Copper Age";
+const MOUNTS_OF_MAYHEM_SKIN_PACK_SECTION: &str = "Mounts of Mayhem";
+const TINY_TAKEOVER_SKIN_PACK_SECTION: &str = "Tiny Takeover";
+
/// A list of default Minecraft skins to make available to the user, created by Mojang.
pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
//
@@ -16,6 +26,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
vec![Skin {
texture_key: Arc::from("46acd06e8483b176e8ea39fc12fe105eb3a2a4970f5100057e9d84d4b60bdfa7"),
name: Some(Arc::from("Alex")),
+ section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
variant: MinecraftSkinVariant::Slim,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -27,6 +38,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("1abc803022d8300ab7578b189294cce39622d9a404cdc00d3feacfdf45be6981"),
name: Some(Arc::from("Alex")),
+ section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -38,6 +50,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("6ac6ca262d67bcfb3dbc924ba8215a18195497c780058a5749de674217721892"),
name: Some(Arc::from("Ari")),
+ section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
variant: MinecraftSkinVariant::Slim,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -49,6 +62,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("4c05ab9e07b3505dc3ec11370c3bdce5570ad2fb2b562e9b9dd9cf271f81aa44"),
name: Some(Arc::from("Ari")),
+ section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -60,6 +74,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("fece7017b1bb13926d1158864b283b8b930271f80a90482f174cca6a17e88236"),
name: Some(Arc::from("Efe")),
+ section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
variant: MinecraftSkinVariant::Slim,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -71,6 +86,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("daf3d88ccb38f11f74814e92053d92f7728ddb1a7955652a60e30cb27ae6659f"),
name: Some(Arc::from("Efe")),
+ section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -82,6 +98,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("226c617fde5b1ba569aa08bd2cb6fd84c93337532a872b3eb7bf66bdd5b395f8"),
name: Some(Arc::from("Kai")),
+ section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
variant: MinecraftSkinVariant::Slim,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -93,6 +110,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("e5cdc3243b2153ab28a159861be643a4fc1e3c17d291cdd3e57a7f370ad676f3"),
name: Some(Arc::from("Kai")),
+ section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -104,6 +122,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("7cb3ba52ddd5cc82c0b050c3f920f87da36add80165846f479079663805433db"),
name: Some(Arc::from("Makena")),
+ section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
variant: MinecraftSkinVariant::Slim,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -115,6 +134,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("dc0fcfaf2aa040a83dc0de4e56058d1bbb2ea40157501f3e7d15dc245e493095"),
name: Some(Arc::from("Makena")),
+ section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -126,6 +146,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("6c160fbd16adbc4bff2409e70180d911002aebcfa811eb6ec3d1040761aea6dd"),
name: Some(Arc::from("Noor")),
+ section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
variant: MinecraftSkinVariant::Slim,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -137,6 +158,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("90e75cd429ba6331cd210b9bd19399527ee3bab467b5a9f61cb8a27b177f6789"),
name: Some(Arc::from("Noor")),
+ section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -148,6 +170,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("d5c4ee5ce20aed9e33e866c66caa37178606234b3721084bf01d13320fb2eb3f"),
name: Some(Arc::from("Steve")),
+ section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
variant: MinecraftSkinVariant::Slim,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -159,6 +182,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("31f477eb1a7beee631c2ca64d06f8f68fa93a3386d04452ab27f43acdf1b60cb"),
name: Some(Arc::from("Steve")),
+ section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -170,6 +194,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("b66bc80f002b10371e2fa23de6f230dd5e2f3affc2e15786f65bc9be4c6eb71a"),
name: Some(Arc::from("Sunny")),
+ section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
variant: MinecraftSkinVariant::Slim,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -181,6 +206,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("a3bd16079f764cd541e072e888fe43885e711f98658323db0f9a6045da91ee7a"),
name: Some(Arc::from("Sunny")),
+ section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -192,6 +218,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("eee522611005acf256dbd152e992c60c0bb7978cb0f3127807700e478ad97664"),
name: Some(Arc::from("Zuri")),
+ section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
variant: MinecraftSkinVariant::Slim,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -203,6 +230,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("f5dddb41dcafef616e959c2817808e0be741c89ffbfed39134a13e75b811863d"),
name: Some(Arc::from("Zuri")),
+ section: Some(Arc::from(DEFAULT_SKINS_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -220,6 +248,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("6c25523e7dabfcaf0dbe32d90fd0c001d5d57ac66206a0595defe9be5947ff08"),
name: Some(Arc::from("Globe Alex")),
+ section: Some(Arc::from(MINECON_EARTH_2017_SKIN_PACK_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -231,6 +260,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("66206c8f51d13d2d31c54696a58a3e8bcd1e5e7db9888d331d0753129324e4f1"),
name: Some(Arc::from("Party Alex")),
+ section: Some(Arc::from(MINECON_EARTH_2017_SKIN_PACK_SECTION)),
variant: MinecraftSkinVariant::Slim,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -242,6 +272,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("6acf91326bd116ce889e461ddb57e92ace07a8367dbd2d191075078fccc3c727"),
name: Some(Arc::from("Cardboard Cosplayer")),
+ section: Some(Arc::from(MINECON_EARTH_2017_SKIN_PACK_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -253,6 +284,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("b9f7facdca2bf4772fa168e1c3cf7b020124eb1fc82118307d426da1b88c32c5"),
name: Some(Arc::from("Creeper Cosplayer")),
+ section: Some(Arc::from(MINECON_EARTH_2017_SKIN_PACK_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -264,6 +296,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("b7393199a84eb9e932efa8dda6829423875eb65af76cb82912ade62f93996b9c"),
name: Some(Arc::from("Creeper Piñata Cosplayer")),
+ section: Some(Arc::from(MINECON_EARTH_2017_SKIN_PACK_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -275,6 +308,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("7cbe449d9d37c111a07a902e322d3869d98790c48f1fa16a24bcbe2d8d73808b"),
name: Some(Arc::from("Sheep Cosplayer")),
+ section: Some(Arc::from(MINECON_EARTH_2017_SKIN_PACK_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -286,6 +320,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("b182ad5783a343be3e202ac35902270a8d31042fdfd48b849fc99a55a1b60a91"),
name: Some(Arc::from("Cake Steve")),
+ section: Some(Arc::from(MINECON_EARTH_2017_SKIN_PACK_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -297,6 +332,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("c05e396bbf744082122f77b7277af390d11d2d4e93dd2f8c67942ca9626db24d"),
name: Some(Arc::from("Party Steve")),
+ section: Some(Arc::from(MINECON_EARTH_2017_SKIN_PACK_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -311,6 +347,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("2007b66a99ae905c81f339e2a0a4bf4b99e9454a485d5164e3e1051c3036ad70"),
name: Some(Arc::from("Barn Builder")),
+ section: Some(Arc::from(BUILDERS_AND_BIOMES_SKIN_PACK_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -322,6 +359,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("59f2872323bf515aa8d84c00931fbf8170b2cec5138961527c09ffcd06ca4ab2"),
name: Some(Arc::from("Bee-Friender")),
+ section: Some(Arc::from(BUILDERS_AND_BIOMES_SKIN_PACK_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -333,6 +371,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("7cd85127cbc710a1c9a53c6bb3474f59995c222b9d8c57b293993cc2d8a225aa"),
name: Some(Arc::from("Bee-Friender (Alternate)")),
+ section: Some(Arc::from(BUILDERS_AND_BIOMES_SKIN_PACK_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -344,6 +383,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("5e4e09eccbce11e701c51bb64b102d688a6ac4018c725dd2b780210aee101b31"),
name: Some(Arc::from("Buff Butcher")),
+ section: Some(Arc::from(BUILDERS_AND_BIOMES_SKIN_PACK_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -355,6 +395,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("d66ed86ce96a1b63c30f1baac762f638717930866474ac4fce697cdbd0bd6fbb"),
name: Some(Arc::from("Buff Butcher (Alternate)")),
+ section: Some(Arc::from(BUILDERS_AND_BIOMES_SKIN_PACK_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -366,6 +407,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("b9e9d1b51b4be289b9525d4decd798cb7912e920bac8846a2df70e9ff4f0b1d8"),
name: Some(Arc::from("Homestead Healer")),
+ section: Some(Arc::from(BUILDERS_AND_BIOMES_SKIN_PACK_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -377,6 +419,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("83e283ab33558baa2cd0184d2e85f090c795a797bdbcb2cc47230c27f23fe9b1"),
name: Some(Arc::from("Pig Whisperer")),
+ section: Some(Arc::from(BUILDERS_AND_BIOMES_SKIN_PACK_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -388,6 +431,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("e1fc44f1d69fd2864df7b80618a38af4170d4800f2df4fbde81c17b74b2a818b"),
name: Some(Arc::from("Pig Whisperer (Alternate)")),
+ section: Some(Arc::from(BUILDERS_AND_BIOMES_SKIN_PACK_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -399,6 +443,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("25dc6421d47cad8e2bdf93f56fae9ab06fcfe218c8645c1775ae2e4563c065ad"),
name: Some(Arc::from("Ranch Ranger")),
+ section: Some(Arc::from(BUILDERS_AND_BIOMES_SKIN_PACK_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -413,6 +458,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("721c05483a435d4362047ccb62e075ef5f001aa63a7e0e2afe03e60759bab91d"),
name: Some(Arc::from("Snowfeather")),
+ section: Some(Arc::from(STRIDING_HERO_SKIN_PACK_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -424,6 +470,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("b914cf5106aaa82409fdd9213fbdb1479b4d65aecc5d5e22b1f25e5744c4c4f7"),
name: Some(Arc::from("Stray")),
+ section: Some(Arc::from(STRIDING_HERO_SKIN_PACK_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -435,6 +482,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("5eb077c54ecfc7e760c36add887b68859d7a3160d331580ff859f7353d959151"),
name: Some(Arc::from("Strider")),
+ section: Some(Arc::from(STRIDING_HERO_SKIN_PACK_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -446,6 +494,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("b271a744ef479018927575952621b110b9c11f62730a95729af7e8591cf8dbf6"),
name: Some(Arc::from("Villager 1")),
+ section: Some(Arc::from(STRIDING_HERO_SKIN_PACK_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -457,6 +506,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("748923629fed7c6ec9462016b4480fa3cff8c16e82ee6fe26d4b707f4de10060"),
name: Some(Arc::from("Villager 2")),
+ section: Some(Arc::from(STRIDING_HERO_SKIN_PACK_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -468,6 +518,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("3d996abc69ea70a20442855e429bf44b45111f9818d0f8c46272e12d12bec218"),
name: Some(Arc::from("Wither Skeleton")),
+ section: Some(Arc::from(STRIDING_HERO_SKIN_PACK_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -482,6 +533,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("6f8fc677cdcd4c6eed67d90c08d23162abc3a3a85357c7636fdf80d874aa857f"),
name: Some(Arc::from("Pale Lumberjack")),
+ section: Some(Arc::from(THE_GARDEN_AWAKENS_SKIN_PACK_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -493,6 +545,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("9a0af2b1fd9659480d43132db95cd7d459d1a66480fe42150e132d03b9731573"),
name: Some(Arc::from("Creaking")),
+ section: Some(Arc::from(THE_GARDEN_AWAKENS_SKIN_PACK_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -507,6 +560,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("8409954698b6c7741460fdd85d6ec6a5e0a9ad04ade7e2c72c913f02936a607d"),
name: Some(Arc::from("Ghast Pilot")),
+ section: Some(Arc::from(CHASE_THE_SKIES_SKIN_PACK_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -518,6 +572,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("e12d98dab548e92cad7ac80f92d8fefbb9ca7a1af94aa4f428daf6ef723aa8e0"),
name: Some(Arc::from("Ghast Swimmer")),
+ section: Some(Arc::from(CHASE_THE_SKIES_SKIN_PACK_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -533,6 +588,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("33aef79a4ca986a2971057d35046e71dce326b645353e6f92d56e1c4bb3b0073"),
name: Some(Arc::from("Copper Chemist")),
+ section: Some(Arc::from(THE_COPPER_AGE_SKIN_PACK_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -544,6 +600,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("514b10ff7bc50dd01b5438632815b9e27cfe54064d1a28ef6014f3309d278b38"),
name: Some(Arc::from("Copper Welder")),
+ section: Some(Arc::from(THE_COPPER_AGE_SKIN_PACK_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -558,6 +615,7 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
Skin {
texture_key: Arc::from("e0bae80c765f9ef3c3050dd72ea4d4bc53ae00e39c8a376a886d419abdc5dd84"),
name: Some(Arc::from("Zombie Horse Onesie")),
+ section: Some(Arc::from(MOUNTS_OF_MAYHEM_SKIN_PACK_SECTION)),
variant: MinecraftSkinVariant::Classic,
cape_id: None,
texture: Arc::from(Url::try_from(
@@ -565,5 +623,43 @@ pub static DEFAULT_SKINS: LazyLock> = LazyLock::new(|| {
).unwrap()),
source: SkinSource::Default,
is_equipped: false,
+ },
+ // Tiny Takeover skin pack
+ Skin {
+ texture_key: Arc::from("890044fb07cbca79bb9ffec4d2f15cdd1053e4b554e9a02469e9d0b271f3fdfa"),
+ name: Some(Arc::from("Baby Bee")),
+ section: Some(Arc::from(TINY_TAKEOVER_SKIN_PACK_SECTION)),
+ variant: MinecraftSkinVariant::Classic,
+ cape_id: None,
+ texture: Arc::from(Url::try_from(
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAMiklEQVR4nNVbfWwUxxV/e16fP87fNingAAaHr5oA5usPEj4shBJBlFKUtOKPtklVQaUWqUqrVopUihKJP1BLK/FPG7WiTVRFqqqkjQpSShEBDBU1BAMxUMDGGAwE7Duf8fns8/m2+s3s25vb292789kJ/UnreTPzbnbem7czb96MNcqAwQPzDa96fVYxxe+MuNaVvnpZozxgPHjZIH+TzMQ6yKLNvDb9o7za1zMxnDp827O+rT9BUwoIHOug2PB1ojAKrhNVEvlLF5jK+IimVAHrts7xrG/JYAF7X71M+SIpvIkwUYyukz/vlun/wAJg9qrwDJSV5t+8/sRbgDDz6+nllfTFfAKnngQLgLCqFfAcQE+IBVy8M9exbtmsW5NiAWs29tF/jgSlEiB81ddp+doT1H6Gpl4BTwogNFU5WEee0LHOw8wx0vY0G+D7b6Krab9FGt86hw5v8nv6EVv+/GJypiebadvWfFc/wQbHtmz1XKfTJAKTnhNUZaqOE5TEnbV3EPBbdE1KuURNiqBwiKAMt/ZYYPCNdi0SfEXzrmmeCuDOenl7KfyzSzJOpveicsC++UqDrDC/a3QGWePBAsEg89cInb147YwWG15klaMMcwLTLDSn3NZoFxmqFVjKMd8J+HjU7KOnCq0K7zbKbmCBkcISvvPrZuodTpYVNV/T1BleHcH6+q8a615bKNLd8eNWOeh563y0Zv3XDKQpCDs7UWq74p0mfDxq9tFzG02vUXZTUvFz1ZbQKrhM7ZAqRH3jfOrtvCHSwy2brHLQap36u5S2TP609pU63Wm0shXMCWjDUqrCX1+q0YF3bxG9e0vQrm0pQkDAuvIqKy1qPiPK1TKkboIiP3pBfjqq0CqvrgqOEUHHOI+RA86fni5/dcd8SOZXPvdApPGeaEobqiI4j28ebWeyIFWI3t4rWq9Jd11YZBQ1Sxpzgqi3fpNFe3brMKGj8+gkUuvFDuaqYufBo/TO7s3JRkxBuQ17m/t2fUr5wtGMJ+H3mr3gvfWFxrLnK+liq7SXzkGDesJSIbMrNdrbHvfsyKnXyowlG+pooFOjqkb5O6Y/O9FH3V2jFq/6Hqa/dXLMs/3IB8+kjI4+1/yKzVmdV4BsodsL0BFOTxztp+1baul2x5Aom9NURnvb+zI2OhaK0t27EWo9KYVtmFdEgZpASvv291n0ycztq9DCprxOO8aJKOBiazilUx8cGUhawO2BjA0uXBqg/16KiJGesel5UdZ9rNWq+/hvwTQlqO/OFUalaRDOTU7MAk4c7Rf0hs21Is3FAh511lHTxhLq7uIpSqJpYx3db4MVBNPMnumJIG8LOLhaS5vxGmYWUfc9ab7o4EA0LuiB0LiYI9TOL10uVwqtLEHGkI9o3PuFdn58HlBOPBilgmcX0Kl5tw1Yil5TQh2f9NGKl80VyAVjwTHPOSKw/abnnOBjoqpEp790GfSD3/XR/tYRoQTgnY4xUaeiqrrAqiuoJxocHCZfpWyqosrdZZ6xOpLGXxOYY1nOtMY+8ZmABqCYqYYPQkPA/e1jNL+iwHqgBKC2yGcpQaTVsg6KAl/o6jBVLy4VKRBoHqfIjQJ6YVsN3T/WKp6t360XZRhlO/+Mloiog/CYO/BAUcw/1dCFsO1JMwqOGlRTpAnh9vwxKLWkafT7K3GhjD+0xUQdg4VBCkQuFNDgQDFNqx2iFc+aG5GyiCib0TxO1WOGKz+sIRi5TVRGFv+UK+DGYPIlSdpH/aMJGjWjXVGzODouP69+cymHQiBM5cxikVZUlFJgxTgFKEL32+poVmOMPjcGSO+popmrIlTQU5iR32+UkN5TaPHr2woo3h4nfbku07m6mPjE7F9JoqywplDMBeCxoOz4vODDn8ay1B0VhM8G4IMQ+J6Rzm4uJT1YSOGbGvnnPaT+YJz0UBmN0CAN9Y6KfK78EOTfl2eR1qNJAeEqnJguZ/+wdISQhxJU4a3VIZMC5lcUUOdQQihBnQPwzCzxiafGr4mH8yoPJjWMJlKhlGCcBgcjgh4OlZJePEKjwz4KPhynaH00Z34IEigvF0Keem+GUATnUafWi1E3hbfyGaCtqyVjwJwCqgqlWbMFsCGUFGgUjBlUUkBCAaqF7GwqtJZETJCNL8rA3fn3+2nljlqRAqDHriboUnsoJ/5HtU1CQEbk8eOUvIqVK26IFMKDB3n/xqtaTo5QvykcFHEvmqAiHwnhGVzGgBAwaV4a+89Kpwn5xGBCpPAfQGP5y5U/0JAqrJvwQomfythAoFzNX828CgRHpYCY/VMhJfWZvlK1X0vjm7u2go7/9RG1vDJNCjKaoIEiH8HduXA4RM1bpePDNHK58D+mqYX+zwc5LDWR9G1yy+GQcJkhFHwFYd7/CgnvEUJCEKbBA3jxczmEB71qB00p9O8t1Gn7liqx6VFTwF6GFFDrAWHSdm+xRKdEOOFIe/FzOfN/KYjY/OlcETrUYHCq0vZ6L9oNxpVdhnHzF4ZIr+wyEBVWn1z7qjt1PvTZAN19q854ek/fhKIwiAcA/zjUm1YG5artR+4MCRp11a93Z3zfkd2HqLRExhOHoyO0+eeNNOm4+1adwZ3KFQ8PfEX8jneNKs11PNLqO0BnY3mWBZhWkK8FpEE1w4l+CuiIXQHcObf2s33XskVrjeMvVYgHdOyTxSlPrn312TuVLewdztZi2MzBz3v1seOLjUh3csGDpaAMNEYZ54ucomzj/h3iAY6+3UmFT6230tjZNdb8ADpTf3z2An1JcqcXC8tAiBe4o4FZZVYZ3FUWhM0egBU48Rsa0VNvfK4xP2hue+zxhZT3tf0p7kgLfpMX8wSerPG4zQzfZllur1MtiE0dgsD08dhNU+VnhaiKUvkxinuX6wanO6/fFzQe0GodUpx2g4a1ZLrhZq0C5as7HWdft3K1DsLArJGWNZRQ4byk780h7m/MNgwWdqg7Sin8Lem+ujFb9hv80dMhEZQdMdOPWzbRL38sNy+Hf7WJXvAn65ACb3zb+cLGlOGu8v2rkxFStgh1hs6Vn799p7sGXnVfGEIOzo59SWJTnwj/VEK/9GadMWu+nJDu3Bgi0GoKqPWcZx5ANWk7WBCYutMnkInf6ROZdIQONRhQBGhO1dFBmZPLyryqSWNyHO6Qlxwen3Ne53Pln3KEHPwAFs4pb69z66xano2D5cZveXrKUTdoL88v5VjcAxoLtHTfxPx+dBoODXcY5o0y+BB2GiYdC41RLvyJKhn8tMBhLnvA06nc5Ug8TQH5Qt04sULstMqTC78YZY7wqpHeMFH8VqojxIFRjhhnc1rsy1t6jOKSqozeo8qTKz9HeFMivZUkosQQGpFhpBBc+BA5HDNqXpXqiGQLfE58nojIT8rpchb3C9LO/znW73INxj4P+Ec2EPmnE8Xk7ZVY8QnP3+tOneBZOlfhAZws86nyRO8XqOB9Rbb1R97OLV6gT7YFqEizgCzuF7i5xW4xfqvexJaDr0sLAGIP0iwgKwU8vadPw/ocWJ278DhVZiv4Mixg+fcv02+eeV/QP7q5hNp+6/17PZtOuM3UoDvPDVgXKvhIXQUrA0oAjTkCNGL/iAg3rrLfgM7t/B8xAGx9MfJyC7zKihXQ9ssiTsB1SGNny43C8mZr+6y7vVjdCaoztdusLY7YzSN1kcfJT4kuJkMcpQM/fb44hZ4MZIoXHH0znZdjBZv3Lc9uGVSDJPaACQTiuwM4TcKDwxa+S4D7B+qdA/v9g8kA/mkDwiH9YfXfBY0HtFoH8JYZ+ZHToewUoFqDSkMZEBh3B3CHQAXKP7wkL10w7DTfP8gHaiwAKa7RgsYDWq1j4XFnmWMGWr4dsB+u2sGHreqhK5fjnPHU+UXpZ/zmCS8cG3h71t0A1SdQPD3EArYei1mp+n6vuknxBCGIV56FZiXYywEIjxNdcelRPd42vT3M9LgjwEsZ3w1gsGBOAnrVOcYDAHtMwCteADPG3QH18BSC8gEqX7lh1BQlPwNxKBtOHmdrYem9WfmeB1IR5qkw6g0yRNn6DZLOFxr+YGcGYXhHyHE+3iWyktQy5sn0CbBCnG6doKj1w8XWsbYd9rsAyENw8DOd6fw/E3z4owrPeYDL1DqmmYfBVsBwM3d7vZvwTncBkE/eAUjSKtziBuqeQa3XKEu4xQy2zfQZoZj7/QEv4BP42U9eonywouV6TvEBe5me7YvcAibnQgnP+wNTDUyIdm9RTKZK3CAtlmCWa2Et//8am4z7BfkAS2UhyViAWDptmyNeTq2rdYxKGTj5H1CWJkjdsN/kAAAAAElFTkSuQmCC"
+ ).unwrap()),
+ source: SkinSource::Default,
+ is_equipped: false,
+ },
+ Skin {
+ texture_key: Arc::from("8d0484011053097a9809f14c0301166981369b3a660150afea1e753ae7e54685"),
+ name: Some(Arc::from("Baby Axolotl")),
+ section: Some(Arc::from(TINY_TAKEOVER_SKIN_PACK_SECTION)),
+ variant: MinecraftSkinVariant::Classic,
+ cape_id: None,
+ texture: Arc::from(Url::try_from(
+ "data:image/png;base64,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"
+ ).unwrap()),
+ source: SkinSource::Default,
+ is_equipped: false,
+ },
+ // Dandelion Onesie skin pack
+ Skin {
+ texture_key: Arc::from("b240795e214270b5b864cea3cbbcbac2fae60abed5de10229a7567510713355b"),
+ name: Some(Arc::from("Dandelion Onesie")),
+ section: Some(Arc::from(TINY_TAKEOVER_SKIN_PACK_SECTION)),
+ variant: MinecraftSkinVariant::Classic,
+ cape_id: None,
+ texture: Arc::from(Url::try_from(
+ "data:image/png;base64,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"
+ ).unwrap()),
+ source: SkinSource::Default,
+ is_equipped: false,
}]
});
diff --git a/packages/app-lib/src/api/minecraft_skins/png_util.rs b/packages/app-lib/src/api/minecraft_skins/png_util.rs
index 11376e570..6af5e3b2b 100644
--- a/packages/app-lib/src/api/minecraft_skins/png_util.rs
+++ b/packages/app-lib/src/api/minecraft_skins/png_util.rs
@@ -28,6 +28,7 @@ pub async fn url_to_data_stream(
let response = INSECURE_REQWEST_CLIENT
.get(url.as_str())
.header("Accept", "image/png")
+ .timeout(std::time::Duration::from_secs(10))
.send()
.await
.and_then(|response| response.error_for_status())?;
diff --git a/packages/app-lib/src/api/profile/mod.rs b/packages/app-lib/src/api/profile/mod.rs
index 31b3a965d..04409a920 100644
--- a/packages/app-lib/src/api/profile/mod.rs
+++ b/packages/app-lib/src/api/profile/mod.rs
@@ -917,6 +917,8 @@ async fn run_credentials(
}
}
+ crate::minecraft_skins::flush_pending_skin_change().await?;
+
crate::launcher::launch_minecraft(
&java_args,
&env_args,
diff --git a/packages/app-lib/src/state/db.rs b/packages/app-lib/src/state/db.rs
index b2a180560..7b5479357 100644
--- a/packages/app-lib/src/state/db.rs
+++ b/packages/app-lib/src/state/db.rs
@@ -48,11 +48,6 @@ pub(crate) async fn connect(
async fn stale_data_cleanup(pool: &Pool) -> crate::Result<()> {
let mut tx = pool.begin().await?;
- sqlx::query!(
- "DELETE FROM default_minecraft_capes WHERE minecraft_user_uuid NOT IN (SELECT uuid FROM minecraft_users)"
- )
- .execute(&mut *tx)
- .await?;
sqlx::query!(
"DELETE FROM custom_minecraft_skins WHERE minecraft_user_uuid NOT IN (SELECT uuid FROM minecraft_users)"
)
diff --git a/packages/app-lib/src/state/minecraft_auth.rs b/packages/app-lib/src/state/minecraft_auth.rs
index d28938506..c021a239d 100644
--- a/packages/app-lib/src/state/minecraft_auth.rs
+++ b/packages/app-lib/src/state/minecraft_auth.rs
@@ -20,7 +20,6 @@ use serde_json::json;
use sha2::Digest;
use std::borrow::Cow;
use std::collections::HashMap;
-use std::collections::hash_map::Entry;
use std::future::Future;
use std::hash::{BuildHasherDefault, DefaultHasher};
use std::io;
@@ -217,6 +216,34 @@ pub(super) static PROFILE_CACHE: Mutex<
HashMap>,
> = Mutex::const_new(HashMap::with_hasher(BuildHasherDefault::new()));
+const ONLINE_PROFILE_CACHE_MAX_AGE: std::time::Duration =
+ std::time::Duration::from_secs(60);
+const ONLINE_PROFILE_LIVE_STATE_MAX_AGE: std::time::Duration =
+ std::time::Duration::from_secs(5);
+const ONLINE_PROFILE_AUTH_ERROR_BACKOFF: std::time::Duration =
+ std::time::Duration::from_secs(60);
+
+#[derive(Debug, Clone, Copy)]
+enum OnlineProfileCacheIntent {
+ NormalRead,
+ LiveStateRead,
+ RefreshFromMojang,
+}
+
+impl OnlineProfileCacheIntent {
+ fn max_age(self) -> std::time::Duration {
+ match self {
+ Self::NormalRead => ONLINE_PROFILE_CACHE_MAX_AGE,
+ Self::LiveStateRead => ONLINE_PROFILE_LIVE_STATE_MAX_AGE,
+ Self::RefreshFromMojang => std::time::Duration::ZERO,
+ }
+ }
+
+ fn can_use_stale_on_fetch_error(self) -> bool {
+ matches!(self, Self::LiveStateRead)
+ }
+}
+
impl Credentials {
/// Refreshes the authentication tokens for this user if they are expired, or
/// very close to expiration.
@@ -268,92 +295,133 @@ impl Credentials {
Ok(())
}
+ /// Returns online profile data when the cached copy is still recent enough.
#[tracing::instrument(skip(self))]
pub async fn online_profile(&self) -> Option> {
- let mut profile_cache = PROFILE_CACHE.lock().await;
+ self.online_profile_with_cache_intent(
+ OnlineProfileCacheIntent::NormalRead,
+ )
+ .await
+ }
- loop {
- match profile_cache.entry(self.offline_profile.id) {
- Entry::Occupied(entry) => {
- match entry.get() {
- ProfileCacheEntry::Hit(profile)
- if profile.is_fresh() =>
- {
- return Some(Arc::clone(profile));
- }
- ProfileCacheEntry::Hit(_) => {
- // The profile is stale, so remove it and try again
- entry.remove();
- continue;
- }
- // Auth errors must be handled with a backoff strategy because it
- // has been experimentally found that Mojang quickly rate limits
- // the profile data endpoint on repeated attempts with bad auth
- ProfileCacheEntry::AuthErrorBackoff {
- likely_expired_token,
- last_attempt,
- } if &self.access_token != likely_expired_token
- || Instant::now()
- .saturating_duration_since(*last_attempt)
- > std::time::Duration::from_secs(60) =>
- {
- entry.remove();
- continue;
- }
- ProfileCacheEntry::AuthErrorBackoff { .. } => {
- return None;
- }
+ /// Returns profile data recent enough for skin and cape state.
+ ///
+ /// Reuses a profile read from the last few seconds so opening the skins page
+ /// does not send several identical Mojang requests.
+ #[tracing::instrument(skip(self))]
+ pub async fn online_profile_fresh(&self) -> Option> {
+ self.online_profile_with_cache_intent(
+ OnlineProfileCacheIntent::LiveStateRead,
+ )
+ .await
+ }
+
+ /// Fetches the online profile from Mojang after a skin or cape change.
+ #[tracing::instrument(skip(self))]
+ pub async fn refresh_online_profile(
+ &self,
+ ) -> Option> {
+ self.online_profile_with_cache_intent(
+ OnlineProfileCacheIntent::RefreshFromMojang,
+ )
+ .await
+ }
+
+ async fn online_profile_with_cache_intent(
+ &self,
+ cache_intent: OnlineProfileCacheIntent,
+ ) -> Option> {
+ let max_age = cache_intent.max_age();
+ let stale_profile = {
+ let mut profile_cache = PROFILE_CACHE.lock().await;
+ let mut remove_cached_entry = false;
+
+ let stale_profile = if let Some(cache_entry) =
+ profile_cache.get(&self.offline_profile.id)
+ {
+ match cache_entry {
+ ProfileCacheEntry::Hit(profile)
+ if profile.is_fresh(max_age) =>
+ {
+ return Some(Arc::clone(profile));
+ }
+ ProfileCacheEntry::Hit(profile) => {
+ Some(Arc::clone(profile))
+ }
+ // Auth errors must be handled with a backoff strategy because it
+ // has been experimentally found that Mojang quickly rate limits
+ // the profile data endpoint on repeated attempts with bad auth
+ ProfileCacheEntry::AuthErrorBackoff {
+ likely_expired_token,
+ last_attempt,
+ } if &self.access_token != likely_expired_token
+ || Instant::now()
+ .saturating_duration_since(*last_attempt)
+ > ONLINE_PROFILE_AUTH_ERROR_BACKOFF =>
+ {
+ remove_cached_entry = true;
+ None
+ }
+ ProfileCacheEntry::AuthErrorBackoff { .. } => {
+ return None;
}
}
- Entry::Vacant(entry) => {
- match minecraft_profile(&self.access_token).await {
- Ok(profile) => {
- let profile = Arc::new(profile);
- let cache_entry =
- ProfileCacheEntry::Hit(Arc::clone(&profile));
+ } else {
+ None
+ };
- // When fetching a profile for the first time, the player UUID may
- // be unknown (i.e., set to a dummy value), so make sure we don't
- // cache it in the wrong place
- if entry.key() != &profile.id {
- profile_cache.insert(profile.id, cache_entry);
- } else {
- entry.insert(cache_entry);
- }
+ if remove_cached_entry {
+ profile_cache.remove(&self.offline_profile.id);
+ }
- return Some(profile);
- }
- Err(
- err @ MinecraftAuthenticationError::DeserializeResponse {
- status_code: StatusCode::UNAUTHORIZED,
- ..
- },
- ) => {
- tracing::warn!(
- "Failed to fetch online profile for UUID {} likely due to stale credentials, backing off: {err}",
- self.offline_profile.id
- );
+ stale_profile
+ };
- // We have to assume the player UUID key we have is correct here, which
- // should always be the case assuming a non-adversarial server. In any
- // case, any cache poisoning is inconsequential due to the entry expiration
- // and the fact that we use at most one single dummy UUID
- entry.insert(ProfileCacheEntry::AuthErrorBackoff {
- likely_expired_token: self.access_token.clone(),
- last_attempt: Instant::now(),
- });
+ match minecraft_profile(&self.access_token).await {
+ Ok(profile) => {
+ let profile = Arc::new(profile);
+ let cache_entry = ProfileCacheEntry::Hit(Arc::clone(&profile));
- return None;
- }
- Err(err) => {
- tracing::warn!(
- "Failed to fetch online profile for UUID {}: {err}",
- self.offline_profile.id
- );
+ let mut profile_cache = PROFILE_CACHE.lock().await;
+ if self.offline_profile.id != profile.id {
+ profile_cache.remove(&self.offline_profile.id);
+ }
+ profile_cache.insert(profile.id, cache_entry);
- return None;
- }
- }
+ Some(profile)
+ }
+ Err(
+ err @ MinecraftAuthenticationError::DeserializeResponse {
+ status_code: StatusCode::UNAUTHORIZED,
+ ..
+ },
+ ) => {
+ tracing::warn!(
+ "Failed to fetch online profile for UUID {} likely due to stale credentials, backing off: {err}",
+ self.offline_profile.id
+ );
+
+ let mut profile_cache = PROFILE_CACHE.lock().await;
+ profile_cache.insert(
+ self.offline_profile.id,
+ ProfileCacheEntry::AuthErrorBackoff {
+ likely_expired_token: self.access_token.clone(),
+ last_attempt: Instant::now(),
+ },
+ );
+
+ None
+ }
+ Err(err) => {
+ tracing::warn!(
+ "Failed to fetch online profile for UUID {}: {err}",
+ self.offline_profile.id
+ );
+
+ if cache_intent.can_use_stale_on_fetch_error() {
+ stale_profile
+ } else {
+ None
}
}
}
@@ -717,6 +785,8 @@ impl DeviceTokenPair {
const MICROSOFT_CLIENT_ID: &str = "00000000402b5328";
const AUTH_REPLY_URL: &str = "https://login.live.com/oauth20_desktop.srf";
const REQUESTED_SCOPE: &str = "service::user.auth.xboxlive.com::MBI_SSL";
+pub const MINECRAFT_SERVICES_USER_AGENT: &str =
+ "Modrinth App (support@modrinth.com; https://modrinth.com/app)";
pub struct RequestWithDate {
pub date: DateTime,
@@ -1051,6 +1121,7 @@ async fn minecraft_token(
INSECURE_REQWEST_CLIENT
.post("https://api.minecraftservices.com/launcher/login")
.header("Accept", "application/json")
+ .header("User-Agent", MINECRAFT_SERVICES_USER_AGENT)
.json(&json!({
"platform": "PC_LAUNCHER",
"xtoken": format!("XBL3.0 x={uhs};{token}"),
@@ -1224,10 +1295,10 @@ impl MinecraftProfile {
/// from the Mojang API: the vanilla launcher was seen refreshing profile
/// data every 60 seconds when re-entering the skin selection screen, and
/// external applications may change this data at any time.
- fn is_fresh(&self) -> bool {
+ fn is_fresh(&self, max_age: std::time::Duration) -> bool {
self.fetch_time.is_some_and(|last_profile_fetch_time| {
Instant::now().saturating_duration_since(last_profile_fetch_time)
- < std::time::Duration::from_secs(60)
+ < max_age
})
}
@@ -1279,6 +1350,7 @@ async fn minecraft_profile(
INSECURE_REQWEST_CLIENT
.get("https://api.minecraftservices.com/minecraft/profile")
.header("Accept", "application/json")
+ .header("User-Agent", MINECRAFT_SERVICES_USER_AGENT)
.bearer_auth(token)
// Profiles may be refreshed periodically in response to user actions,
// so we want each refresh to be fast
@@ -1327,12 +1399,13 @@ async fn minecraft_entitlements(
token: &str,
) -> Result {
let res = auth_retry(|| {
- INSECURE_REQWEST_CLIENT
- .get(format!("https://api.minecraftservices.com/entitlements/license?requestId={}", Uuid::new_v4()))
- .header("Accept", "application/json")
- .bearer_auth(token)
- .send()
- })
+ INSECURE_REQWEST_CLIENT
+ .get(format!("https://api.minecraftservices.com/entitlements/license?requestId={}", Uuid::new_v4()))
+ .header("Accept", "application/json")
+ .header("User-Agent", MINECRAFT_SERVICES_USER_AGENT)
+ .bearer_auth(token)
+ .send()
+ })
.await.map_err(|source| MinecraftAuthenticationError::Request { source, step: MinecraftAuthStep::MinecraftEntitlements })?;
let status = res.status();
diff --git a/packages/app-lib/src/state/minecraft_skins/mod.rs b/packages/app-lib/src/state/minecraft_skins/mod.rs
index a5baad20c..80a9bbe9d 100644
--- a/packages/app-lib/src/state/minecraft_skins/mod.rs
+++ b/packages/app-lib/src/state/minecraft_skins/mod.rs
@@ -5,81 +5,31 @@ use super::MinecraftSkinVariant;
pub mod mojang_api;
-/// Represents the default cape for a Minecraft player.
-#[derive(Debug, Clone)]
-pub struct DefaultMinecraftCape {
- /// The UUID of a cape for a Minecraft player, which comes from its profile.
- ///
- /// This UUID may or may not be different for every player, even if they refer to the same cape.
- pub id: Uuid,
-}
-
-impl DefaultMinecraftCape {
- pub async fn set(
- minecraft_user_id: Uuid,
- cape_id: Uuid,
- db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>,
- ) -> crate::Result<()> {
- let minecraft_user_id = minecraft_user_id.as_hyphenated();
- let cape_id = cape_id.as_hyphenated();
-
- sqlx::query!(
- "INSERT OR REPLACE INTO default_minecraft_capes (minecraft_user_uuid, id) VALUES (?, ?)",
- minecraft_user_id, cape_id
- )
- .execute(&mut *db.acquire().await?)
- .await?;
-
- Ok(())
- }
-
- pub async fn get(
- minecraft_user_id: Uuid,
- db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>,
- ) -> crate::Result