diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index c841f5fc9..5036efc8a 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -330,6 +330,7 @@ async function setupApp() { locale, telemetry, collapsed_navigation, + hide_nametag_skins_page, advanced_rendering, onboarded, default_page, @@ -360,6 +361,7 @@ async function setupApp() { themeStore.setThemeState(theme) themeStore.collapsedNavigation = collapsed_navigation themeStore.advancedRendering = advanced_rendering + themeStore.hideNametagSkinsPage = hide_nametag_skins_page themeStore.toggleSidebar = toggle_sidebar themeStore.devMode = developer_mode themeStore.featureFlags = feature_flags @@ -1227,7 +1229,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload) > - +

{{ formatMessage(messages.hideNametagDescription) }}

- +
diff --git a/apps/app-frontend/src/components/ui/skin/EditSkinModal.vue b/apps/app-frontend/src/components/ui/skin/EditSkinModal.vue index 5b3a4485c..33485ddb6 100644 --- a/apps/app-frontend/src/components/ui/skin/EditSkinModal.vue +++ b/apps/app-frontend/src/components/ui/skin/EditSkinModal.vue @@ -1,149 +1,242 @@ 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 @@ - - 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 @@ + + + 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 @@ 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,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" + ).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> { - let minecraft_user_id = minecraft_user_id.as_hyphenated(); - - Ok(sqlx::query_as!( - Self, - "SELECT id AS 'id: Hyphenated' FROM default_minecraft_capes WHERE minecraft_user_uuid = ?", - minecraft_user_id - ) - .fetch_optional(&mut *db.acquire().await?) - .await?) - } - - pub async fn remove( - minecraft_user_id: Uuid, - db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>, - ) -> crate::Result<()> { - let minecraft_user_id = minecraft_user_id.as_hyphenated(); - - sqlx::query!( - "DELETE FROM default_minecraft_capes WHERE minecraft_user_uuid = ?", - minecraft_user_id - ) - .execute(&mut *db.acquire().await?) - .await?; - - Ok(()) - } -} - -/// Represents a custom skin for a Minecraft player. +/// Represents a saved skin row for a Minecraft player. +/// +/// The same player and `texture_key` always point to the same saved skin. +/// Changing the model variant or cape updates that saved skin instead of +/// creating a second copy. Bundled default skins with a cape are also stored +/// here so the cape can stay associated with the default skin card. #[derive(Debug, Clone)] pub struct CustomMinecraftSkin { - /// The key for the texture skin, which is akin to a hash that identifies it. + /// The key for the skin texture, which is akin to a hash that identifies it. pub texture_key: String, /// The variant of the skin model. pub variant: MinecraftSkinVariant, /// The UUID of the cape that this skin uses, which should match one of the /// cape UUIDs the player has in its profile. /// - /// 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`, the skin is saved without a cape. pub cape_id: Option, } +struct CustomMinecraftSkinRow { + texture_key: String, + variant: MinecraftSkinVariant, + cape_id: Option, +} + impl CustomMinecraftSkin { pub async fn add( minecraft_user_id: Uuid, @@ -95,24 +45,59 @@ impl CustomMinecraftSkin { let mut transaction = db.begin().await?; sqlx::query!( - "INSERT OR REPLACE INTO custom_minecraft_skin_textures (texture_key, texture) VALUES (?, ?)", - texture_key, texture + "DELETE FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ?", + minecraft_user_id, + texture_key ) .execute(&mut *transaction) .await?; sqlx::query!( - "INSERT OR REPLACE INTO custom_minecraft_skins (minecraft_user_uuid, texture_key, variant, cape_id) VALUES (?, ?, ?, ?)", - minecraft_user_id, texture_key, variant, cape_id - ) - .execute(&mut *transaction) - .await?; + "INSERT OR REPLACE INTO custom_minecraft_skin_textures (texture_key, texture) VALUES (?, ?)", + texture_key, texture + ) + .execute(&mut *transaction) + .await?; + + sqlx::query!( + "INSERT OR REPLACE INTO custom_minecraft_skins (minecraft_user_uuid, texture_key, variant, cape_id) VALUES (?, ?, ?, ?)", + minecraft_user_id, texture_key, variant, cape_id + ) + .execute(&mut *transaction) + .await?; transaction.commit().await?; Ok(()) } + pub async fn get_by_texture( + minecraft_user_id: Uuid, + texture_key: &str, + db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>, + ) -> crate::Result> { + let minecraft_user_id = minecraft_user_id.as_hyphenated(); + + sqlx::query_as!( + CustomMinecraftSkinRow, + "SELECT texture_key, variant AS 'variant: MinecraftSkinVariant', cape_id AS 'cape_id: Hyphenated' \ + FROM custom_minecraft_skins \ + WHERE minecraft_user_uuid = ? AND texture_key = ?", + minecraft_user_id, + texture_key + ) + .fetch_optional(&mut *db.acquire().await?) + .await? + .map(|row| { + Ok(Self { + texture_key: row.texture_key, + variant: row.variant, + cape_id: row.cape_id.map(Uuid::from), + }) + }) + .transpose() + } + pub async fn get_many( minecraft_user_id: Uuid, offset: u32, @@ -165,12 +150,11 @@ impl CustomMinecraftSkin { db: impl sqlx::Acquire<'_, Database = sqlx::Sqlite>, ) -> crate::Result<()> { let minecraft_user_id = minecraft_user_id.as_hyphenated(); - let cape_id = self.cape_id.map(|id| id.hyphenated()); sqlx::query!( - "DELETE FROM custom_minecraft_skins \ - WHERE minecraft_user_uuid = ? AND texture_key = ? AND variant = ? AND cape_id IS ?", - minecraft_user_id, self.texture_key, self.variant, cape_id + "DELETE FROM custom_minecraft_skins WHERE minecraft_user_uuid = ? AND texture_key = ?", + minecraft_user_id, + self.texture_key ) .execute(&mut *db.acquire().await?) .await?; diff --git a/packages/app-lib/src/state/minecraft_skins/mojang_api.rs b/packages/app-lib/src/state/minecraft_skins/mojang_api.rs index 4e25711f1..9e8953770 100644 --- a/packages/app-lib/src/state/minecraft_skins/mojang_api.rs +++ b/packages/app-lib/src/state/minecraft_skins/mojang_api.rs @@ -10,7 +10,10 @@ use super::MinecraftSkinVariant; use crate::{ ErrorKind, data::Credentials, - state::{MinecraftProfile, PROFILE_CACHE, ProfileCacheEntry}, + state::{ + MINECRAFT_SERVICES_USER_AGENT, MinecraftProfile, PROFILE_CACHE, + ProfileCacheEntry, + }, util::fetch::INSECURE_REQWEST_CLIENT, }; @@ -24,12 +27,13 @@ impl MinecraftCapeOperation { ) -> crate::Result<()> { update_profile_cache_from_response( INSECURE_REQWEST_CLIENT - .put("https://api.minecraftservices.com/minecraft/profile/capes/active") - .header("Content-Type", "application/json; charset=utf-8") - .header("Accept", "application/json") - .bearer_auth(&credentials.access_token) - .json(&json!({ - "capeId": cape_id.hyphenated(), + .put("https://api.minecraftservices.com/minecraft/profile/capes/active") + .header("Content-Type", "application/json; charset=utf-8") + .header("Accept", "application/json") + .header("User-Agent", MINECRAFT_SERVICES_USER_AGENT) + .bearer_auth(&credentials.access_token) + .json(&json!({ + "capeId": cape_id.hyphenated(), })) .send() .await @@ -42,12 +46,13 @@ impl MinecraftCapeOperation { pub async fn unequip_any(credentials: &Credentials) -> crate::Result<()> { update_profile_cache_from_response( - INSECURE_REQWEST_CLIENT - .delete("https://api.minecraftservices.com/minecraft/profile/capes/active") - .header("Accept", "application/json") - .bearer_auth(&credentials.access_token) - .send() - .await + INSECURE_REQWEST_CLIENT + .delete("https://api.minecraftservices.com/minecraft/profile/capes/active") + .header("Accept", "application/json") + .header("User-Agent", MINECRAFT_SERVICES_USER_AGENT) + .bearer_auth(&credentials.access_token) + .send() + .await .and_then(|response| response.error_for_status())? ) .await; @@ -64,7 +69,7 @@ impl MinecraftSkinOperation { credentials: &Credentials, texture: TextureStream, variant: MinecraftSkinVariant, - ) -> crate::Result<()> + ) -> crate::Result>> where TextureStream: TryStream + Send + 'static, TextureStream::Error: Into>, @@ -91,12 +96,13 @@ impl MinecraftSkinOperation { .file_name("skin.png"), ); - update_profile_cache_from_response( + let profile = update_profile_cache_from_response( INSECURE_REQWEST_CLIENT .post( "https://api.minecraftservices.com/minecraft/profile/skins", ) .header("Accept", "application/json") + .header("User-Agent", MINECRAFT_SERVICES_USER_AGENT) .bearer_auth(&credentials.access_token) .multipart(form) .send() @@ -105,17 +111,18 @@ impl MinecraftSkinOperation { ) .await; - Ok(()) + Ok(profile) } pub async fn unequip_any(credentials: &Credentials) -> crate::Result<()> { update_profile_cache_from_response( - INSECURE_REQWEST_CLIENT - .delete("https://api.minecraftservices.com/minecraft/profile/skins/active") - .header("Accept", "application/json") - .bearer_auth(&credentials.access_token) - .send() - .await + INSECURE_REQWEST_CLIENT + .delete("https://api.minecraftservices.com/minecraft/profile/skins/active") + .header("Accept", "application/json") + .header("User-Agent", MINECRAFT_SERVICES_USER_AGENT) + .bearer_auth(&credentials.access_token) + .send() + .await .and_then(|response| response.error_for_status())? ) .await; @@ -124,19 +131,24 @@ impl MinecraftSkinOperation { } } -async fn update_profile_cache_from_response(response: reqwest::Response) { +async fn update_profile_cache_from_response( + response: reqwest::Response, +) -> Option> { let Some(mut profile) = response.json::().await.ok() else { tracing::warn!( "Failed to parse player profile from skin or cape operation response, not updating profile cache" ); - return; + return None; }; profile.fetch_time = Some(Instant::now()); + let profile = Arc::new(profile); PROFILE_CACHE .lock() .await - .insert(profile.id, ProfileCacheEntry::Hit(Arc::new(profile))); + .insert(profile.id, ProfileCacheEntry::Hit(Arc::clone(&profile))); + + Some(profile) } diff --git a/packages/assets/generated-icons.ts b/packages/assets/generated-icons.ts index c5cb8747f..2bd878b55 100644 --- a/packages/assets/generated-icons.ts +++ b/packages/assets/generated-icons.ts @@ -373,6 +373,7 @@ import _TrashExclamationIcon from './icons/trash-exclamation.svg?component' import _TriangleAlertIcon from './icons/triangle-alert.svg?component' import _UnderlineIcon from './icons/underline.svg?component' import _UndoIcon from './icons/undo.svg?component' +import _UnfoldHorizontalIcon from './icons/unfold-horizontal.svg?component' import _UnknownIcon from './icons/unknown.svg?component' import _UnknownDonationIcon from './icons/unknown-donation.svg?component' import _UnlinkIcon from './icons/unlink.svg?component' @@ -765,6 +766,7 @@ export const TrashExclamationIcon = _TrashExclamationIcon export const TriangleAlertIcon = _TriangleAlertIcon export const UnderlineIcon = _UnderlineIcon export const UndoIcon = _UndoIcon +export const UnfoldHorizontalIcon = _UnfoldHorizontalIcon export const UnknownIcon = _UnknownIcon export const UnknownDonationIcon = _UnknownDonationIcon export const UnlinkIcon = _UnlinkIcon diff --git a/packages/assets/icons/unfold-horizontal.svg b/packages/assets/icons/unfold-horizontal.svg new file mode 100644 index 000000000..b8c6d4dc8 --- /dev/null +++ b/packages/assets/icons/unfold-horizontal.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + diff --git a/packages/ui/CLAUDE.md b/packages/ui/CLAUDE.md index db030d9b5..69c0ee040 100644 --- a/packages/ui/CLAUDE.md +++ b/packages/ui/CLAUDE.md @@ -70,7 +70,7 @@ CSS custom properties are defined in `packages/assets/styles/variables.scss` wit ## Storybook -When modifying a component in `src/components/`, you must also update its corresponding Storybook story in `src/stories/` to reflect the changes. If a story file doesn't exist yet, create one. Stories should cover the component's key states and variants. +When modifying a component in `src/components/`, you must also update its corresponding Storybook story in `src/stories/` to reflect the changes. If a story file doesn't exist yet, create one. Stories should cover the component's key states and variants - do not make or modify a storybook unless the user asks for it or skip if it's incredibly obvious one should not be needed (e.g minor changes or styling changes DO NOT need a storybook edit) ## Dependency Injection diff --git a/packages/ui/src/components/modal/NewModal.vue b/packages/ui/src/components/modal/NewModal.vue index 43dbc255c..b1ff2a89f 100644 --- a/packages/ui/src/components/modal/NewModal.vue +++ b/packages/ui/src/components/modal/NewModal.vue @@ -84,6 +84,7 @@
- - diff --git a/packages/ui/src/components/skin/SkinButton.vue b/packages/ui/src/components/skin/SkinButton.vue index 14fb06ae0..35bf60887 100644 --- a/packages/ui/src/components/skin/SkinButton.vue +++ b/packages/ui/src/components/skin/SkinButton.vue @@ -1,5 +1,5 @@