1558 lines
49 KiB
Diff
1558 lines
49 KiB
Diff
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
|
|
From: Modrinth Enhanced <patches@modrinth-enhanced.invalid>
|
|
Date: Tue, 15 Sep 2026 11:27:41 +0200
|
|
Subject: [PATCH] Browse skins from Ely.by, NameMC, laby.net and crafty.gg
|
|
|
|
The skin page has a Browse tab next to the account's own skins. Picking a
|
|
skin there previews it on the big model, and Add skin sends it through
|
|
the same dialog a skin file goes through, so choosing the model, the
|
|
cape and an Ely.by account's upload all work as they already did.
|
|
|
|
Ely.by's catalogue is browsed in the app: sort by best or newest, filter
|
|
by format, category, tags and uploader, and order what is loaded by
|
|
likes, wearers or views, all of which show on every tile. Its catalogue
|
|
has no text search, so players can be searched by name instead, showing
|
|
the skin each wears. Ely.by's robots.txt invites exactly this.
|
|
|
|
NameMC, laby.net and crafty.gg are not browsed from the app. NameMC's
|
|
skin lists sit behind a Cloudflare bot check, laby.net has no public
|
|
API, and crafty.gg's terms forbid scraping. They open in a window of the
|
|
app to browse as usual, with each site's own search, filters and counts,
|
|
and the launcher only reads which page that window is on: the skin page
|
|
open there is fetched and previewed, from NameMC's image host, laby.net's
|
|
texture endpoint or crafty.gg's lookup for that one skin.
|
|
---
|
|
.../src/components/ui/skin/SkinBrowser.vue | 606 ++++++++++++++++++
|
|
apps/app-frontend/src/helpers/skin-browser.ts | 100 +++
|
|
apps/app-frontend/src/pages/Skins.vue | 115 +++-
|
|
apps/app/build.rs | 13 +
|
|
apps/app/capabilities/plugins.json | 3 +-
|
|
apps/app/src/api/mod.rs | 1 +
|
|
apps/app/src/api/skin_browser.rs | 122 ++++
|
|
apps/app/src/main.rs | 1 +
|
|
packages/app-lib/src/api/mod.rs | 1 +
|
|
packages/app-lib/src/api/skin_browser.rs | 373 +++++++++++
|
|
10 files changed, 1323 insertions(+), 12 deletions(-)
|
|
create mode 100644 apps/app-frontend/src/components/ui/skin/SkinBrowser.vue
|
|
create mode 100644 apps/app-frontend/src/helpers/skin-browser.ts
|
|
create mode 100644 apps/app/src/api/skin_browser.rs
|
|
create mode 100644 packages/app-lib/src/api/skin_browser.rs
|
|
|
|
diff --git a/apps/app-frontend/src/components/ui/skin/SkinBrowser.vue b/apps/app-frontend/src/components/ui/skin/SkinBrowser.vue
|
|
new file mode 100644
|
|
index 0000000..3d92c68
|
|
--- /dev/null
|
|
+++ b/apps/app-frontend/src/components/ui/skin/SkinBrowser.vue
|
|
@@ -0,0 +1,606 @@
|
|
+<!--
|
|
+ Finding skins elsewhere, next to the account's own.
|
|
+
|
|
+ Ely.by's catalogue is browsed right here. NameMC, laby.net and crafty.gg are
|
|
+ not: their lists are bot-protected or not meant for other programs, so they
|
|
+ open in a window to browse as usual, and the skin page open there is what
|
|
+ gets previewed. Picking a skin only previews it; the skin page adds it.
|
|
+-->
|
|
+<template>
|
|
+ <div class="flex flex-col gap-4">
|
|
+ <Chips v-model="source" :items="SOURCES" :format-label="sourceLabel" size="small" />
|
|
+
|
|
+ <template v-if="source === 'ely'">
|
|
+ <Chips v-model="elyMode" :items="ELY_MODES" :format-label="elyModeLabel" size="small" />
|
|
+
|
|
+ <div v-if="elyMode === 'catalogue'" class="flex flex-wrap items-center gap-2">
|
|
+ <Combobox
|
|
+ v-model="sort"
|
|
+ class="w-max"
|
|
+ :options="sortOptions"
|
|
+ :show-icon-in-selected="false"
|
|
+ dropdown-min-width="160px"
|
|
+ >
|
|
+ <template #prefix>
|
|
+ <ArrowUpDownIcon class="size-5 text-primary" />
|
|
+ </template>
|
|
+ <template #selected="{ label }">
|
|
+ <span>{{ label }}</span>
|
|
+ </template>
|
|
+ </Combobox>
|
|
+ <Combobox
|
|
+ v-model="order"
|
|
+ class="w-max"
|
|
+ :options="orderOptions"
|
|
+ :show-icon-in-selected="false"
|
|
+ dropdown-min-width="160px"
|
|
+ >
|
|
+ <template #prefix>
|
|
+ <HeartIcon class="size-5 text-primary" />
|
|
+ </template>
|
|
+ <template #selected="{ label }">
|
|
+ <span>{{ label }}</span>
|
|
+ </template>
|
|
+ </Combobox>
|
|
+ <Combobox
|
|
+ v-model="model"
|
|
+ class="w-max"
|
|
+ :options="modelOptions"
|
|
+ :show-icon-in-selected="false"
|
|
+ dropdown-min-width="160px"
|
|
+ >
|
|
+ <template #prefix>
|
|
+ <UserIcon class="size-5 text-primary" />
|
|
+ </template>
|
|
+ <template #selected="{ label }">
|
|
+ <span>{{ label }}</span>
|
|
+ </template>
|
|
+ </Combobox>
|
|
+ <Combobox
|
|
+ v-model="kind"
|
|
+ class="w-max"
|
|
+ :options="kindOptions"
|
|
+ :show-icon-in-selected="false"
|
|
+ :max-height="320"
|
|
+ dropdown-min-width="160px"
|
|
+ >
|
|
+ <template #prefix>
|
|
+ <FilterIcon class="size-5 text-primary" />
|
|
+ </template>
|
|
+ <template #selected="{ label }">
|
|
+ <span>{{ label }}</span>
|
|
+ </template>
|
|
+ </Combobox>
|
|
+ <Input
|
|
+ v-model="tagsText"
|
|
+ type="search"
|
|
+ :icon="TagIcon"
|
|
+ :placeholder="formatMessage(messages.tagsPlaceholder)"
|
|
+ wrapper-class="w-52"
|
|
+ />
|
|
+ <Input
|
|
+ v-model="uploader"
|
|
+ type="search"
|
|
+ :icon="UserIcon"
|
|
+ :placeholder="formatMessage(messages.uploaderPlaceholder)"
|
|
+ wrapper-class="w-40"
|
|
+ />
|
|
+ </div>
|
|
+ <Input
|
|
+ v-else
|
|
+ v-model="playerTerm"
|
|
+ type="search"
|
|
+ :icon="SearchIcon"
|
|
+ :placeholder="formatMessage(messages.playerPlaceholder)"
|
|
+ wrapper-class="w-full"
|
|
+ />
|
|
+
|
|
+ <div
|
|
+ v-if="tiles.length"
|
|
+ class="grid w-full grid-cols-3 gap-3 min-[1300px]:grid-cols-4 min-[1750px]:grid-cols-5 min-[2050px]:grid-cols-6"
|
|
+ >
|
|
+ <div v-for="tile in tiles" :key="tile.key" class="flex min-w-0 flex-col gap-1.5">
|
|
+ <BakedSkinButton
|
|
+ v-if="tile.skin"
|
|
+ class="aspect-[31/40] w-full min-w-0 box-border rounded-[20px]"
|
|
+ :skin="tile.skin"
|
|
+ :capes="[]"
|
|
+ :selected="pickedKey === tile.key"
|
|
+ :tooltip="tile.title || undefined"
|
|
+ @select="pick(tile)"
|
|
+ />
|
|
+ <div v-else class="aspect-[31/40] w-full animate-pulse rounded-[20px] bg-surface-3" />
|
|
+ <div
|
|
+ class="flex min-w-0 items-center justify-center gap-3 text-sm font-medium text-secondary"
|
|
+ >
|
|
+ <template v-if="tile.stats">
|
|
+ <span v-tooltip="formatMessage(messages.likes)" class="flex items-center gap-1">
|
|
+ <HeartIcon class="size-4" />{{ compact(tile.stats.likes) }}
|
|
+ </span>
|
|
+ <span v-tooltip="formatMessage(messages.wearers)" class="flex items-center gap-1">
|
|
+ <UsersIcon class="size-4" />{{ compact(tile.stats.wearers) }}
|
|
+ </span>
|
|
+ <span v-tooltip="formatMessage(messages.views)" class="flex items-center gap-1">
|
|
+ <EyeIcon class="size-4" />{{ compact(tile.stats.views) }}
|
|
+ </span>
|
|
+ </template>
|
|
+ <span v-else class="truncate">{{ tile.title }}</span>
|
|
+ </div>
|
|
+ </div>
|
|
+ </div>
|
|
+ <p v-else-if="!loading" class="m-0 text-secondary">
|
|
+ {{
|
|
+ formatMessage(
|
|
+ elyMode === 'players' && playerTerm.trim().length < 3
|
|
+ ? messages.playerHint
|
|
+ : messages.nothingFound,
|
|
+ )
|
|
+ }}
|
|
+ </p>
|
|
+
|
|
+ <div class="flex justify-center">
|
|
+ <SpinnerIcon v-if="loading" class="size-6 animate-spin" />
|
|
+ <Button v-else-if="elyMode === 'catalogue' && hasMore" @click="loadMore">
|
|
+ {{ formatMessage(messages.loadMore) }}
|
|
+ </Button>
|
|
+ </div>
|
|
+ </template>
|
|
+
|
|
+ <template v-else>
|
|
+ <p class="m-0 text-secondary">
|
|
+ {{ formatMessage(messages.siteExplanation, { site: sourceLabel(source) }) }}
|
|
+ </p>
|
|
+ <div>
|
|
+ <Button type="colored" color="brand" @click="openSite">
|
|
+ <ExternalIcon />
|
|
+ {{ formatMessage(messages.openSite, { site: sourceLabel(source) }) }}
|
|
+ </Button>
|
|
+ </div>
|
|
+
|
|
+ <div v-if="siteTile?.skin" class="flex items-center gap-4">
|
|
+ <BakedSkinButton
|
|
+ class="aspect-[31/40] w-40 min-w-0 box-border rounded-[20px]"
|
|
+ :skin="siteTile.skin"
|
|
+ :capes="[]"
|
|
+ :selected="pickedKey === siteTile.key"
|
|
+ @select="pick(siteTile)"
|
|
+ />
|
|
+ <div class="flex min-w-0 flex-col gap-2">
|
|
+ <span class="font-semibold text-contrast">{{ siteTile.title }}</span>
|
|
+ <div v-if="siteTile.stats" class="flex flex-col gap-1 text-sm text-secondary">
|
|
+ <span class="flex items-center gap-1">
|
|
+ <HeartIcon class="size-4" />
|
|
+ {{ formatMessage(messages.likes) }}: {{ compact(siteTile.stats.likes) }}
|
|
+ </span>
|
|
+ <span class="flex items-center gap-1">
|
|
+ <UsersIcon class="size-4" />
|
|
+ {{ formatMessage(messages.wearers) }}: {{ compact(siteTile.stats.wearers) }}
|
|
+ </span>
|
|
+ <span class="flex items-center gap-1">
|
|
+ <EyeIcon class="size-4" />
|
|
+ {{ formatMessage(messages.views) }}: {{ compact(siteTile.stats.views) }}
|
|
+ </span>
|
|
+ </div>
|
|
+ </div>
|
|
+ </div>
|
|
+ <SpinnerIcon v-else-if="siteLoading" class="size-6 animate-spin" />
|
|
+ <p v-else class="m-0 text-secondary">{{ formatMessage(messages.siteWaiting) }}</p>
|
|
+ </template>
|
|
+ </div>
|
|
+</template>
|
|
+
|
|
+<script setup lang="ts">
|
|
+import {
|
|
+ ArrowUpDownIcon,
|
|
+ ExternalIcon,
|
|
+ EyeIcon,
|
|
+ FilterIcon,
|
|
+ HeartIcon,
|
|
+ SearchIcon,
|
|
+ SpinnerIcon,
|
|
+ TagIcon,
|
|
+ UserIcon,
|
|
+ UsersIcon,
|
|
+} from '@modrinth/assets'
|
|
+import {
|
|
+ Button,
|
|
+ Chips,
|
|
+ Combobox,
|
|
+ type ComboboxOption,
|
|
+ defineMessages,
|
|
+ injectNotificationManager,
|
|
+ Input,
|
|
+ useVIntl,
|
|
+} from '@modrinth/ui'
|
|
+import { arrayBufferToBase64 } from '@modrinth/utils'
|
|
+import type { UnlistenFn } from '@tauri-apps/api/event'
|
|
+import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
|
+
|
|
+import BakedSkinButton from '@/components/ui/skin/BakedSkinButton.vue'
|
|
+import { getElyTexture } from '@/helpers/ely_skins'
|
|
+import {
|
|
+ ELY_KINDS,
|
|
+ type ElyModel,
|
|
+ type ElySort,
|
|
+ getElyCatalogue,
|
|
+ getSiteSkin,
|
|
+ onSkinSitePage,
|
|
+ openSkinSite,
|
|
+ searchElyPlayers,
|
|
+ type SitePage,
|
|
+ type SkinBrowserPick,
|
|
+ type SkinSite,
|
|
+} from '@/helpers/skin-browser'
|
|
+import { determineModelType, type Skin, type SkinModel } from '@/helpers/skins'
|
|
+
|
|
+const emit = defineEmits<{
|
|
+ pick: [pick: SkinBrowserPick | null]
|
|
+}>()
|
|
+
|
|
+const { formatMessage } = useVIntl()
|
|
+const { handleError } = injectNotificationManager()
|
|
+
|
|
+type Source = 'ely' | SkinSite
|
|
+type ElyMode = 'catalogue' | 'players'
|
|
+type Order = 'site' | 'likes' | 'wearers' | 'views'
|
|
+
|
|
+interface Stats {
|
|
+ likes: number
|
|
+ wearers: number
|
|
+ views: number
|
|
+}
|
|
+
|
|
+interface Entry {
|
|
+ key: string
|
|
+ title: string
|
|
+ url: string
|
|
+ variant: SkinModel
|
|
+ stats?: Stats
|
|
+}
|
|
+
|
|
+interface Tile extends Entry {
|
|
+ skin?: Skin
|
|
+}
|
|
+
|
|
+const SOURCES: Source[] = ['ely', 'namemc', 'laby', 'crafty']
|
|
+const ELY_MODES: ElyMode[] = ['catalogue', 'players']
|
|
+const SITE_NAMES: Record<Source, string> = {
|
|
+ ely: 'Ely.by',
|
|
+ namemc: 'NameMC',
|
|
+ laby: 'laby.net',
|
|
+ crafty: 'crafty.gg',
|
|
+}
|
|
+
|
|
+const messages = defineMessages({
|
|
+ catalogue: { id: 'app.skins.browse.ely.catalogue', defaultMessage: 'Catalogue' },
|
|
+ players: { id: 'app.skins.browse.ely.players', defaultMessage: 'Players' },
|
|
+ sortBest: { id: 'app.skins.browse.sort.best', defaultMessage: 'Best' },
|
|
+ sortNew: { id: 'app.skins.browse.sort.new', defaultMessage: 'Newest' },
|
|
+ orderSite: { id: 'app.skins.browse.order.site', defaultMessage: 'As loaded' },
|
|
+ orderLikes: { id: 'app.skins.browse.order.likes', defaultMessage: 'Most liked' },
|
|
+ orderWearers: { id: 'app.skins.browse.order.wearers', defaultMessage: 'Most worn' },
|
|
+ orderViews: { id: 'app.skins.browse.order.views', defaultMessage: 'Most viewed' },
|
|
+ modelAny: { id: 'app.skins.browse.model.any', defaultMessage: 'Any format' },
|
|
+ modelOld: { id: 'app.skins.browse.model.old', defaultMessage: 'Old format' },
|
|
+ modelNew: { id: 'app.skins.browse.model.new', defaultMessage: 'New format' },
|
|
+ modelSlim: { id: 'app.skins.browse.model.slim', defaultMessage: 'Slim' },
|
|
+ allCategories: { id: 'app.skins.browse.kind.all', defaultMessage: 'All categories' },
|
|
+ tagsPlaceholder: {
|
|
+ id: 'app.skins.browse.tags-placeholder',
|
|
+ defaultMessage: 'Tags, comma separated',
|
|
+ },
|
|
+ uploaderPlaceholder: { id: 'app.skins.browse.uploader-placeholder', defaultMessage: 'Uploader' },
|
|
+ playerPlaceholder: {
|
|
+ id: 'app.skins.browse.player-placeholder',
|
|
+ defaultMessage: 'Search players by name',
|
|
+ },
|
|
+ playerHint: {
|
|
+ id: 'app.skins.browse.player-hint',
|
|
+ defaultMessage: 'Type at least three letters of a player name.',
|
|
+ },
|
|
+ likes: { id: 'app.skins.browse.likes', defaultMessage: 'Likes' },
|
|
+ wearers: { id: 'app.skins.browse.wearers', defaultMessage: 'Players wearing it' },
|
|
+ views: { id: 'app.skins.browse.views', defaultMessage: 'Views' },
|
|
+ nothingFound: { id: 'app.skins.browse.nothing-found', defaultMessage: 'No skins found.' },
|
|
+ loadMore: { id: 'app.skins.browse.load-more', defaultMessage: 'Load more' },
|
|
+ siteExplanation: {
|
|
+ id: 'app.skins.browse.site.explanation',
|
|
+ defaultMessage:
|
|
+ '{site} opens in its own window. Search, filter and sort there as usual; the skin page you open is previewed here.',
|
|
+ },
|
|
+ openSite: { id: 'app.skins.browse.site.open', defaultMessage: 'Open {site}' },
|
|
+ siteWaiting: {
|
|
+ id: 'app.skins.browse.site.waiting',
|
|
+ defaultMessage: 'Open a skin page in the window to preview it here.',
|
|
+ },
|
|
+})
|
|
+
|
|
+function sourceLabel(value: Source) {
|
|
+ return SITE_NAMES[value]
|
|
+}
|
|
+
|
|
+function elyModeLabel(value: ElyMode) {
|
|
+ return formatMessage(value === 'players' ? messages.players : messages.catalogue)
|
|
+}
|
|
+
|
|
+const compactFormat = new Intl.NumberFormat(undefined, {
|
|
+ notation: 'compact',
|
|
+ maximumFractionDigits: 1,
|
|
+})
|
|
+
|
|
+function compact(value: number) {
|
|
+ return compactFormat.format(value)
|
|
+}
|
|
+
|
|
+const source = ref<Source>('ely')
|
|
+const elyMode = ref<ElyMode>('catalogue')
|
|
+const sort = ref<ElySort>('best')
|
|
+const order = ref<Order>('site')
|
|
+const model = ref<ElyModel>('any')
|
|
+const kind = ref('')
|
|
+const tagsText = ref('')
|
|
+const uploader = ref('')
|
|
+const playerTerm = ref('')
|
|
+
|
|
+const sortOptions = computed<ComboboxOption<ElySort>[]>(() => [
|
|
+ { value: 'best', label: formatMessage(messages.sortBest) },
|
|
+ { value: 'time', label: formatMessage(messages.sortNew) },
|
|
+])
|
|
+
|
|
+// Ely.by sorts by best or newest itself. The rest orders what has been loaded.
|
|
+const orderOptions = computed<ComboboxOption<Order>[]>(() => [
|
|
+ { value: 'site', label: formatMessage(messages.orderSite) },
|
|
+ { value: 'likes', label: formatMessage(messages.orderLikes) },
|
|
+ { value: 'wearers', label: formatMessage(messages.orderWearers) },
|
|
+ { value: 'views', label: formatMessage(messages.orderViews) },
|
|
+])
|
|
+
|
|
+const modelOptions = computed<ComboboxOption<ElyModel>[]>(() => [
|
|
+ { value: 'any', label: formatMessage(messages.modelAny) },
|
|
+ { value: 'old', label: formatMessage(messages.modelOld) },
|
|
+ { value: 'new', label: formatMessage(messages.modelNew) },
|
|
+ { value: 'slim', label: formatMessage(messages.modelSlim) },
|
|
+])
|
|
+
|
|
+const kindOptions = computed<ComboboxOption<string>[]>(() => [
|
|
+ { value: '', label: formatMessage(messages.allCategories) },
|
|
+ ...ELY_KINDS.map((value) => ({ value, label: value })),
|
|
+])
|
|
+
|
|
+const entries = ref<Entry[]>([])
|
|
+const textures = ref<Record<string, string>>({})
|
|
+const page = ref(1)
|
|
+const lastPage = ref(1)
|
|
+const loading = ref(false)
|
|
+const pickedKey = ref<string | null>(null)
|
|
+let generation = 0
|
|
+
|
|
+const hasMore = computed(() => page.value < lastPage.value)
|
|
+
|
|
+function skinOf(entry: Entry): Skin | undefined {
|
|
+ const texture = textures.value[entry.key]
|
|
+ if (!texture) return undefined
|
|
+ return {
|
|
+ texture_key: entry.key,
|
|
+ variant: entry.variant,
|
|
+ texture,
|
|
+ source: 'custom_external',
|
|
+ is_equipped: false,
|
|
+ }
|
|
+}
|
|
+
|
|
+const tiles = computed<Tile[]>(() => {
|
|
+ const ordered =
|
|
+ order.value === 'site' || elyMode.value === 'players'
|
|
+ ? entries.value
|
|
+ : [...entries.value].sort(
|
|
+ (a, b) => (b.stats?.[order.value as keyof Stats] ?? 0) - (a.stats?.[order.value as keyof Stats] ?? 0),
|
|
+ )
|
|
+ return ordered.map((entry) => ({ ...entry, skin: skinOf(entry) }))
|
|
+})
|
|
+
|
|
+function tags() {
|
|
+ return tagsText.value
|
|
+ .split(',')
|
|
+ .map((tag) => tag.trim())
|
|
+ .filter(Boolean)
|
|
+ .slice(0, 10)
|
|
+}
|
|
+
|
|
+/** Textures come through Rust: ely.by sends no CORS headers. They land one by one. */
|
|
+function loadTextures(list: Entry[], forGeneration: number) {
|
|
+ for (const entry of list) {
|
|
+ if (textures.value[entry.key]) continue
|
|
+ getElyTexture(entry.url)
|
|
+ .then((texture) => {
|
|
+ if (forGeneration !== generation) return
|
|
+ textures.value = { ...textures.value, [entry.key]: texture }
|
|
+ })
|
|
+ .catch(() => {})
|
|
+ }
|
|
+}
|
|
+
|
|
+async function loadCatalogue(pageNumber: number, forGeneration: number) {
|
|
+ loading.value = true
|
|
+ try {
|
|
+ const result = await getElyCatalogue({
|
|
+ sort: sort.value,
|
|
+ model: model.value,
|
|
+ kind: kind.value,
|
|
+ tags: tags(),
|
|
+ uploader: uploader.value.trim(),
|
|
+ page: pageNumber,
|
|
+ })
|
|
+ if (forGeneration !== generation) return
|
|
+
|
|
+ const known = new Set(entries.value.map((entry) => entry.key))
|
|
+ const added: Entry[] = result.items
|
|
+ .map((skin) => ({
|
|
+ key: `browse-ely-${skin.id}`,
|
|
+ title: skin.tags.join(', '),
|
|
+ url: skin.skin_url,
|
|
+ variant: (skin.is_slim ? 'SLIM' : 'CLASSIC') as SkinModel,
|
|
+ stats: { likes: skin.likes, wearers: skin.wearers, views: skin.views },
|
|
+ }))
|
|
+ .filter((entry) => !known.has(entry.key))
|
|
+
|
|
+ entries.value = [...entries.value, ...added]
|
|
+ page.value = result.current || pageNumber
|
|
+ lastPage.value = result.last || pageNumber
|
|
+ loadTextures(added, forGeneration)
|
|
+ } catch (error) {
|
|
+ if (forGeneration === generation) handleError(error as Error)
|
|
+ } finally {
|
|
+ if (forGeneration === generation) loading.value = false
|
|
+ }
|
|
+}
|
|
+
|
|
+async function loadPlayers(forGeneration: number) {
|
|
+ loading.value = true
|
|
+ try {
|
|
+ const players = await searchElyPlayers(playerTerm.value.trim())
|
|
+ if (forGeneration !== generation) return
|
|
+
|
|
+ const found: Entry[] = players
|
|
+ .filter((player) => player.skin_url)
|
|
+ .map((player) => ({
|
|
+ key: `browse-ely-player-${player.nickname.toLowerCase()}`,
|
|
+ title: player.nickname,
|
|
+ url: player.skin_url!,
|
|
+ variant: 'UNKNOWN' as SkinModel,
|
|
+ }))
|
|
+
|
|
+ entries.value = found
|
|
+ loadTextures(found, forGeneration)
|
|
+ } catch (error) {
|
|
+ if (forGeneration === generation) handleError(error as Error)
|
|
+ } finally {
|
|
+ if (forGeneration === generation) loading.value = false
|
|
+ }
|
|
+}
|
|
+
|
|
+function reload() {
|
|
+ const forGeneration = ++generation
|
|
+ entries.value = []
|
|
+ page.value = 1
|
|
+ lastPage.value = 1
|
|
+ loading.value = false
|
|
+
|
|
+ if (elyMode.value === 'players') {
|
|
+ if (playerTerm.value.trim().length >= 3) void loadPlayers(forGeneration)
|
|
+ return
|
|
+ }
|
|
+ void loadCatalogue(1, forGeneration)
|
|
+}
|
|
+
|
|
+function loadMore() {
|
|
+ if (!loading.value && hasMore.value) void loadCatalogue(page.value + 1, generation)
|
|
+}
|
|
+
|
|
+watch([sort, model, kind, elyMode], reload)
|
|
+
|
|
+let typingTimeout: ReturnType<typeof setTimeout> | undefined
|
|
+watch([tagsText, uploader, playerTerm], () => {
|
|
+ clearTimeout(typingTimeout)
|
|
+ typingTimeout = setTimeout(reload, 500)
|
|
+})
|
|
+
|
|
+function dataUrlBytes(url: string) {
|
|
+ return Uint8Array.from(atob(url.slice(url.indexOf(',') + 1)), (char) => char.charCodeAt(0))
|
|
+}
|
|
+
|
|
+async function pick(tile: Tile) {
|
|
+ if (!tile.skin) return
|
|
+ if (pickedKey.value === tile.key) {
|
|
+ pickedKey.value = null
|
|
+ emit('pick', null)
|
|
+ return
|
|
+ }
|
|
+
|
|
+ pickedKey.value = tile.key
|
|
+ const texture = tile.skin.texture
|
|
+ const variant =
|
|
+ tile.variant === 'UNKNOWN'
|
|
+ ? await determineModelType(texture).catch(() => 'CLASSIC' as const)
|
|
+ : tile.variant
|
|
+ emit('pick', { texture, bytes: dataUrlBytes(texture), variant })
|
|
+}
|
|
+
|
|
+watch(source, () => {
|
|
+ pickedKey.value = null
|
|
+ emit('pick', null)
|
|
+})
|
|
+
|
|
+const siteTile = ref<Tile | null>(null)
|
|
+const siteLoading = ref(false)
|
|
+let siteGeneration = 0
|
|
+let unlistenSite: UnlistenFn | undefined
|
|
+let unmounted = false
|
|
+
|
|
+function openSite() {
|
|
+ if (source.value === 'ely') return
|
|
+ openSkinSite(source.value).catch((error) => handleError(error as Error))
|
|
+}
|
|
+
|
|
+/**
|
|
+ * Follows the browsing window. Leaving a skin page keeps the last one on show,
|
|
+ * so it can still be added after browsing on.
|
|
+ */
|
|
+async function showSitePage(sitePage: SitePage | null) {
|
|
+ if (!sitePage) return
|
|
+ const forGeneration = ++siteGeneration
|
|
+ siteLoading.value = true
|
|
+
|
|
+ try {
|
|
+ const skin = await getSiteSkin(sitePage.site, sitePage.id)
|
|
+ if (forGeneration !== siteGeneration) return
|
|
+
|
|
+ const bytes = new Uint8Array(skin.texture)
|
|
+ const texture = `data:image/png;base64,${arrayBufferToBase64(bytes)}`
|
|
+ const variant: SkinModel =
|
|
+ skin.slim === null
|
|
+ ? await determineModelType(texture).catch(() => 'CLASSIC' as const)
|
|
+ : skin.slim
|
|
+ ? 'SLIM'
|
|
+ : 'CLASSIC'
|
|
+ const key = `browse-${sitePage.site}-${sitePage.id}`
|
|
+ const hasStats = skin.likes !== null || skin.wearers !== null || skin.views !== null
|
|
+
|
|
+ siteTile.value = {
|
|
+ key,
|
|
+ title: SITE_NAMES[sitePage.site],
|
|
+ url: '',
|
|
+ variant,
|
|
+ stats: hasStats
|
|
+ ? { likes: skin.likes ?? 0, wearers: skin.wearers ?? 0, views: skin.views ?? 0 }
|
|
+ : undefined,
|
|
+ skin: { texture_key: key, variant, texture, source: 'custom_external', is_equipped: false },
|
|
+ }
|
|
+
|
|
+ // The page open in the window is the skin being looked at, so it is
|
|
+ // previewed straight away.
|
|
+ pickedKey.value = key
|
|
+ emit('pick', { texture, bytes, variant })
|
|
+ } catch (error) {
|
|
+ if (forGeneration === siteGeneration) handleError(error as Error)
|
|
+ } finally {
|
|
+ if (forGeneration === siteGeneration) siteLoading.value = false
|
|
+ }
|
|
+}
|
|
+
|
|
+onMounted(async () => {
|
|
+ reload()
|
|
+ const unlisten = await onSkinSitePage((sitePage) => void showSitePage(sitePage))
|
|
+ if (unmounted) {
|
|
+ unlisten()
|
|
+ } else {
|
|
+ unlistenSite = unlisten
|
|
+ }
|
|
+})
|
|
+
|
|
+onUnmounted(() => {
|
|
+ unmounted = true
|
|
+ clearTimeout(typingTimeout)
|
|
+ unlistenSite?.()
|
|
+})
|
|
+</script>
|
|
diff --git a/apps/app-frontend/src/helpers/skin-browser.ts b/apps/app-frontend/src/helpers/skin-browser.ts
|
|
new file mode 100644
|
|
index 0000000..ea86e86
|
|
--- /dev/null
|
|
+++ b/apps/app-frontend/src/helpers/skin-browser.ts
|
|
@@ -0,0 +1,100 @@
|
|
+/**
|
|
+ * Finding skins on other sites, for the skin page.
|
|
+ *
|
|
+ * Ely.by's catalogue is read directly. NameMC, laby.net and crafty.gg open in a
|
|
+ * window the player browses; the launcher only learns which skin page it is on.
|
|
+ */
|
|
+import { invoke } from '@tauri-apps/api/core'
|
|
+import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
|
+
|
|
+import type { SkinModel } from '@/helpers/skins'
|
|
+
|
|
+export type SkinSite = 'namemc' | 'laby' | 'crafty'
|
|
+export type ElySort = 'best' | 'time'
|
|
+export type ElyModel = 'any' | 'old' | 'new' | 'slim'
|
|
+
|
|
+export interface ElyCatalogueQuery {
|
|
+ sort: ElySort
|
|
+ model: ElyModel
|
|
+ kind: string
|
|
+ tags: string[]
|
|
+ uploader: string
|
|
+ page: number
|
|
+}
|
|
+
|
|
+export interface ElyCatalogueSkin {
|
|
+ id: number
|
|
+ skin_url: string
|
|
+ is_slim: boolean
|
|
+ likes: number
|
|
+ wearers: number
|
|
+ views: number
|
|
+ tags: string[]
|
|
+}
|
|
+
|
|
+export interface ElyCataloguePage {
|
|
+ items: ElyCatalogueSkin[]
|
|
+ current: number
|
|
+ last: number
|
|
+ total: number
|
|
+}
|
|
+
|
|
+export interface ElyPlayer {
|
|
+ nickname: string
|
|
+ skin_url: string | null
|
|
+}
|
|
+
|
|
+export interface SitePage {
|
|
+ site: SkinSite
|
|
+ id: string
|
|
+}
|
|
+
|
|
+export interface SiteSkin {
|
|
+ texture: number[]
|
|
+ slim: boolean | null
|
|
+ likes: number | null
|
|
+ views: number | null
|
|
+ wearers: number | null
|
|
+}
|
|
+
|
|
+/** A skin picked in the browser, previewed until it is added. */
|
|
+export interface SkinBrowserPick {
|
|
+ texture: string
|
|
+ bytes: Uint8Array
|
|
+ variant: SkinModel
|
|
+}
|
|
+
|
|
+/** The categories Ely.by's catalogue sorts skins into, under its own names. */
|
|
+export const ELY_KINDS = [
|
|
+ 'Comics',
|
|
+ 'Adventure',
|
|
+ 'Heroes',
|
|
+ 'Evildoers',
|
|
+ 'Weekend',
|
|
+ 'Characters',
|
|
+ 'Historical',
|
|
+ 'Fantasy',
|
|
+ 'Scientific',
|
|
+ 'Other',
|
|
+] as const
|
|
+
|
|
+export async function getElyCatalogue(query: ElyCatalogueQuery): Promise<ElyCataloguePage> {
|
|
+ return await invoke('plugin:skin-browser|skin_browser_ely_catalogue', { query })
|
|
+}
|
|
+
|
|
+export async function searchElyPlayers(term: string): Promise<ElyPlayer[]> {
|
|
+ return await invoke('plugin:skin-browser|skin_browser_ely_players', { term })
|
|
+}
|
|
+
|
|
+export async function getSiteSkin(site: SkinSite, id: string): Promise<SiteSkin> {
|
|
+ return await invoke('plugin:skin-browser|skin_browser_site_skin', { site, id })
|
|
+}
|
|
+
|
|
+export async function openSkinSite(site: SkinSite): Promise<void> {
|
|
+ await invoke('plugin:skin-browser|skin_browser_open_site', { site })
|
|
+}
|
|
+
|
|
+/** Calls `handler` whenever the browsing window moves onto or off a skin page. */
|
|
+export function onSkinSitePage(handler: (page: SitePage | null) => void): Promise<UnlistenFn> {
|
|
+ return listen<SitePage | null>('skin-browser-page', (event) => handler(event.payload))
|
|
+}
|
|
diff --git a/apps/app-frontend/src/pages/Skins.vue b/apps/app-frontend/src/pages/Skins.vue
|
|
index bcd79ec..9a10f0e 100644
|
|
--- a/apps/app-frontend/src/pages/Skins.vue
|
|
+++ b/apps/app-frontend/src/pages/Skins.vue
|
|
@@ -5,6 +5,7 @@ import {
|
|
EyeIcon,
|
|
InfoIcon,
|
|
LogInIcon,
|
|
+ PlusIcon,
|
|
RotateCounterClockwiseIcon,
|
|
ShirtIcon,
|
|
SpinnerIcon,
|
|
@@ -12,6 +13,7 @@ import {
|
|
} from '@modrinth/assets'
|
|
import {
|
|
Button,
|
|
+ Chips,
|
|
commonMessages,
|
|
ConfirmModal,
|
|
defineMessages,
|
|
@@ -33,6 +35,7 @@ import EarsModIcon from '@/assets/skins/ears-mod.png'
|
|
import type AccountsCard from '@/components/ui/AccountsCard.vue'
|
|
import EditSkinModal from '@/components/ui/skin/EditSkinModal.vue'
|
|
import ElySkinModal from '@/components/ui/skin/ElySkinModal.vue'
|
|
+import SkinBrowser from '@/components/ui/skin/SkinBrowser.vue'
|
|
import VirtualSkinSectionList from '@/components/ui/skin/VirtualSkinSectionList.vue'
|
|
import { useAppSettings } from '@/composables/use-app-settings.ts'
|
|
import { handleSevereError } from '@/composables/use-error.js'
|
|
@@ -52,6 +55,7 @@ import {
|
|
uploadElySkin,
|
|
wearElySkin,
|
|
} from '@/helpers/ely_skins'
|
|
+import type { SkinBrowserPick } from '@/helpers/skin-browser'
|
|
import type { Cape, Skin, SkinModel, SkinTextureUrl } from '@/helpers/skins.ts'
|
|
import {
|
|
determineModelType,
|
|
@@ -237,6 +241,18 @@ const messages = defineMessages({
|
|
id: 'app.skins.ely.failed.title',
|
|
defaultMessage: 'Ely.by did not take the change',
|
|
},
|
|
+ savedTab: {
|
|
+ id: 'app.skins.tab.saved',
|
|
+ defaultMessage: 'My skins',
|
|
+ },
|
|
+ browseTab: {
|
|
+ id: 'app.skins.tab.browse',
|
|
+ defaultMessage: 'Browse',
|
|
+ },
|
|
+ browseAddButton: {
|
|
+ id: 'app.skins.browse.add-button',
|
|
+ defaultMessage: 'Add skin',
|
|
+ },
|
|
})
|
|
|
|
const editSkinModal = useTemplateRef('editSkinModal')
|
|
@@ -1032,6 +1048,28 @@ async function processSkinFileBuffer(buffer: Uint8Array | ArrayBuffer) {
|
|
}
|
|
}
|
|
|
|
+/*
|
|
+ * Skins found elsewhere (see SkinBrowser.vue). A pick is only previewed until
|
|
+ * it is added, and adding goes through the same dialog a skin file does.
|
|
+ */
|
|
+const skinsTab = ref<'saved' | 'browse'>('saved')
|
|
+const browsePick = ref<SkinBrowserPick | null>(null)
|
|
+
|
|
+watch(skinsTab, (tab) => {
|
|
+ if (tab === 'saved') browsePick.value = null
|
|
+})
|
|
+
|
|
+function skinsTabLabel(tab: 'saved' | 'browse') {
|
|
+ return formatMessage(tab === 'browse' ? messages.browseTab : messages.savedTab)
|
|
+}
|
|
+
|
|
+async function addBrowsePick() {
|
|
+ const pick = browsePick.value
|
|
+ if (!pick) return
|
|
+ browsePick.value = null
|
|
+ await processSkinFileBuffer(pick.bytes)
|
|
+}
|
|
+
|
|
/*
|
|
* Ely.by accounts. Ely.by keeps an account's skins in its public catalogue and
|
|
* has no API to change one, so the grid shows that catalogue, and changes are
|
|
@@ -1459,16 +1497,16 @@ await loadAccountSkins()
|
|
class="ml-5 mt-4 flex h-[calc(80vh-1rem)] items-center justify-center max-[700px]:h-[calc(50vh-1rem)]"
|
|
>
|
|
<SkinPreviewRenderer
|
|
- :cape-src="capeTexture"
|
|
- :texture-src="skinTexture || ''"
|
|
- :ears-texture-src="selectedSkin?.texture"
|
|
- :variant="skinVariant"
|
|
+ :cape-src="browsePick ? undefined : capeTexture"
|
|
+ :texture-src="browsePick?.texture ?? (skinTexture || '')"
|
|
+ :ears-texture-src="browsePick?.texture ?? selectedSkin?.texture"
|
|
+ :variant="browsePick?.variant ?? skinVariant"
|
|
:nametag="skinNametag"
|
|
:initial-rotation="Math.PI / 8"
|
|
:ears-enabled="earsFeaturesEnabled"
|
|
@ears-features-detected="selectedSkinHasEarsFeatures = $event"
|
|
>
|
|
- <template v-if="hasPendingSkinChange" #nametag-badge>
|
|
+ <template v-if="hasPendingSkinChange || browsePick" #nametag-badge>
|
|
<div
|
|
class="flex items-center justify-center gap-1.5 rounded-full border border-solid border-brand-blue bg-bg-blue px-3 py-1 text-base font-semibold leading-6 text-brand-blue"
|
|
>
|
|
@@ -1482,7 +1520,26 @@ await loadAccountSkins()
|
|
:class="{ 'has-ears-features': selectedSkinHasEarsFeatures }"
|
|
>
|
|
<div
|
|
- v-if="hasPendingSkinChange"
|
|
+ v-if="browsePick"
|
|
+ class="flex w-full flex-wrap items-center justify-center gap-1.5"
|
|
+ >
|
|
+ <Button type="base" size="lg" @click="browsePick = null">
|
|
+ <RotateCounterClockwiseIcon />
|
|
+ {{ formatMessage(commonMessages.resetButton) }}
|
|
+ </Button>
|
|
+ <Button
|
|
+ type="colored"
|
|
+ color="brand"
|
|
+ size="lg"
|
|
+ :disabled="isSkinManagementReadOnly"
|
|
+ @click="addBrowsePick"
|
|
+ >
|
|
+ <PlusIcon />
|
|
+ {{ formatMessage(messages.browseAddButton) }}
|
|
+ </Button>
|
|
+ </div>
|
|
+ <div
|
|
+ v-else-if="hasPendingSkinChange"
|
|
class="skin-preview-actions flex w-full items-center justify-center gap-1.5"
|
|
:class="selectedSkinHasEarsFeatures ? 'flex-nowrap' : 'flex-wrap'"
|
|
>
|
|
@@ -1607,7 +1664,15 @@ await loadAccountSkins()
|
|
</div>
|
|
|
|
<div class="pt-2">
|
|
+ <Chips
|
|
+ v-model="skinsTab"
|
|
+ :items="['saved', 'browse']"
|
|
+ :format-label="skinsTabLabel"
|
|
+ class="mb-4"
|
|
+ />
|
|
+ <SkinBrowser v-if="skinsTab === 'browse'" @pick="browsePick = $event" />
|
|
<VirtualSkinSectionList
|
|
+ v-else
|
|
ref="skinSectionList"
|
|
:saved-skins="savedSkins"
|
|
:default-skin-sections="defaultSkinSections"
|
|
@@ -1638,13 +1703,13 @@ await loadAccountSkins()
|
|
class="ml-5 mt-4 flex h-[calc(80vh-1rem)] items-center justify-center max-[700px]:h-[calc(50vh-1rem)]"
|
|
>
|
|
<SkinPreviewRenderer
|
|
- :texture-src="elyPreviewTexture"
|
|
- :variant="elyPreviewVariant"
|
|
- :cape-src="elyCape"
|
|
+ :texture-src="browsePick?.texture ?? elyPreviewTexture"
|
|
+ :variant="browsePick?.variant ?? elyPreviewVariant"
|
|
+ :cape-src="browsePick ? undefined : elyCape"
|
|
:nametag="appSettings.hideNametagSkinsPage ? undefined : elyUsername"
|
|
:initial-rotation="Math.PI / 8"
|
|
>
|
|
- <template v-if="elyPending" #nametag-badge>
|
|
+ <template v-if="elyPending || browsePick" #nametag-badge>
|
|
<div
|
|
class="flex items-center justify-center gap-1.5 rounded-full border border-solid border-brand-blue bg-bg-blue px-3 py-1 text-base font-semibold leading-6 text-brand-blue"
|
|
>
|
|
@@ -1654,7 +1719,26 @@ await loadAccountSkins()
|
|
</template>
|
|
<template #subtitle>
|
|
<div
|
|
- v-if="elyPending"
|
|
+ v-if="browsePick"
|
|
+ class="flex w-full flex-wrap items-center justify-center gap-1.5"
|
|
+ >
|
|
+ <Button type="base" size="lg" @click="browsePick = null">
|
|
+ <RotateCounterClockwiseIcon />
|
|
+ {{ formatMessage(commonMessages.resetButton) }}
|
|
+ </Button>
|
|
+ <Button
|
|
+ type="colored"
|
|
+ color="brand"
|
|
+ size="lg"
|
|
+ :disabled="isSkinManagementReadOnly"
|
|
+ @click="addBrowsePick"
|
|
+ >
|
|
+ <PlusIcon />
|
|
+ {{ formatMessage(messages.browseAddButton) }}
|
|
+ </Button>
|
|
+ </div>
|
|
+ <div
|
|
+ v-else-if="elyPending"
|
|
class="flex w-full flex-wrap items-center justify-center gap-1.5"
|
|
>
|
|
<Button type="base" size="lg" :disabled="elyBusy" @click="elyPending = null">
|
|
@@ -1679,6 +1763,14 @@ await loadAccountSkins()
|
|
</div>
|
|
|
|
<div class="pt-2">
|
|
+ <Chips
|
|
+ v-model="skinsTab"
|
|
+ :items="['saved', 'browse']"
|
|
+ :format-label="skinsTabLabel"
|
|
+ class="mb-4"
|
|
+ />
|
|
+ <SkinBrowser v-if="skinsTab === 'browse'" @pick="browsePick = $event" />
|
|
+ <template v-else>
|
|
<p v-if="!elyLoading && !elyCatalogue.length" class="m-0 mb-4 text-secondary">
|
|
{{ formatMessage(messages.elySkinsEmpty) }}
|
|
</p>
|
|
@@ -1700,6 +1792,7 @@ await loadAccountSkins()
|
|
@add-skin-dragleave="onAddSkinDragLeave"
|
|
@add-skin-drop="onAddSkinDrop"
|
|
/>
|
|
+ </template>
|
|
</div>
|
|
</div>
|
|
|
|
diff --git a/apps/app/build.rs b/apps/app/build.rs
|
|
index 4553d48..90e22ef 100644
|
|
--- a/apps/app/build.rs
|
|
+++ b/apps/app/build.rs
|
|
@@ -44,6 +44,19 @@ fn main() {
|
|
DefaultPermissionRule::AllowAllCommands,
|
|
),
|
|
)
|
|
+ .plugin(
|
|
+ "skin-browser",
|
|
+ InlinedPlugin::new()
|
|
+ .commands(&[
|
|
+ "skin_browser_ely_catalogue",
|
|
+ "skin_browser_ely_players",
|
|
+ "skin_browser_site_skin",
|
|
+ "skin_browser_open_site",
|
|
+ ])
|
|
+ .default_permission(
|
|
+ DefaultPermissionRule::AllowAllCommands,
|
|
+ ),
|
|
+ )
|
|
.plugin(
|
|
"crash-analysis",
|
|
InlinedPlugin::new()
|
|
diff --git a/apps/app/capabilities/plugins.json b/apps/app/capabilities/plugins.json
|
|
index 0a1560d..c44fb00 100644
|
|
--- a/apps/app/capabilities/plugins.json
|
|
+++ b/apps/app/capabilities/plugins.json
|
|
@@ -106,6 +106,7 @@
|
|
"friends:default",
|
|
"worlds:default",
|
|
"ely-skins:default",
|
|
- "crash-analysis:default"
|
|
+ "crash-analysis:default",
|
|
+ "skin-browser:default"
|
|
]
|
|
}
|
|
diff --git a/apps/app/src/api/mod.rs b/apps/app/src/api/mod.rs
|
|
index 565a39f..e01fcbc 100644
|
|
--- a/apps/app/src/api/mod.rs
|
|
+++ b/apps/app/src/api/mod.rs
|
|
@@ -18,6 +18,7 @@ pub mod process;
|
|
pub mod reports;
|
|
pub mod settings;
|
|
pub mod shortcuts;
|
|
+pub mod skin_browser;
|
|
pub mod tags;
|
|
mod thumbnails;
|
|
pub mod users;
|
|
diff --git a/apps/app/src/api/skin_browser.rs b/apps/app/src/api/skin_browser.rs
|
|
new file mode 100644
|
|
index 0000000..cfd0d8c
|
|
--- /dev/null
|
|
+++ b/apps/app/src/api/skin_browser.rs
|
|
@@ -0,0 +1,122 @@
|
|
+//! Browsing skins from the skin page.
|
|
+//!
|
|
+//! Ely.by's catalogue is read directly (see `theseus::skin_browser`). NameMC,
|
|
+//! laby.net and crafty.gg open in a window the player browses like any browser;
|
|
+//! the launcher only watches which page that window is on, so that the skin
|
|
+//! page the player has open can be previewed and added.
|
|
+
|
|
+use std::time::Duration;
|
|
+
|
|
+use serde::Serialize;
|
|
+use tauri::plugin::TauriPlugin;
|
|
+use tauri::{
|
|
+ AppHandle, Emitter, Manager, Runtime, WebviewUrl, WebviewWindowBuilder,
|
|
+};
|
|
+use theseus::skin_browser::{
|
|
+ self, ElyCatalogueQuery, ElyCataloguePage, ElyPlayer, SiteSkin, SkinSite,
|
|
+};
|
|
+
|
|
+use crate::api::{Result, TheseusSerializableError};
|
|
+
|
|
+pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
|
+ tauri::plugin::Builder::<R>::new("skin-browser")
|
|
+ .invoke_handler(tauri::generate_handler![
|
|
+ skin_browser_ely_catalogue,
|
|
+ skin_browser_ely_players,
|
|
+ skin_browser_site_skin,
|
|
+ skin_browser_open_site,
|
|
+ ])
|
|
+ .build()
|
|
+}
|
|
+
|
|
+const WINDOW_LABEL: &str = "skin-site";
|
|
+
|
|
+/// Carries the skin page the window is on, or `null` when it is on none or has
|
|
+/// been closed.
|
|
+const PAGE_EVENT: &str = "skin-browser-page";
|
|
+
|
|
+#[derive(Serialize, Clone, PartialEq)]
|
|
+struct SitePage {
|
|
+ site: SkinSite,
|
|
+ id: String,
|
|
+}
|
|
+
|
|
+#[tauri::command]
|
|
+pub async fn skin_browser_ely_catalogue(
|
|
+ query: ElyCatalogueQuery,
|
|
+) -> Result<ElyCataloguePage> {
|
|
+ Ok(skin_browser::ely_catalogue(&query).await?)
|
|
+}
|
|
+
|
|
+#[tauri::command]
|
|
+pub async fn skin_browser_ely_players(term: String) -> Result<Vec<ElyPlayer>> {
|
|
+ Ok(skin_browser::ely_players(&term).await?)
|
|
+}
|
|
+
|
|
+#[tauri::command]
|
|
+pub async fn skin_browser_site_skin(
|
|
+ site: SkinSite,
|
|
+ id: String,
|
|
+) -> Result<SiteSkin> {
|
|
+ Ok(skin_browser::site_skin(site, &id).await?)
|
|
+}
|
|
+
|
|
+/// Opens a skin site in the browsing window, or sends the open window there.
|
|
+#[tauri::command]
|
|
+pub async fn skin_browser_open_site<R: Runtime>(
|
|
+ app: AppHandle<R>,
|
|
+ site: SkinSite,
|
|
+) -> Result<()> {
|
|
+ let home: tauri::Url = site
|
|
+ .home()
|
|
+ .parse()
|
|
+ .map_err(|_| other("Could not build the skin site address"))?;
|
|
+
|
|
+ if let Some(window) = app.get_webview_window(WINDOW_LABEL) {
|
|
+ window.navigate(home)?;
|
|
+ window.unminimize().ok();
|
|
+ window.set_focus()?;
|
|
+ return Ok(());
|
|
+ }
|
|
+
|
|
+ let window = WebviewWindowBuilder::new(
|
|
+ &app,
|
|
+ WINDOW_LABEL,
|
|
+ WebviewUrl::External(home),
|
|
+ )
|
|
+ .title("Browse skins")
|
|
+ .inner_size(1100.0, 800.0)
|
|
+ .min_inner_size(600.0, 500.0)
|
|
+ .center()
|
|
+ .focused(true)
|
|
+ .build()?;
|
|
+
|
|
+ // Where the window is gets read rather than reported, as in the sign-in
|
|
+ // windows: a navigation callback is not called for every way a page moves.
|
|
+ tauri::async_runtime::spawn(async move {
|
|
+ let mut last: Option<SitePage> = None;
|
|
+ loop {
|
|
+ tokio::time::sleep(Duration::from_millis(300)).await;
|
|
+ if window.title().is_err() {
|
|
+ break;
|
|
+ }
|
|
+ let Ok(url) = window.url() else { break };
|
|
+
|
|
+ let page = skin_browser::skin_page(url.as_str())
|
|
+ .map(|(site, id)| SitePage { site, id });
|
|
+ if page != last {
|
|
+ app.emit(PAGE_EVENT, &page).ok();
|
|
+ last = page;
|
|
+ }
|
|
+ }
|
|
+ app.emit(PAGE_EVENT, Option::<SitePage>::None).ok();
|
|
+ });
|
|
+
|
|
+ Ok(())
|
|
+}
|
|
+
|
|
+fn other(message: &str) -> TheseusSerializableError {
|
|
+ theseus::ErrorKind::OtherError(message.to_string())
|
|
+ .as_error()
|
|
+ .into()
|
|
+}
|
|
diff --git a/apps/app/src/main.rs b/apps/app/src/main.rs
|
|
index 8429e4c..04eedbd 100644
|
|
--- a/apps/app/src/main.rs
|
|
+++ b/apps/app/src/main.rs
|
|
@@ -252,6 +252,7 @@ fn main() {
|
|
.plugin(api::auth::init())
|
|
.plugin(api::ely_skins::init())
|
|
.plugin(api::crash_analysis::init())
|
|
+ .plugin(api::skin_browser::init())
|
|
.plugin(api::mr_auth::init())
|
|
.plugin(api::onboarding_checklist::init())
|
|
.plugin(api::import::init())
|
|
diff --git a/packages/app-lib/src/api/mod.rs b/packages/app-lib/src/api/mod.rs
|
|
index 566810b..a6f770d 100644
|
|
--- a/packages/app-lib/src/api/mod.rs
|
|
+++ b/packages/app-lib/src/api/mod.rs
|
|
@@ -17,6 +17,7 @@ pub mod process;
|
|
pub mod reports;
|
|
pub mod server_address;
|
|
pub mod settings;
|
|
+pub mod skin_browser;
|
|
pub mod tags;
|
|
pub mod users;
|
|
pub mod worlds;
|
|
diff --git a/packages/app-lib/src/api/skin_browser.rs b/packages/app-lib/src/api/skin_browser.rs
|
|
new file mode 100644
|
|
index 0000000..fba085b
|
|
--- /dev/null
|
|
+++ b/packages/app-lib/src/api/skin_browser.rs
|
|
@@ -0,0 +1,373 @@
|
|
+//! Browsing skins from other sites.
|
|
+//!
|
|
+//! Only Ely.by's catalogue is read from here: its robots.txt invites it. NameMC,
|
|
+//! laby.net and crafty.gg are not - their lists are bot-protected or not meant
|
|
+//! for other programs - so the player browses them in a window of the desktop
|
|
+//! shell, and all that is read from them is the one skin whose page is open.
|
|
+
|
|
+use base64::Engine;
|
|
+use serde::{Deserialize, Serialize};
|
|
+
|
|
+use crate::util::fetch::REQWEST_CLIENT;
|
|
+
|
|
+fn failed(what: &str, error: impl std::fmt::Display) -> crate::Error {
|
|
+ crate::ErrorKind::OtherError(format!("Could not {what}: {error}"))
|
|
+ .as_error()
|
|
+}
|
|
+
|
|
+fn invalid(what: &str) -> crate::Error {
|
|
+ crate::ErrorKind::OtherError(format!("Not a valid {what}")).as_error()
|
|
+}
|
|
+
|
|
+/// Short, and nothing that could not be typed.
|
|
+fn plain(value: &str, max: usize) -> bool {
|
|
+ value.chars().count() <= max && !value.chars().any(char::is_control)
|
|
+}
|
|
+
|
|
+fn is_hex(value: &str, length: usize) -> bool {
|
|
+ value.len() == length && value.bytes().all(|byte| byte.is_ascii_hexdigit())
|
|
+}
|
|
+
|
|
+/// A catalogue filter, as Ely.by's own catalogue page sends it.
|
|
+#[derive(Deserialize, Debug)]
|
|
+pub struct ElyCatalogueQuery {
|
|
+ /// `best` or `time`.
|
|
+ pub sort: String,
|
|
+ /// `any`, `old`, `new` or `slim`.
|
|
+ pub model: String,
|
|
+ /// A catalogue category, or empty for all of them.
|
|
+ pub kind: String,
|
|
+ pub tags: Vec<String>,
|
|
+ pub uploader: String,
|
|
+ pub page: u32,
|
|
+}
|
|
+
|
|
+#[derive(Serialize, Debug)]
|
|
+pub struct ElyCatalogueSkin {
|
|
+ pub id: u64,
|
|
+ pub skin_url: String,
|
|
+ pub is_slim: bool,
|
|
+ pub likes: u64,
|
|
+ pub wearers: u64,
|
|
+ pub views: u64,
|
|
+ pub tags: Vec<String>,
|
|
+}
|
|
+
|
|
+#[derive(Serialize, Debug)]
|
|
+pub struct ElyCataloguePage {
|
|
+ pub items: Vec<ElyCatalogueSkin>,
|
|
+ pub current: u32,
|
|
+ pub last: u32,
|
|
+ pub total: u64,
|
|
+}
|
|
+
|
|
+#[derive(Deserialize)]
|
|
+struct RawPage {
|
|
+ items: Vec<RawSkin>,
|
|
+ #[serde(default)]
|
|
+ current: u32,
|
|
+ #[serde(default)]
|
|
+ last: u32,
|
|
+ #[serde(default)]
|
|
+ total_items: u64,
|
|
+}
|
|
+
|
|
+#[derive(Deserialize)]
|
|
+struct RawSkin {
|
|
+ id: u64,
|
|
+ skin_url: String,
|
|
+ #[serde(default)]
|
|
+ is_slim: bool,
|
|
+ #[serde(default)]
|
|
+ count_cubes: u64,
|
|
+ #[serde(default)]
|
|
+ count_wearers: u64,
|
|
+ #[serde(default)]
|
|
+ count_views_total: u64,
|
|
+ #[serde(default)]
|
|
+ tags: Vec<String>,
|
|
+}
|
|
+
|
|
+/// One page of Ely.by's skin catalogue.
|
|
+///
|
|
+/// The filters go in the query and the page number in a form body, which is how
|
|
+/// the catalogue page itself asks: a page number in the query is ignored.
|
|
+pub async fn ely_catalogue(
|
|
+ query: &ElyCatalogueQuery,
|
|
+) -> crate::Result<ElyCataloguePage> {
|
|
+ if !matches!(query.sort.as_str(), "best" | "time") {
|
|
+ return Err(invalid("sort"));
|
|
+ }
|
|
+ if !matches!(query.model.as_str(), "any" | "old" | "new" | "slim") {
|
|
+ return Err(invalid("skin format"));
|
|
+ }
|
|
+ if !plain(&query.kind, 32)
|
|
+ || !plain(&query.uploader, 32)
|
|
+ || query.tags.len() > 10
|
|
+ || query.tags.iter().any(|tag| !plain(tag, 64))
|
|
+ {
|
|
+ return Err(invalid("filter"));
|
|
+ }
|
|
+
|
|
+ let mut params = vec![("sort", query.sort.clone())];
|
|
+ // The catalogue page leaves out whatever is not narrowing anything.
|
|
+ if query.model != "any" {
|
|
+ params.push(("type", query.model.clone()));
|
|
+ }
|
|
+ if !query.kind.is_empty() {
|
|
+ params.push(("kind", query.kind.clone()));
|
|
+ }
|
|
+ if !query.uploader.is_empty() {
|
|
+ params.push(("uploader", query.uploader.clone()));
|
|
+ }
|
|
+ for tag in &query.tags {
|
|
+ params.push(("tags[]", tag.clone()));
|
|
+ }
|
|
+
|
|
+ let url = url::Url::parse_with_params("https://ely.by/skins/get", ¶ms)
|
|
+ .map_err(|error| failed("build the catalogue address", error))?;
|
|
+
|
|
+ let page: RawPage = REQWEST_CLIENT
|
|
+ .post(url)
|
|
+ .header("X-Requested-With", "XMLHttpRequest")
|
|
+ .header("Accept", "application/json")
|
|
+ .header("Content-Type", "application/x-www-form-urlencoded")
|
|
+ .body(format!("skinsPage={}", query.page.max(1)))
|
|
+ .send()
|
|
+ .await
|
|
+ .and_then(|response| response.error_for_status())
|
|
+ .map_err(|error| failed("load the Ely.by catalogue", error))?
|
|
+ .json()
|
|
+ .await
|
|
+ .map_err(|error| failed("read the Ely.by catalogue", error))?;
|
|
+
|
|
+ Ok(ElyCataloguePage {
|
|
+ items: page
|
|
+ .items
|
|
+ .into_iter()
|
|
+ .map(|skin| ElyCatalogueSkin {
|
|
+ id: skin.id,
|
|
+ skin_url: skin.skin_url,
|
|
+ is_slim: skin.is_slim,
|
|
+ likes: skin.count_cubes,
|
|
+ wearers: skin.count_wearers,
|
|
+ views: skin.count_views_total,
|
|
+ tags: skin.tags,
|
|
+ })
|
|
+ .collect(),
|
|
+ current: page.current,
|
|
+ last: page.last,
|
|
+ total: page.total_items,
|
|
+ })
|
|
+}
|
|
+
|
|
+#[derive(Serialize, Deserialize, Debug)]
|
|
+pub struct ElyPlayer {
|
|
+ pub nickname: String,
|
|
+ pub skin_url: Option<String>,
|
|
+}
|
|
+
|
|
+/// Ely.by players whose name matches `term`, with the skin each wears.
|
|
+///
|
|
+/// The catalogue itself has no text search; Ely.by's site-wide search, which
|
|
+/// finds players, is how it looks things up by name.
|
|
+pub async fn ely_players(term: &str) -> crate::Result<Vec<ElyPlayer>> {
|
|
+ let term = term.trim();
|
|
+ if term.chars().count() < 3 || !plain(term, 32) {
|
|
+ return Err(invalid("player search"));
|
|
+ }
|
|
+
|
|
+ let url =
|
|
+ url::Url::parse_with_params("https://ely.by/search/", &[("term", term)])
|
|
+ .map_err(|error| failed("build the search address", error))?;
|
|
+
|
|
+ REQWEST_CLIENT
|
|
+ .get(url)
|
|
+ .header("X-Requested-With", "XMLHttpRequest")
|
|
+ .header("Accept", "application/json")
|
|
+ .send()
|
|
+ .await
|
|
+ .and_then(|response| response.error_for_status())
|
|
+ .map_err(|error| failed("search Ely.by", error))?
|
|
+ .json()
|
|
+ .await
|
|
+ .map_err(|error| failed("read the Ely.by search", error))
|
|
+}
|
|
+
|
|
+/// A site that is browsed in the launcher's window.
|
|
+#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
|
+#[serde(rename_all = "lowercase")]
|
|
+pub enum SkinSite {
|
|
+ Namemc,
|
|
+ Laby,
|
|
+ Crafty,
|
|
+}
|
|
+
|
|
+impl SkinSite {
|
|
+ /// Where browsing the site starts.
|
|
+ pub fn home(self) -> &'static str {
|
|
+ match self {
|
|
+ Self::Namemc => "https://namemc.com/minecraft-skins",
|
|
+ Self::Laby => "https://laby.net/skins",
|
|
+ Self::Crafty => "https://crafty.gg/skins",
|
|
+ }
|
|
+ }
|
|
+}
|
|
+
|
|
+/// The skin a page is about, when it is a skin page on one of the sites.
|
|
+pub fn skin_page(url: &str) -> Option<(SkinSite, String)> {
|
|
+ let url = url::Url::parse(url).ok()?;
|
|
+ let host = url.host_str()?.trim_start_matches("www.");
|
|
+ let mut segments = url.path_segments()?.filter(|part| !part.is_empty());
|
|
+ let section = segments.next()?;
|
|
+ let id = segments.next()?.to_ascii_lowercase();
|
|
+
|
|
+ let site = if host == "namemc.com" || host.ends_with(".namemc.com") {
|
|
+ (section == "skin" && is_hex(&id, 16)).then_some(SkinSite::Namemc)
|
|
+ } else if host == "laby.net" {
|
|
+ (matches!(section, "skin" | "skins") && is_hex(&id, 32))
|
|
+ .then_some(SkinSite::Laby)
|
|
+ } else if host == "crafty.gg" {
|
|
+ (section == "skins" && uuid::Uuid::parse_str(&id).is_ok())
|
|
+ .then_some(SkinSite::Crafty)
|
|
+ } else {
|
|
+ None
|
|
+ }?;
|
|
+
|
|
+ Some((site, id))
|
|
+}
|
|
+
|
|
+/// A skin from a skin page, with whatever the site says about it.
|
|
+#[derive(Serialize, Debug)]
|
|
+pub struct SiteSkin {
|
|
+ pub texture: Vec<u8>,
|
|
+ pub slim: Option<bool>,
|
|
+ pub likes: Option<u64>,
|
|
+ pub views: Option<u64>,
|
|
+ pub wearers: Option<u64>,
|
|
+}
|
|
+
|
|
+#[derive(Deserialize)]
|
|
+struct CraftyResponse {
|
|
+ data: CraftySkin,
|
|
+}
|
|
+
|
|
+#[derive(Deserialize)]
|
|
+struct CraftySkin {
|
|
+ texture: String,
|
|
+ #[serde(default)]
|
|
+ slim: Option<bool>,
|
|
+ #[serde(default)]
|
|
+ upvotes_lifetime: Option<u64>,
|
|
+ #[serde(default)]
|
|
+ views_lifetime: Option<u64>,
|
|
+ #[serde(default)]
|
|
+ players_count: Option<u64>,
|
|
+}
|
|
+
|
|
+/// The skin on a skin page the player has open.
|
|
+pub async fn site_skin(site: SkinSite, id: &str) -> crate::Result<SiteSkin> {
|
|
+ let id = id.to_ascii_lowercase();
|
|
+ let texture_only = |texture: Vec<u8>| SiteSkin {
|
|
+ texture,
|
|
+ slim: None,
|
|
+ likes: None,
|
|
+ views: None,
|
|
+ wearers: None,
|
|
+ };
|
|
+
|
|
+ match site {
|
|
+ SkinSite::Namemc => {
|
|
+ if !is_hex(&id, 16) {
|
|
+ return Err(invalid("NameMC skin"));
|
|
+ }
|
|
+ // The image host, which is not behind the bot check the pages are.
|
|
+ let url = format!("https://s.namemc.com/i/{id}.png");
|
|
+ Ok(texture_only(png(&url, "NameMC").await?))
|
|
+ }
|
|
+ SkinSite::Laby => {
|
|
+ if !is_hex(&id, 32) {
|
|
+ return Err(invalid("laby.net skin"));
|
|
+ }
|
|
+ let url = format!("https://laby.net/api/v3/texture/{id}/skin.png");
|
|
+ Ok(texture_only(png(&url, "laby.net").await?))
|
|
+ }
|
|
+ SkinSite::Crafty => {
|
|
+ if uuid::Uuid::parse_str(&id).is_err() {
|
|
+ return Err(invalid("crafty.gg skin"));
|
|
+ }
|
|
+ let skin = REQWEST_CLIENT
|
|
+ .get(format!("https://api.crafty.gg/api/v2/skins/{id}"))
|
|
+ .header("Accept", "application/json")
|
|
+ .send()
|
|
+ .await
|
|
+ .and_then(|response| response.error_for_status())
|
|
+ .map_err(|error| failed("load the crafty.gg skin", error))?
|
|
+ .json::<CraftyResponse>()
|
|
+ .await
|
|
+ .map_err(|error| failed("read the crafty.gg skin", error))?
|
|
+ .data;
|
|
+
|
|
+ let texture = base64::engine::general_purpose::STANDARD
|
|
+ .decode(skin.texture.as_bytes())
|
|
+ .map_err(|error| failed("read the crafty.gg skin", error))?;
|
|
+
|
|
+ Ok(SiteSkin {
|
|
+ texture,
|
|
+ slim: skin.slim,
|
|
+ likes: skin.upvotes_lifetime,
|
|
+ views: skin.views_lifetime,
|
|
+ wearers: skin.players_count,
|
|
+ })
|
|
+ }
|
|
+ }
|
|
+}
|
|
+
|
|
+async fn png(url: &str, site: &str) -> crate::Result<Vec<u8>> {
|
|
+ let what = format!("load the skin from {site}");
|
|
+ let bytes = REQWEST_CLIENT
|
|
+ .get(url)
|
|
+ .send()
|
|
+ .await
|
|
+ .and_then(|response| response.error_for_status())
|
|
+ .map_err(|error| failed(&what, error))?
|
|
+ .bytes()
|
|
+ .await
|
|
+ .map_err(|error| failed(&what, error))?;
|
|
+
|
|
+ // An empty answer or an error page is not a skin.
|
|
+ if !bytes.starts_with(b"\x89PNG") {
|
|
+ return Err(failed(&what, "the answer was not a PNG"));
|
|
+ }
|
|
+
|
|
+ Ok(bytes.to_vec())
|
|
+}
|
|
+
|
|
+#[cfg(test)]
|
|
+mod tests {
|
|
+ use super::*;
|
|
+
|
|
+ #[test]
|
|
+ fn recognises_skin_pages() {
|
|
+ assert_eq!(
|
|
+ skin_page("https://de.namemc.com/skin/ee40191789e621d3"),
|
|
+ Some((SkinSite::Namemc, "ee40191789e621d3".to_string()))
|
|
+ );
|
|
+ assert_eq!(
|
|
+ skin_page("https://laby.net/skins/2322dc180b90075fa936c3408e3776a4"),
|
|
+ Some((SkinSite::Laby, "2322dc180b90075fa936c3408e3776a4".to_string()))
|
|
+ );
|
|
+ assert_eq!(
|
|
+ skin_page(
|
|
+ "https://crafty.gg/skins/0a08b87a-c813-4b4f-b83b-c856d46f4b23"
|
|
+ ),
|
|
+ Some((
|
|
+ SkinSite::Crafty,
|
|
+ "0a08b87a-c813-4b4f-b83b-c856d46f4b23".to_string()
|
|
+ ))
|
|
+ );
|
|
+ assert_eq!(skin_page("https://namemc.com/minecraft-skins"), None);
|
|
+ assert_eq!(skin_page("https://crafty.gg/skins/trending"), None);
|
|
+ assert_eq!(skin_page("https://example.com/skin/ee40191789e621d3"), None);
|
|
+ }
|
|
+}
|