Merge branch 'main' into chyz/misused-slug-quick-fix-patch

This commit is contained in:
Prospector
2026-07-21 11:33:11 -07:00
committed by GitHub
15 changed files with 1698 additions and 191 deletions
+4
View File
@@ -101,6 +101,10 @@ jobs:
~/.cargo/bin
key: ${{ runner.os }}-${{ runner.arch }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Ensure sccache directory exists
if: needs.skip-if-clean.outputs.internal == 'true'
run: sudo mkdir -p /mnt/sccache && sudo chown $(id -u):$(id -g) /mnt/sccache
- name: Mount sccache disk cache
if: needs.skip-if-clean.outputs.internal == 'true'
uses: useblacksmith/stickydisk@13af8883542ca949a717e70fef89d15edbb29d88 # v1.2.0
+4
View File
@@ -109,6 +109,10 @@ jobs:
~/.cargo/bin
key: ${{ runner.os }}-${{ runner.arch }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Ensure sccache directory exists
if: needs.skip-if-clean.outputs.internal == 'true'
run: sudo mkdir -p /mnt/sccache && sudo chown $(id -u):$(id -g) /mnt/sccache
- name: Mount sccache disk cache
if: needs.skip-if-clean.outputs.internal == 'true'
uses: useblacksmith/stickydisk@13af8883542ca949a717e70fef89d15edbb29d88 # v1.2.0
Generated
+70
View File
@@ -3856,6 +3856,12 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
[[package]]
name = "futures-timer"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
[[package]]
name = "futures-util"
version = "0.3.31"
@@ -4167,6 +4173,29 @@ dependencies = [
"system-deps",
]
[[package]]
name = "governor"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8"
dependencies = [
"cfg-if",
"dashmap",
"futures-sink",
"futures-timer",
"futures-util",
"getrandom 0.3.3",
"hashbrown 0.16.0",
"nonzero_ext",
"parking_lot",
"portable-atomic",
"quanta",
"rand 0.9.2",
"smallvec",
"spinning_top",
"web-time",
]
[[package]]
name = "group"
version = "0.13.0"
@@ -6300,6 +6329,12 @@ dependencies = [
"memchr",
]
[[package]]
name = "nonzero_ext"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
[[package]]
name = "noop_proc_macro"
version = "0.3.0"
@@ -7900,6 +7935,21 @@ dependencies = [
"bytemuck",
]
[[package]]
name = "quanta"
version = "0.12.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7"
dependencies = [
"crossbeam-utils",
"libc",
"once_cell",
"raw-cpuid",
"wasi 0.11.1+wasi-snapshot-preview1",
"web-sys",
"winapi",
]
[[package]]
name = "quartz_nbt"
version = "0.2.9"
@@ -8227,6 +8277,15 @@ dependencies = [
"rgb",
]
[[package]]
name = "raw-cpuid"
version = "11.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
dependencies = [
"bitflags 2.9.4",
]
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@@ -9838,6 +9897,15 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591"
[[package]]
name = "spinning_top"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300"
dependencies = [
"lock_api",
]
[[package]]
name = "spki"
version = "0.7.3"
@@ -10940,8 +11008,10 @@ dependencies = [
"fs4",
"futures",
"futures-lite 2.6.1",
"governor",
"heck 0.5.0",
"hickory-resolver 0.25.2",
"httpdate",
"indicatif",
"itertools 0.14.0",
"modrinth-content-management",
+2
View File
@@ -89,10 +89,12 @@ fs4 = { version = "0.13.1", default-features = false }
futures = "0.3.31"
futures-lite = "2.6.1"
futures-util = "0.3.31"
governor = "0.10.4"
heck = "0.5.0"
hex = "0.4.3"
hickory-resolver = "0.25.2"
hmac = "0.12.1"
httpdate = "1.0.3"
hyper = "1.7.0"
hyper-rustls = { version = "0.27.7", default-features = false, features = [
"aws-lc-rs",
@@ -210,7 +210,7 @@ function getModelUrlForVariant(variant: string): string {
export const skinBlobUrlMap = reactive(new Map<string, RenderResult>())
export const headBlobUrlMap = reactive(new Map<string, string>())
const DEBUG_MODE = false
const SKIN_PREVIEW_RENDER_VERSION = 'ears-1'
const SKIN_PREVIEW_RENDER_VERSION = 'ears-2'
let sharedRenderer: BatchSkinRenderer | null = null
let latestPreviewGeneration = 0
+591 -106
View File
@@ -1,19 +1,158 @@
const MODRINTH_ORIGIN = 'https://modrinth.com'
function installAdsConsentThemeStyle() {
if (document.getElementById('modrinth-ads-consent-theme-style')) return
const style = document.createElement('style')
style.id = 'modrinth-ads-consent-theme-style'
style.textContent = `
:root {
--modrinth-usp-bg: #27292e;
--modrinth-usp-surface: #34363c;
--modrinth-usp-divider: #34363c;
--modrinth-usp-text: #b0bac5;
--modrinth-usp-contrast: #ffffff;
--modrinth-usp-brand: #1bd96a;
--modrinth-usp-link: #4f9cff;
--modrinth-usp-accent-contrast: #000000;
--modrinth-usp-shadow: rgba(0, 0, 0, 0.1) 0 4px 6px -1px,
rgba(0, 0, 0, 0.06) 0 2px 4px -1px;
color-scheme: dark;
}
#qc-cmp2-usp {
background: var(--modrinth-usp-bg) !important;
border: 1px solid var(--modrinth-usp-divider) !important;
border-radius: 1rem !important;
box-shadow: var(--modrinth-usp-shadow) !important;
color: var(--modrinth-usp-text) !important;
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Oxygen, Ubuntu, Roboto,
Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif !important;
max-width: 660px;
}
#qc-cmp2-usp .qc-usp-ui-content,
#qc-cmp2-usp .qc-usp-ui-form-content,
#qc-cmp2-usp .qc-usp-container {
background: transparent !important;
}
#qc-cmp2-usp .qc-usp-container{
margin-bottom: 12px;
}
#qc-cmp2-usp p,
#qc-cmp2-usp label,
#qc-cmp2-usp .qc-usp-action-description {
color: var(--modrinth-usp-text) !important;
font-family: inherit !important;
}
#qc-cmp2-usp .qc-usp-title,
#qc-cmp2-usp .qc-cmp2-list-item-title {
color: var(--modrinth-usp-contrast) !important;
font-family: inherit !important;
font-weight: 700 !important;
}
#qc-cmp2-usp .qc-usp-title {
font-size: 1.25rem !important;
}
#qc-cmp2-usp a,
#qc-cmp2-usp .qc-usp-alt-action {
color: var(--modrinth-usp-link) !important;
}
#qc-cmp2-usp .qc-cmp2-list-item {
background: var(--modrinth-usp-surface) !important;
border: 1px solid var(--modrinth-usp-divider) !important;
border-radius: 0.75rem !important;
}
#qc-cmp2-usp .qc-cmp2-list-item-header {
background: transparent !important;
border: 0 !important;
color: var(--modrinth-usp-contrast) !important;
}
#qc-cmp2-usp .qc-cmp2-list-item-header svg {
color: var(--modrinth-usp-text) !important;
}
#qc-cmp2-usp .qc-cmp2-toggle {
background: var(--modrinth-usp-bg) !important;
border-color: var(--modrinth-usp-divider) !important;
}
#qc-cmp2-usp .qc-cmp2-toggle .toggle {
background: var(--modrinth-usp-contrast) !important;
}
#qc-cmp2-usp .qc-cmp2-toggle .text {
color: var(--modrinth-usp-contrast) !important;
}
#qc-cmp2-usp .qc-cmp2-toggle[aria-checked='true'] {
background: var(--modrinth-usp-brand) !important;
border-color: var(--modrinth-usp-brand) !important;
}
#qc-cmp2-usp .qc-cmp2-toggle[aria-checked='true'] .text {
color: var(--modrinth-usp-accent-contrast) !important;
}
#qc-cmp2-usp button[mode='primary'] {
background: var(--modrinth-usp-brand) !important;
border: 0 !important;
border-radius: 0.75rem !important;
color: var(--modrinth-usp-accent-contrast) !important;
font-family: inherit !important;
font-weight: 700 !important;
}
#qc-cmp2-usp .qc-usp-close-icon {
border: 0 !important;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 6 6 18'/%3E%3Cpath d='m6 6 12 12'/%3E%3C/svg%3E")
center / 1.5rem 1.5rem no-repeat;
}
#qc-cmp2-usp a:focus-visible,
#qc-cmp2-usp button:focus-visible {
outline: 2px solid var(--modrinth-usp-brand) !important;
outline-offset: 2px !important;
}
#qc-cmp2-usp .qc-usp-ui-content {
max-width: 100% !important;
}
#qc-cmp2-usp .qc-usp-ui-content .qc-usp-ui-form-content {
border: 1px solid transparent !important;
padding: 0 !important;
}
`
document.documentElement.appendChild(style)
}
document.addEventListener(
'click',
function (e) {
window.top.postMessage({ modrinthAdClick: true }, MODRINTH_ORIGIN)
let target = e.target
if (target?.closest?.('.qc-cmp2-close-icon')) {
if (modrinthAdsConsentReprompt) {
const uspCloseIcon = target?.closest?.('.qc-usp-close-icon')
if (target?.closest?.('.qc-cmp2-close-icon') || uspCloseIcon) {
if (isAdsConsentReprompt()) {
setTimeout(finishAdsConsentReprompt)
} else if (document.documentElement.classList.contains('modrinth-ads-consent-preferences')) {
setTimeout(() => void restoreAdsConsentNotification())
}
}
if (target?.closest?.('#qc-cmp2-usp .qc-usp-ui-form-content button[mode="primary"]')) {
beginUspConsentCommit()
}
while (target != null) {
if (target.matches('a')) {
e.preventDefault()
@@ -32,14 +171,54 @@ window.open = (url, target, features) => {
window.top.postMessage({ modrinthOpenUrl: url }, MODRINTH_ORIGIN)
}
let modrinthAdsConsentOverlayShown = false
const modrinthAdsConsentState = {
phase: 'idle',
variant: null,
commitTimeout: null,
}
let modrinthTcfListenerInstalled = false
let modrinthTcfListenerAttempts = 0
let modrinthAdsConsentReprompt = false
let modrinthAdsConsentRepromptManaging = false
let modrinthGppListenerInstalled = false
let modrinthGppListenerAttempts = 0
let modrinthAdsConsentActionRequestId = 0
const modrinthAdsConsentActionResolvers = new Map()
function transitionAdsConsent(event) {
const phase = modrinthAdsConsentState.phase
if (event === 'prompt-detected') {
if (phase === 'idle') modrinthAdsConsentState.phase = 'initial'
} else if (event === 'reprompt-started') {
modrinthAdsConsentState.phase = 'reprompt'
} else if (event === 'commit-started') {
modrinthAdsConsentState.phase = isAdsConsentReprompt()
? 'reprompt-committing'
: 'initial-committing'
} else if (event === 'commit-timed-out') {
if (phase === 'reprompt-committing') {
modrinthAdsConsentState.phase = 'reprompt'
} else if (phase === 'initial-committing') {
modrinthAdsConsentState.phase = 'initial'
}
} else if (event === 'completed') {
modrinthAdsConsentState.phase = 'complete'
}
}
function isAdsConsentReprompt() {
return (
modrinthAdsConsentState.phase === 'reprompt' ||
modrinthAdsConsentState.phase === 'reprompt-committing'
)
}
function isUspConsentCommitPending() {
return (
modrinthAdsConsentState.phase === 'initial-committing' ||
modrinthAdsConsentState.phase === 'reprompt-committing'
)
}
function installAdsRailStyle() {
if (document.getElementById('modrinth-ads-rail-style')) {
return
@@ -48,8 +227,7 @@ function installAdsRailStyle() {
style.id = 'modrinth-ads-rail-style'
style.textContent = `
html.modrinth-ads-consent-preferences #modrinth-rail-1 {
visibility: hidden !important;
pointer-events: none !important;
display: none !important;
}
`
document.documentElement.appendChild(style)
@@ -62,18 +240,13 @@ function installAdsConsentOverlayStyle() {
const style = document.createElement('style')
style.id = 'modrinth-ads-consent-overlay-style'
style.textContent = `
html.modrinth-ads-consent-overlay:not(.modrinth-ads-consent-preferences) #modrinth-rail-1 {
visibility: hidden !important;
}
html.modrinth-ads-consent-overlay:not(.modrinth-ads-consent-preferences) #qc-cmp2-container,
html.modrinth-ads-consent-overlay:not(.modrinth-ads-consent-preferences) #qc-cmp2-main {
visibility: hidden !important;
pointer-events: none !important;
html.modrinth-ads-consent-preferences:not(.modrinth-ads-consent-preferences-visible) #qc-cmp2-container {
display: none !important;
}
.qc-cmp2-close-icon {
background: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cpath d='M.5.5l23 23m0-23l-23 23' fill='none' stroke='%23b0bac5' stroke-width='3' stroke-linecap='round' stroke-linejoin='round' stroke-miterlimit='10'/%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3C/svg%3E") 0% 0% / 66% auto no-repeat !important;
#qc-cmp2-usp .qc-usp-close-icon {
display: none !important;
}
`
document.documentElement.appendChild(style)
@@ -96,13 +269,20 @@ function invokeAdsConsentOverlayCommand(shown) {
invoke(`plugin:ads|${command}`, args).catch(() => {})
}
function revealAdsConsentPreferences() {
function prepareAdsConsentPreferences() {
installAdsRailStyle()
installAdsConsentOverlayStyle()
document.documentElement.classList.add('modrinth-ads-consent-preferences')
document.getElementById('modrinth-ads-consent-overlay-style')?.remove()
document.documentElement.classList.remove('modrinth-ads-consent-preferences-visible')
}
function revealAdsConsentPreferences() {
document.documentElement.classList.add('modrinth-ads-consent-preferences-visible')
}
function concealAdsConsentPreferences() {
document.documentElement.classList.remove('modrinth-ads-consent-preferences')
document.documentElement.classList.remove('modrinth-ads-consent-preferences-visible')
installAdsConsentOverlayStyle()
}
@@ -127,60 +307,181 @@ function isDirectChildFrame(source) {
)
}
function findAdsConsentButton(action) {
const container = document.querySelector('#qc-cmp2-container, #qc-cmp2-main')
if (!container) return null
const summaryButtons = Array.from(container.querySelectorAll('.qc-cmp2-summary-buttons button'))
function displayUspConsentUi() {
if (typeof window.__uspapi !== 'function') return false
if (action === 'accept') {
const explicitAcceptButton = container.querySelector('#accept-btn')
if (explicitAcceptButton) return explicitAcceptButton
if (summaryButtons.length >= 3) return summaryButtons[2]
return container.querySelector('.qc-cmp2-summary-buttons button[mode="primary"]')
try {
window.__uspapi('displayUspUi', 1, () => {})
return true
} catch {
return false
}
if (action === 'reject') {
const explicitRejectButton = container.querySelector('#disagree-btn')
if (explicitRejectButton) return explicitRejectButton
if (summaryButtons.length >= 3) return summaryButtons[1]
const secondaryButtons = container.querySelectorAll(
'.qc-cmp2-summary-buttons button[mode="secondary"]',
)
if (secondaryButtons.length > 1) return secondaryButtons[1]
return summaryButtons.find(
(button) => button.textContent?.trim().toLowerCase() === 'reject all',
)
}
return (
container.querySelector(
'#more-options-btn, .qc-cmp2-summary-buttons > button[mode="secondary"]:first-of-type',
) ?? summaryButtons[0]
)
}
function clickAdsConsentButtonWhenReady(action, timeoutMs, onButtonFound) {
const deadline = Date.now() + timeoutMs
function detectAdsConsentVariant() {
let variant = null
if (document.getElementById('qc-cmp2-usp')) {
variant = 'usp'
} else if (document.getElementById('qc-cmp2-ui')) {
variant = 'tcf'
}
if (variant) {
modrinthAdsConsentState.variant = variant
}
return variant
}
function findTcfConsentButton(action) {
const dialog = document.getElementById('qc-cmp2-ui')
if (!dialog) return null
const buttonIds = {
accept: 'accept-btn',
reject: 'disagree-btn',
manage: 'more-options-btn',
}
const buttonId = buttonIds[action]
if (!buttonId) return null
const button = dialog.querySelector(`#${buttonId}`)
return button && !button.disabled ? button : null
}
function getUspConsentControls() {
const dialog = document.getElementById('qc-cmp2-usp')
if (!dialog) return null
const toggles = Array.from(
dialog.querySelectorAll('.qc-usp-container button.qc-cmp2-toggle[role="switch"]'),
)
const confirmButton = dialog.querySelector('.qc-usp-ui-form-content button[mode="primary"]')
if (
toggles.length === 0 ||
!confirmButton ||
confirmButton.disabled ||
toggles.some(
(toggle) =>
toggle.disabled || !['true', 'false'].includes(toggle.getAttribute('aria-checked')),
)
) {
return null
}
return { toggles, confirmButton }
}
function waitForUspToggleState(index, checked, expectedCount, deadline) {
return new Promise((resolve) => {
function tryClick() {
const button = findAdsConsentButton(action)
if (button) {
// CMP navigation can replace this document during the click, so acknowledge it first.
onButtonFound?.()
resolve(true)
button.click()
function checkState() {
const controls = getUspConsentControls()
if (
controls &&
controls.toggles.length === expectedCount &&
controls.toggles[index]?.getAttribute('aria-checked') === String(checked)
) {
resolve(controls)
} else if (Date.now() >= deadline) {
resolve(false)
resolve(null)
} else {
setTimeout(tryClick, 50)
setTimeout(checkState, 50)
}
}
tryClick()
checkState()
})
}
async function setUspToggleStates(checked, controls, timeoutMs) {
const expectedCount = controls.toggles.length
const deadline = Date.now() + timeoutMs
for (let index = 0; index < expectedCount; index += 1) {
controls = getUspConsentControls()
if (!controls || controls.toggles.length !== expectedCount) return null
const toggle = controls.toggles[index]
if (toggle.getAttribute('aria-checked') !== String(checked)) {
toggle.click()
controls = await waitForUspToggleState(index, checked, expectedCount, deadline)
if (!controls) return null
}
}
return controls
}
async function performAdsConsentActionInDocument(action, onHandled) {
const variant = detectAdsConsentVariant()
const unknownDialog = document.querySelector('#qc-cmp2-container [role="dialog"]')
if (action === 'show') {
if (variant || unknownDialog) {
onHandled?.()
return 'handled'
}
return 'not-ready'
}
if (variant === 'usp') {
if (action === 'manage') {
onHandled?.()
return 'handled'
}
if (!['accept', 'reject'].includes(action)) return 'failed'
const controls = getUspConsentControls()
if (!controls) return 'not-ready'
const shouldOptOut = action === 'reject'
const settledControls = await setUspToggleStates(shouldOptOut, controls, 2000)
if (!settledControls) return 'failed'
// CMP navigation can replace this document during the click, so acknowledge it first.
onHandled?.()
beginUspConsentCommit()
settledControls.confirmButton.click()
return 'handled'
}
if (variant === 'tcf') {
const button = findTcfConsentButton(action)
if (!button) return 'not-ready'
// CMP navigation can replace this document during the click, so acknowledge it first.
onHandled?.()
button.click()
return 'handled'
}
if (action === 'manage' && unknownDialog) {
onHandled?.()
return 'handled'
}
return 'not-ready'
}
function performAdsConsentActionWhenReady(action, timeoutMs, onHandled) {
const deadline = Date.now() + timeoutMs
return new Promise((resolve) => {
async function tryAction() {
const result = await performAdsConsentActionInDocument(action, onHandled)
if (result === 'handled') {
resolve(true)
} else if (result === 'failed' || Date.now() >= deadline) {
resolve(false)
} else {
setTimeout(tryAction, 50)
}
}
void tryAction()
})
}
@@ -200,9 +501,7 @@ function performAdsConsentActionAcrossFrames(action, timeoutMs) {
const timeout = setTimeout(() => settle(false), timeoutMs)
modrinthAdsConsentActionResolvers.set(requestId, () => settle(true))
sendAdsConsentCommandToChildFrames({ type: 'perform', action, requestId, timeoutMs })
clickAdsConsentButtonWhenReady(action, timeoutMs, () => settle(true)).then((clicked) => {
if (!clicked) settle(false)
})
void performAdsConsentActionWhenReady(action, timeoutMs, () => settle(true))
})
}
@@ -220,10 +519,30 @@ async function restoreAdsConsentNotification() {
}
}
async function showNativeAdsConsentUi() {
prepareAdsConsentPreferences()
await waitForAdsConsentLayout()
sendAdsConsentCommandToChildFrames({ type: 'prepare' })
await expandAdsConsentWebview()
await waitForAdsConsentLayout()
revealAdsConsentPreferences()
sendAdsConsentCommandToChildFrames({ type: 'reveal' })
window.dispatchEvent(new Event('resize'))
sendAdsConsentCommandToChildFrames({ type: 'resize' })
const shown = await performAdsConsentActionAcrossFrames('show', 2500)
if (!shown) {
await restoreAdsConsentNotification()
}
return shown
}
function finishAdsConsentReprompt() {
modrinthAdsConsentReprompt = false
modrinthAdsConsentRepromptManaging = false
modrinthAdsConsentOverlayShown = false
transitionAdsConsent('completed')
clearTimeout(modrinthAdsConsentState.commitTimeout)
modrinthAdsConsentState.commitTimeout = null
document.documentElement.classList.remove('modrinth-ads-consent-overlay')
concealAdsConsentPreferences()
sendAdsConsentCommandToChildFrames({ type: 'conceal' })
@@ -231,18 +550,9 @@ function finishAdsConsentReprompt() {
}
async function openAdsConsentPreferences() {
revealAdsConsentPreferences()
sendAdsConsentCommandToChildFrames({ type: 'reveal' })
await expandAdsConsentWebview()
await waitForAdsConsentLayout()
if (!(await showNativeAdsConsentUi())) return
window.dispatchEvent(new Event('resize'))
sendAdsConsentCommandToChildFrames({ type: 'resize' })
const clicked = await performAdsConsentActionAcrossFrames('manage', 2500)
if (!clicked) {
await restoreAdsConsentNotification()
}
await performAdsConsentActionAcrossFrames('manage', 2500)
}
async function performAdsConsentAction(action) {
@@ -257,30 +567,119 @@ async function performAdsConsentAction(action) {
return
}
await performAdsConsentActionAcrossFrames(action, 1000)
const handled = await performAdsConsentActionAcrossFrames(action, 2500)
if (!handled) {
try {
await showNativeAdsConsentUi()
} catch {
await restoreAdsConsentNotification()
}
}
}
window.modrinthAdsConsentAction = (action) => {
void performAdsConsentAction(action)
}
function isUspConsentApplicable() {
if (detectAdsConsentVariant() === 'usp') return Promise.resolve(true)
if (typeof window.__uspapi !== 'function') return Promise.resolve(false)
return new Promise((resolve) => {
let settled = false
const settle = (applicable) => {
if (settled) return
settled = true
clearTimeout(timeout)
resolve(applicable)
}
const timeout = setTimeout(() => settle(false), 500)
try {
window.__uspapi('uspPing', 1, (data, success) => {
if (!success || !data) {
settle(false)
return
}
const modes = Array.isArray(data.mode) ? data.mode : [data.mode]
const jurisdictions = Array.isArray(data.jurisdiction)
? data.jurisdiction
: [data.jurisdiction]
const location = String(data.location ?? '').toUpperCase()
const hasUspMode = modes.some((mode) =>
String(mode ?? '')
.toUpperCase()
.includes('USP'),
)
const locationApplies =
!location ||
jurisdictions.some((jurisdiction) =>
String(jurisdiction ?? '')
.toUpperCase()
.includes(location),
)
settle(hasUspMode && locationApplies)
})
} catch {
settle(false)
}
})
}
async function displayAdsConsentReprompt() {
if (
(modrinthAdsConsentState.variant === 'usp' || (await isUspConsentApplicable())) &&
typeof window.__uspapi === 'function'
) {
modrinthAdsConsentState.variant = 'usp'
return displayUspConsentUi()
}
if (typeof window.__tcfapi === 'function') {
modrinthAdsConsentState.variant = 'tcf'
window.__tcfapi('displayConsentUi', 2, () => {})
return true
}
return false
}
window.modrinthAdsReopenConsentPreferences = async () => {
modrinthAdsConsentReprompt = true
modrinthAdsConsentRepromptManaging = false
revealAdsConsentPreferences()
sendAdsConsentCommandToChildFrames({ type: 'reveal' })
if (document.documentElement.classList.contains('modrinth-ads-consent-overlay')) {
try {
await openAdsConsentPreferences()
} catch {
await restoreAdsConsentNotification()
}
return
}
transitionAdsConsent('reprompt-started')
prepareAdsConsentPreferences()
sendAdsConsentCommandToChildFrames({ type: 'prepare' })
try {
await waitForAdsConsentLayout()
await expandAdsConsentWebview()
await waitForAdsConsentLayout()
revealAdsConsentPreferences()
sendAdsConsentCommandToChildFrames({ type: 'reveal' })
window.dispatchEvent(new Event('resize'))
sendAdsConsentCommandToChildFrames({ type: 'resize' })
if (typeof window.__tcfapi === 'function') {
window.__tcfapi('displayConsentUi', 2, () => {})
} else {
if (!(await displayAdsConsentReprompt())) {
finishAdsConsentReprompt()
return
}
if (!(await performAdsConsentActionAcrossFrames('show', 2500))) {
finishAdsConsentReprompt()
return
}
await performAdsConsentActionAcrossFrames('manage', 2500)
} catch {
finishAdsConsentReprompt()
}
@@ -302,7 +701,10 @@ window.addEventListener('message', (event) => {
const command = event.data?.modrinthAdsConsentCommand
if (!command || typeof command !== 'object') return
if (command.type === 'reveal') {
if (command.type === 'prepare') {
prepareAdsConsentPreferences()
sendAdsConsentCommandToChildFrames(command)
} else if (command.type === 'reveal') {
revealAdsConsentPreferences()
sendAdsConsentCommandToChildFrames(command)
} else if (command.type === 'conceal') {
@@ -318,20 +720,20 @@ window.addEventListener('message', (event) => {
typeof command.timeoutMs === 'number'
) {
sendAdsConsentCommandToChildFrames(command)
clickAdsConsentButtonWhenReady(command.action, command.timeoutMs, () => {
performAdsConsentActionWhenReady(command.action, command.timeoutMs, () => {
window.parent.postMessage({ modrinthAdsConsentResult: command.requestId }, '*')
})
}
})
function setAdsConsentOverlay(shown) {
if (modrinthAdsConsentOverlayShown === shown) return
if (document.documentElement.classList.contains('modrinth-ads-consent-overlay') === shown) return
modrinthAdsConsentOverlayShown = shown
installAdsConsentOverlayStyle()
document.documentElement.classList.toggle('modrinth-ads-consent-overlay', shown)
if (!shown) {
document.documentElement.classList.remove('modrinth-ads-consent-preferences')
document.documentElement.classList.remove('modrinth-ads-consent-preferences-visible')
}
if (window.top === window) {
@@ -343,36 +745,111 @@ function setAdsConsentOverlay(shown) {
if (window.top === window) {
window.addEventListener('message', (event) => {
if (
event.origin === MODRINTH_ORIGIN &&
typeof event.data?.modrinthAdsConsentOverlay === 'boolean'
) {
if (event.origin !== MODRINTH_ORIGIN) return
if (typeof event.data?.modrinthAdsConsentOverlay === 'boolean') {
setAdsConsentOverlay(event.data.modrinthAdsConsentOverlay)
}
})
}
function finishUspConsentCommit() {
if (!isUspConsentCommitPending()) return
const wasReprompt = isAdsConsentReprompt()
transitionAdsConsent('completed')
clearTimeout(modrinthAdsConsentState.commitTimeout)
modrinthAdsConsentState.commitTimeout = null
if (wasReprompt) {
finishAdsConsentReprompt()
} else {
setAdsConsentOverlay(false)
}
}
function beginUspConsentCommit() {
if (!document.getElementById('qc-cmp2-usp')) return
modrinthAdsConsentState.variant = 'usp'
transitionAdsConsent('commit-started')
clearTimeout(modrinthAdsConsentState.commitTimeout)
const deadline = Date.now() + 2500
function checkForDialogClosure() {
if (!isUspConsentCommitPending()) return
if (!document.getElementById('qc-cmp2-usp')) {
finishUspConsentCommit()
} else if (Date.now() >= deadline) {
transitionAdsConsent('commit-timed-out')
modrinthAdsConsentState.commitTimeout = null
} else {
modrinthAdsConsentState.commitTimeout = setTimeout(checkForDialogClosure, 50)
}
}
modrinthAdsConsentState.commitTimeout = setTimeout(checkForDialogClosure, 50)
}
function syncAdsConsentUi() {
const variant = detectAdsConsentVariant()
if (variant && !isAdsConsentReprompt() && modrinthAdsConsentState.phase !== 'complete') {
transitionAdsConsent('prompt-detected')
setAdsConsentOverlay(true)
}
if (isUspConsentCommitPending() && !document.getElementById('qc-cmp2-usp')) {
finishUspConsentCommit()
}
}
function handleGppConsentEvent(gppData, success) {
if (
success &&
gppData?.eventName === 'sectionChange' &&
modrinthAdsConsentState.variant === 'usp' &&
isUspConsentCommitPending()
) {
finishUspConsentCommit()
}
}
function installGppConsentListener() {
if (modrinthGppListenerInstalled) return
if (typeof window.__gpp === 'function') {
modrinthGppListenerInstalled = true
window.__gpp('addEventListener', handleGppConsentEvent)
return
}
if (modrinthGppListenerAttempts < 60) {
modrinthGppListenerAttempts += 1
setTimeout(installGppConsentListener, 500)
}
}
function handleTcfConsentEvent(tcData, success) {
if (!success || !tcData) return
detectAdsConsentVariant()
if (tcData.eventStatus === 'cmpuishown') {
if (modrinthAdsConsentReprompt) {
if (!modrinthAdsConsentRepromptManaging) {
modrinthAdsConsentRepromptManaging = true
window.dispatchEvent(new Event('resize'))
sendAdsConsentCommandToChildFrames({ type: 'resize' })
void performAdsConsentActionAcrossFrames('manage', 2500)
}
return
}
if (isAdsConsentReprompt()) return
transitionAdsConsent('prompt-detected')
setAdsConsentOverlay(true)
} else if (tcData.eventStatus === 'useractioncomplete') {
if (modrinthAdsConsentReprompt) {
} else if (
tcData.eventStatus === 'useractioncomplete' &&
modrinthAdsConsentState.variant === 'tcf'
) {
if (isAdsConsentReprompt()) {
finishAdsConsentReprompt()
return
}
transitionAdsConsent('completed')
setAdsConsentOverlay(false)
}
}
@@ -469,14 +946,22 @@ function muteVideos() {
}
document.addEventListener('DOMContentLoaded', () => {
installAdsConsentThemeStyle()
installAdsRailStyle()
installAdsConsentOverlayStyle()
muteVideos()
muteAudioContext()
syncAdsConsentUi()
installTcfConsentListener()
installGppConsentListener()
const observer = new MutationObserver(muteVideos)
const observer = new MutationObserver(() => {
muteVideos()
syncAdsConsentUi()
})
observer.observe(document.body, { childList: true, subtree: true })
})
syncAdsConsentUi()
installTcfConsentListener()
installGppConsentListener()
+2
View File
@@ -582,6 +582,8 @@ pub async fn init_ads_window<R: Runtime>(
}
})?;
webview.open_devtools();
Some(webview)
} else {
None
+106
View File
@@ -487,3 +487,109 @@ input {
background-color: transparent;
color: hsl(145, 78%, 28%);
}
#qc-cmp2-usp {
background: var(--color-raised-bg) !important;
border: 1px solid var(--color-divider) !important;
border-radius: var(--radius-lg) !important;
box-shadow: var(--shadow-floating) !important;
color: var(--color-base) !important;
font-family: var(--font-standard) !important;
max-width: 660px;
.qc-usp-ui-content,
.qc-usp-ui-form-content,
.qc-usp-container {
background: transparent !important;
}
p,
label,
.qc-usp-action-description {
color: var(--color-base) !important;
font-family: var(--font-standard) !important;
}
.qc-usp-title,
.qc-cmp2-list-item-title {
color: var(--color-contrast) !important;
font-family: var(--font-standard) !important;
font-weight: 700 !important;
}
.qc-usp-title {
font-size: 1.25rem !important;
}
a,
.qc-usp-alt-action {
color: var(--color-link) !important;
}
.qc-cmp2-list-item {
background: var(--color-button-bg) !important;
border: 1px solid var(--color-divider) !important;
border-radius: var(--radius-md) !important;
}
.qc-cmp2-list-item-header {
background: transparent !important;
border: 0 !important;
color: var(--color-contrast) !important;
}
.qc-cmp2-list-item-header svg {
color: var(--color-base) !important;
}
.qc-cmp2-toggle {
background: var(--surface-3) !important;
border-color: var(--color-divider) !important;
}
.qc-cmp2-toggle .toggle {
background: var(--color-contrast) !important;
}
.qc-cmp2-toggle .text {
color: var(--color-contrast) !important;
}
.qc-cmp2-toggle[aria-checked='true'] {
background: var(--color-brand) !important;
border-color: var(--color-brand) !important;
}
.qc-cmp2-toggle[aria-checked='true'] .text {
color: var(--color-accent-contrast) !important;
}
button[mode='primary'] {
background: var(--color-brand) !important;
border: 0 !important;
border-radius: var(--radius-md) !important;
color: var(--color-accent-contrast) !important;
font-family: var(--font-standard) !important;
font-weight: 700 !important;
}
.qc-usp-close-icon {
border: 0 !important;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 6 6 18'/%3E%3Cpath d='m6 6 12 12'/%3E%3C/svg%3E")
center / 1.5rem 1.5rem no-repeat;
}
a:focus-visible,
button:focus-visible {
outline: 2px solid var(--color-brand) !important;
outline-offset: 2px !important;
}
.qc-usp-ui-content {
max-width: 100% !important;
}
.qc-usp-ui-content .qc-usp-ui-form-content {
border: 1px solid transparent !important;
padding: 0 !important;
}
}
@@ -8,6 +8,7 @@ import {
import { onBeforeUnmount, onMounted } from 'vue'
type ConsentAction = 'accept' | 'reject' | 'manage'
type ConsentVariant = 'tcf' | 'usp'
interface TcfData {
eventStatus?: string
@@ -17,6 +18,19 @@ interface TcfData {
type TcfCallback = (data: TcfData, success: boolean) => void
type TcfApi = (command: string, version: number, callback: TcfCallback, parameter?: unknown) => void
interface UspControls {
toggles: HTMLButtonElement[]
confirmButton: HTMLButtonElement
}
interface GppData {
eventName?: string
listenerId?: number
}
type GppCallback = (data: GppData, success: boolean) => void
type GppApi = (command: string, callback: GppCallback, parameter?: unknown) => void
const CMP_HIDDEN_CLASS = 'modrinth-cmp-summary-hidden'
const notificationManager = injectNotificationManager()
const { formatMessage } = useVIntl()
@@ -47,8 +61,17 @@ const messages = defineMessages({
let notificationId: WebNotification['id'] | null = null
let tcfListenerId: number | undefined
let gppListenerId: number | undefined
let listenerInstalled = false
let gppListenerInstalled = false
let gppListenerAttempts = 0
let gppInstallTimeout: ReturnType<typeof setTimeout> | undefined
let managingPreferences = false
let consentComplete = false
let consentVariant: ConsentVariant | null = null
let uspConsentCommitPending = false
let uspSuccessModalDismissed = false
let uspCommitTimeout: ReturnType<typeof setTimeout> | undefined
let consentContainerObserver: MutationObserver | undefined
const consentContainerContains = new Map<HTMLElement, HTMLElement['contains']>()
@@ -56,31 +79,132 @@ function getTcfApi(): TcfApi | undefined {
return (window as typeof window & { __tcfapi?: TcfApi }).__tcfapi
}
function getGppApi(): GppApi | undefined {
return (window as typeof window & { __gpp?: GppApi }).__gpp
}
function setConsentUiHidden(hidden: boolean) {
document.documentElement.classList.toggle(CMP_HIDDEN_CLASS, hidden)
patchConsentFocusTrapContainer()
}
function patchConsentFocusTrapContainer() {
const container = document.querySelector<HTMLElement>('#qc-cmp2-ui')
if (!container || consentContainerContains.has(container)) return
const containers = document.querySelectorAll<HTMLElement>('#qc-cmp2-ui, #qc-cmp2-usp')
const originalContains = container.contains
// InMobi's focus trap otherwise cancels clicks outside its hidden container.
container.contains = (node: Node | null) =>
document.documentElement.classList.contains(CMP_HIDDEN_CLASS) ||
originalContains.call(container, node)
consentContainerContains.set(container, originalContains)
for (const container of containers) {
if (consentContainerContains.has(container)) continue
const originalContains = container.contains
// InMobi's focus trap otherwise cancels clicks outside its hidden container.
container.contains = (node: Node | null) =>
document.documentElement.classList.contains(CMP_HIDDEN_CLASS) ||
originalContains.call(container, node)
consentContainerContains.set(container, originalContains)
}
}
function detectConsentVariant(): ConsentVariant | null {
let variant: ConsentVariant | null = null
if (document.getElementById('qc-cmp2-usp')) {
variant = 'usp'
} else if (document.getElementById('qc-cmp2-ui')) {
variant = 'tcf'
}
if (variant) consentVariant = variant
return variant
}
function getConsentContainers(): ParentNode[] {
const containers = Array.from(
document.querySelectorAll<HTMLElement>('#qc-cmp2-container, #qc-cmp2-main, #qc-cmp2-ui'),
document.querySelectorAll<HTMLElement>(
'#qc-cmp2-container, #qc-cmp2-main, #qc-cmp2-ui, #qc-cmp2-usp',
),
)
return containers.length > 0 ? containers : [document]
}
function getUspConsentControls(): UspControls | null {
const dialog = document.getElementById('qc-cmp2-usp')
if (!dialog) return null
const toggles = Array.from(
dialog.querySelectorAll<HTMLButtonElement>(
'.qc-usp-container button.qc-cmp2-toggle[role="switch"]',
),
)
const confirmButton = dialog.querySelector<HTMLButtonElement>(
'.qc-usp-ui-form-content button[mode="primary"]',
)
if (
toggles.length === 0 ||
!confirmButton ||
confirmButton.disabled ||
toggles.some(
(toggle) =>
toggle.disabled || !['true', 'false'].includes(toggle.getAttribute('aria-checked') ?? ''),
)
) {
return null
}
return { toggles, confirmButton }
}
function waitForUspToggleState(
index: number,
checked: boolean,
expectedCount: number,
deadline: number,
): Promise<UspControls | null> {
return new Promise((resolve) => {
function checkState() {
const controls = getUspConsentControls()
if (
controls &&
controls.toggles.length === expectedCount &&
controls.toggles[index]?.getAttribute('aria-checked') === String(checked)
) {
resolve(controls)
} else if (Date.now() >= deadline) {
resolve(null)
} else {
setTimeout(checkState, 50)
}
}
checkState()
})
}
async function setUspToggleStates(
checked: boolean,
controls: UspControls,
timeoutMs: number,
): Promise<UspControls | null> {
const expectedCount = controls.toggles.length
const deadline = Date.now() + timeoutMs
for (let index = 0; index < expectedCount; index += 1) {
const currentControls = getUspConsentControls()
if (!currentControls || currentControls.toggles.length !== expectedCount) return null
controls = currentControls
const toggle = controls.toggles[index]
if (toggle.getAttribute('aria-checked') !== String(checked)) {
toggle.click()
const settledControls = await waitForUspToggleState(index, checked, expectedCount, deadline)
if (!settledControls) return null
controls = settledControls
}
}
return controls
}
function matchesButtonText(button: HTMLButtonElement, terms: string[]): boolean {
const text = [button.textContent, button.getAttribute('aria-label')]
.filter(Boolean)
@@ -173,9 +297,29 @@ function clickConsentButtonWhenReady(action: ConsentAction, timeoutMs: number):
}
async function performConsentAction(action: ConsentAction) {
const variant = detectConsentVariant()
if (action === 'manage') {
managingPreferences = true
setConsentUiHidden(false)
if (variant === 'usp') return
}
if (variant === 'usp' && action !== 'manage') {
const controls = getUspConsentControls()
const settledControls = controls
? await setUspToggleStates(action === 'reject', controls, 2000)
: null
if (settledControls) {
beginUspConsentCommit()
settledControls.confirmButton.click()
return
}
managingPreferences = false
setConsentUiHidden(false)
return
}
const clicked = await clickConsentButtonWhenReady(action, action === 'manage' ? 2500 : 1000)
@@ -194,6 +338,8 @@ async function performConsentAction(action: ConsentAction) {
}
function showConsentNotification() {
if (consentComplete) return
setConsentUiHidden(true)
if (
@@ -239,6 +385,10 @@ function showConsentNotification() {
function finishConsent() {
managingPreferences = false
consentComplete = true
uspConsentCommitPending = false
clearTimeout(uspCommitTimeout)
uspCommitTimeout = undefined
setConsentUiHidden(false)
if (notificationId !== null) {
@@ -247,8 +397,67 @@ function finishConsent() {
}
}
function beginUspConsentCommit() {
if (!document.getElementById('qc-cmp2-usp')) return
consentVariant = 'usp'
uspConsentCommitPending = true
uspSuccessModalDismissed = false
clearTimeout(uspCommitTimeout)
const deadline = Date.now() + 2500
function checkForDialogClosure() {
if (!uspConsentCommitPending) return
if (!document.getElementById('qc-cmp2-usp')) {
finishConsent()
} else if (Date.now() >= deadline) {
uspConsentCommitPending = false
uspCommitTimeout = undefined
} else {
uspCommitTimeout = setTimeout(checkForDialogClosure, 50)
}
}
uspCommitTimeout = setTimeout(checkForDialogClosure, 50)
}
function dismissUspSuccessModal() {
if ((!uspConsentCommitPending && !consentComplete) || uspSuccessModalDismissed) return
const closeButton = document.querySelector<HTMLElement>(
'#qc-cmp2-usp [aria-label="Close success modal"]',
)
if (!closeButton || (closeButton instanceof HTMLButtonElement && closeButton.disabled)) return
uspSuccessModalDismissed = true
closeButton.click()
}
function syncConsentUi() {
patchConsentFocusTrapContainer()
installGppConsentListener()
const variant = detectConsentVariant()
dismissUspSuccessModal()
if (
variant === 'usp' &&
!consentComplete &&
!uspConsentCommitPending &&
!managingPreferences &&
getUspConsentControls()
) {
showConsentNotification()
}
if (uspConsentCommitPending && !document.getElementById('qc-cmp2-usp')) {
finishConsent()
}
}
function handleTcfConsentEvent(data: TcfData, success: boolean) {
if (!success) return
detectConsentVariant()
if (data.listenerId !== undefined) {
tcfListenerId = data.listenerId
@@ -258,11 +467,47 @@ function handleTcfConsentEvent(data: TcfData, success: boolean) {
if (!managingPreferences) {
showConsentNotification()
}
} else if (data.eventStatus === 'useractioncomplete') {
} else if (data.eventStatus === 'useractioncomplete' && consentVariant === 'tcf') {
finishConsent()
}
}
function handleGppConsentEvent(data: GppData, success: boolean) {
if (data.listenerId !== undefined) {
gppListenerId = data.listenerId
}
if (
success &&
data.eventName === 'sectionChange' &&
consentVariant === 'usp' &&
uspConsentCommitPending
) {
finishConsent()
}
}
function installGppConsentListener() {
if (gppListenerInstalled) return
const gppApi = getGppApi()
if (!gppApi) {
if (gppListenerAttempts < 60 && gppInstallTimeout === undefined) {
gppListenerAttempts += 1
gppInstallTimeout = setTimeout(() => {
gppInstallTimeout = undefined
installGppConsentListener()
}, 500)
}
return
}
gppListenerInstalled = true
clearTimeout(gppInstallTimeout)
gppInstallTimeout = undefined
gppApi('addEventListener', handleGppConsentEvent)
}
function installTcfConsentListener() {
if (listenerInstalled) return
@@ -273,31 +518,56 @@ function installTcfConsentListener() {
tcfApi('addEventListener', 2, handleTcfConsentEvent)
}
function handleDocumentClick(event: MouseEvent) {
if (!managingPreferences || !(event.target instanceof Element)) return
if (!event.target.closest('.qc-cmp2-close-icon')) return
function restoreConsentNotification() {
setTimeout(() => {
if (notificationId === null) return
managingPreferences = false
setConsentUiHidden(true)
showConsentNotification()
})
}
function handleDocumentClick(event: MouseEvent) {
if (!(event.target instanceof Element)) return
if (event.target.closest('#qc-cmp2-usp .qc-usp-ui-form-content button[mode="primary"]')) {
beginUspConsentCommit()
return
}
if (!managingPreferences) return
if (!event.target.closest('.qc-cmp2-close-icon, .qc-usp-close-icon')) return
restoreConsentNotification()
}
function handleDocumentKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && managingPreferences) {
restoreConsentNotification()
}
}
onMounted(() => {
consentContainerObserver = new MutationObserver(patchConsentFocusTrapContainer)
consentContainerObserver.observe(document.body, { childList: true, subtree: true })
patchConsentFocusTrapContainer()
consentContainerObserver = new MutationObserver(syncConsentUi)
consentContainerObserver.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['aria-label', 'disabled'],
})
syncConsentUi()
installTcfConsentListener()
installGppConsentListener()
window.addEventListener('modrinth-cmp-ready', installTcfConsentListener)
document.addEventListener('click', handleDocumentClick, true)
document.addEventListener('keydown', handleDocumentKeydown, true)
})
onBeforeUnmount(() => {
consentContainerObserver?.disconnect()
clearTimeout(uspCommitTimeout)
clearTimeout(gppInstallTimeout)
window.removeEventListener('modrinth-cmp-ready', installTcfConsentListener)
document.removeEventListener('click', handleDocumentClick, true)
document.removeEventListener('keydown', handleDocumentKeydown, true)
setConsentUiHidden(false)
for (const [container, originalContains] of consentContainerContains) {
container.contains = originalContains
@@ -309,6 +579,11 @@ onBeforeUnmount(() => {
tcfApi('removeEventListener', 2, () => {}, tcfListenerId)
}
const gppApi = getGppApi()
if (gppApi && gppListenerId !== undefined) {
gppApi('removeEventListener', () => {}, gppListenerId)
}
if (notificationId !== null) {
notificationManager.removeNotification(notificationId)
}
@@ -319,7 +594,8 @@ onBeforeUnmount(() => {
html.modrinth-cmp-summary-hidden .qc-cmp2-container,
html.modrinth-cmp-summary-hidden #qc-cmp2-container,
html.modrinth-cmp-summary-hidden #qc-cmp2-main,
html.modrinth-cmp-summary-hidden #qc-cmp2-ui {
html.modrinth-cmp-summary-hidden #qc-cmp2-ui,
html.modrinth-cmp-summary-hidden #qc-cmp2-usp {
display: none !important;
z-index: -1 !important;
pointer-events: none !important;
+2
View File
@@ -43,8 +43,10 @@ flate2 = { workspace = true }
fs4 = { workspace = true, features = ["tokio"] }
futures = { workspace = true, features = ["alloc", "async-await"] }
futures-lite = { workspace = true }
governor = { workspace = true }
heck = { workspace = true }
hickory-resolver = { workspace = true }
httpdate = { workspace = true }
indicatif = { workspace = true, optional = true }
itertools = { workspace = true }
modrinth-content-management = { workspace = true }
+24
View File
@@ -79,6 +79,9 @@ pub enum ErrorKind {
#[error("Too many API errors, try again in {0} minutes")]
ApiIsDownError(u32),
#[error("Too many requests, retry in {}", format_seconds(*.retry_in_seconds))]
Ratelimited { retry_in_seconds: u64 },
#[error("{0}")]
LabrinthError(LabrinthError),
@@ -192,6 +195,27 @@ pub enum ErrorKind {
DiscordRichPresenceError(#[from] discord_rich_presence::error::Error),
}
fn format_seconds(seconds: u64) -> String {
let plural = |unit: u64| if unit == 1 { "" } else { "s" };
if seconds <= 59 {
return format!("{seconds} second{}", plural(seconds));
}
let minutes = seconds / 60;
let rem_seconds = seconds % 60;
if rem_seconds == 0 {
return format!("{minutes} minutes");
}
format!(
"{minutes} minute{} and {rem_seconds} second{}",
plural(minutes),
plural(rem_seconds)
)
}
#[derive(Debug)]
pub struct Error {
pub raw: Arc<ErrorKind>,
+3 -3
View File
@@ -1074,9 +1074,9 @@ fn install_error_code(
PreparingJava => "java_error",
_ => "metadata_error",
},
ErrorKind::FetchError(_) | ErrorKind::ApiIsDownError(_) => {
"network_error"
}
ErrorKind::FetchError(_)
| ErrorKind::ApiIsDownError(_)
| ErrorKind::Ratelimited { .. } => "network_error",
ErrorKind::Any(_)
if matches!(
phase,
+156 -2
View File
@@ -6,6 +6,8 @@ use crate::{ErrorKind, LabrinthError};
use bytes::Bytes;
use chrono::{DateTime, TimeDelta, Utc};
use eyre::{Context, eyre};
use governor::clock::{Clock, DefaultClock};
use governor::{DefaultDirectRateLimiter, Quota, RateLimiter};
use parking_lot::Mutex;
use rand::Rng;
use reqwest::Method;
@@ -14,12 +16,14 @@ use serde::{Deserialize, Serialize};
use std::collections::{HashMap, VecDeque};
use std::ffi::OsStr;
use std::future::Future;
use std::num::NonZeroU32;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::LazyLock;
use std::time::{self};
use std::sync::{Arc, LazyLock};
use std::time::{self, Duration, Instant, SystemTime};
use tokio::sync::Semaphore;
use tokio::{fs::File, io::AsyncReadExt, io::AsyncWriteExt};
use tracing::{debug, info};
pub const DOWNLOAD_META_HEADER: &str = "modrinth-download-meta";
@@ -188,6 +192,145 @@ static GLOBAL_FETCH_FENCE: LazyLock<FetchFence> =
inner: Mutex::new(HashMap::new()),
});
const API_RETRY_AFTER_FALLBACK: Duration = Duration::from_secs(60);
// This means the unit recovery time will be:
// replenish one unit time in seconds = (60 / (units recovered per minute))
// smooth recovery time = replenish one unit time in seconds * API_RATE_LIMIT_RECOVERY_SIZE.
//
// At 20 it means (60 / 120) * 20 = 10 seconds.
const API_RATE_LIMIT_RECOVERY_SIZE: u32 = 20;
/// Implements request-rate-limit handling as well as a local rate limiter of 120 RPM + 50 burst.
struct ApiRateLimit {
block_until: Mutex<Option<Instant>>,
check_lock: Mutex<()>,
local: Arc<DefaultDirectRateLimiter>,
recovery_padding: Duration,
}
impl ApiRateLimit {
fn new() -> Self {
let quota = Quota::per_minute(NonZeroU32::new(120).unwrap())
.allow_burst(NonZeroU32::new(50).unwrap());
let recovery_size =
API_RATE_LIMIT_RECOVERY_SIZE.min(quota.burst_size().get());
Self {
block_until: Mutex::new(None),
check_lock: Mutex::new(()),
local: Arc::new(RateLimiter::direct(quota)),
recovery_padding: quota
.replenish_interval()
.saturating_mul(recovery_size.saturating_sub(1)),
}
}
fn check(&self) -> crate::Result<()> {
let _check_guard = self.check_lock.lock();
self.ensure_not_blocked()?;
if let Err(not_until) = self.local.check() {
// Adds hysteresis to the rate limiting system, ensuring recovery happens
// for longer but for more units, avoiding the "flapping" effect when running
// out of units.
let retry_after = not_until
.wait_time_from(DefaultClock::default().now())
.saturating_add(self.recovery_padding);
info!(
?retry_after,
"Hit builtin rate limiter; waiting for recovery"
);
self.block_for(retry_after);
let retry_in_seconds = self
.retry_in_seconds()
.unwrap_or_else(|| duration_seconds_ceil(retry_after));
return Err(ErrorKind::Ratelimited { retry_in_seconds }.into());
}
self.ensure_not_blocked()
}
fn handle_response(
&self,
response: &reqwest::Response,
) -> Option<ErrorKind> {
if response.status() != reqwest::StatusCode::TOO_MANY_REQUESTS {
return None;
}
debug!("Received 429 response; blocking");
let retry_after = response
.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(|value| value.to_str().ok())
.and_then(|value| parse_retry_after(value, SystemTime::now()))
.unwrap_or(API_RETRY_AFTER_FALLBACK);
self.block_for(retry_after);
Some(ErrorKind::Ratelimited {
retry_in_seconds: self.retry_in_seconds().unwrap_or(0),
})
}
fn ensure_not_blocked(&self) -> crate::Result<()> {
if let Some(retry_in_seconds) = self.retry_in_seconds() {
debug!("Hit builtin rate limiter; blocking");
return Err(ErrorKind::Ratelimited { retry_in_seconds }.into());
}
Ok(())
}
fn block_for(&self, duration: Duration) {
let Some(block_until) = Instant::now().checked_add(duration) else {
return;
};
let mut current_block = self.block_until.lock();
if current_block.is_none_or(|current| current < block_until) {
*current_block = Some(block_until);
}
}
fn retry_in_seconds(&self) -> Option<u64> {
let mut block_until = self.block_until.lock();
let remaining = (*block_until)?.checked_duration_since(Instant::now());
if let Some(remaining) = remaining
&& !remaining.is_zero()
{
return Some(duration_seconds_ceil(remaining));
}
*block_until = None;
None
}
}
static GLOBAL_API_RATE_LIMIT: LazyLock<ApiRateLimit> =
LazyLock::new(ApiRateLimit::new);
fn parse_retry_after(value: &str, now: SystemTime) -> Option<Duration> {
if let Ok(seconds) = value.parse::<u64>() {
return Some(Duration::from_secs(seconds));
}
let retry_at = httpdate::parse_http_date(value).ok()?;
Some(retry_at.duration_since(now).unwrap_or(Duration::ZERO))
}
fn duration_seconds_ceil(duration: Duration) -> u64 {
duration
.as_secs()
.saturating_add(u64::from(duration.subsec_nanos() > 0))
}
fn reqwest_client_builder() -> reqwest::ClientBuilder {
reqwest::Client::builder()
.connect_timeout(time::Duration::from_secs(15))
@@ -452,6 +595,10 @@ async fn fetch_advanced_with_client_and_progress(
.map(|m| (DOWNLOAD_META_HEADER.to_string(), m.to_header_value()));
for attempt in 1..=(FETCH_ATTEMPTS + 1) {
if is_api_url {
GLOBAL_API_RATE_LIMIT.check()?;
}
if let Some(fence_key) = fence_key
&& GLOBAL_FETCH_FENCE.is_blocked(fence_key)
{
@@ -483,6 +630,13 @@ async fn fetch_advanced_with_client_and_progress(
let result = req.send().await;
match result {
Ok(resp) => {
if is_api_url
&& let Some(error) =
GLOBAL_API_RATE_LIMIT.handle_response(&resp)
{
return Err(error.into());
}
if resp.status().is_server_error() {
if let Some(fence_key) = fence_key {
GLOBAL_FETCH_FENCE.record_fail(fence_key);
@@ -17,7 +17,7 @@
@mouseleave="setNotificationTimer(item)"
>
<div
class="flex w-full gap-2 overflow-hidden rounded-lg bg-bg-raised shadow-xl border border-solid border-surface-5"
class="flex w-full gap-2 overflow-hidden rounded-lg bg-bg-raised border border-solid border-surface-5"
:class="item.containerClass"
>
<div
@@ -1,7 +1,10 @@
/*
* Portions of this file are adapted from Ears and the Ears Skin Manipulator:
* https://github.com/exaskye/Ears
* Source revision: 5d050d461d207c1a7cda0de11e2562f6581f7a16
* Portions of this file are adapted from Ears, Ears2, and the Ears Skin Manipulator:
* https://git.sleeping.town/exa.mods/Ears
* https://git.sleeping.town/exa.mods/Ears2
* Source revisions:
* - Ears: 5d050d461d207c1a7cda0de11e2562f6581f7a16
* - Ears2: 8ad9adc213e3b16b1a3306fb4141cfb618c25a6f
*
* MIT License
*
@@ -59,16 +62,26 @@ type WingMode =
| 'ASYMMETRIC_R'
| 'ASYMMETRIC_DUAL'
| 'FLAT'
type WingAnimationMode = 'NORMAL' | 'NONE' | 'NO_FLIGHT'
type LegMode = 'PLANTIGRADE' | 'DIGITIGRADE_PARTIAL' | 'DIGITIGRADE_FULL'
type Protrusions =
| 'NONE'
| 'HORN'
| 'CLAWS'
| 'CLAWS_AND_HORN'
| 'HALO'
| 'DOUBLE_HALO'
| 'CLAWS_AND_HALO'
| 'CLAWS_AND_DOUBLE_HALO'
type BodyPart = 'HEAD' | 'TORSO' | 'LEFT_ARM' | 'RIGHT_ARM' | 'LEFT_LEG' | 'RIGHT_LEG'
type TextureSource = 'skin' | 'wing' | 'cape'
type TextureSource = 'skin' | 'displaced-skin' | 'wing' | 'cape'
type TextureRotation = 'NONE' | 'CW' | 'CCW' | 'UPSIDE_DOWN'
type TextureFlip = 'NONE' | 'HORIZONTAL' | 'VERTICAL' | 'BOTH'
interface EarsFeatures {
earMode: EarMode
earAnchor: EarAnchor
claws: boolean
horn: boolean
protrusions: Protrusions
tailMode: TailMode
tailSegments: number
tailBends: [number, number, number, number]
@@ -78,9 +91,12 @@ interface EarsFeatures {
snoutDepth: number
chestSize: number
wingMode: WingMode
animateWings: boolean
wingAnimationMode: WingAnimationMode
capeEnabled: boolean
emissive: boolean
legMode: LegMode
animateTail: boolean
swapJacketBackAndTail: boolean
}
interface TexturePair {
@@ -150,6 +166,24 @@ const WING_MODES: WingMode[] = [
'ASYMMETRIC_DUAL',
'FLAT',
]
const WING_ANIMATION_MODES: WingAnimationMode[] = ['NORMAL', 'NONE', 'NO_FLIGHT']
const LEG_MODES: LegMode[] = ['PLANTIGRADE', 'DIGITIGRADE_PARTIAL', 'DIGITIGRADE_FULL']
const PROTRUSIONS: Protrusions[] = [
'NONE',
'HORN',
'CLAWS',
'CLAWS_AND_HORN',
'HALO',
'DOUBLE_HALO',
'CLAWS_AND_HALO',
'CLAWS_AND_DOUBLE_HALO',
]
const CLAW_PROTRUSIONS = new Set<Protrusions>([
'CLAWS',
'CLAWS_AND_HORN',
'CLAWS_AND_HALO',
'CLAWS_AND_DOUBLE_HALO',
])
const MAGIC_PIXELS = {
BLUE: 0x3f23d8,
@@ -189,6 +223,54 @@ const FORCED_OPAQUE_REGIONS = [
[16, 52, 48, 64],
] as const
const FORCED_OPAQUE_REGIONS_WITHOUT_LEG_BOTTOM = [
[8, 0, 24, 8],
[0, 8, 32, 16],
[4, 16, 8, 20],
[20, 16, 36, 20],
[44, 16, 52, 20],
[0, 20, 16, 38],
[16, 20, 56, 32],
[20, 48, 24, 52],
[36, 48, 44, 52],
[16, 52, 32, 58],
[32, 52, 48, 64],
] as const
const FORCED_OPAQUE_REGIONS_WITHOUT_LEGS = [
[8, 0, 24, 8],
[0, 8, 32, 16],
[4, 16, 8, 20],
[20, 16, 36, 20],
[44, 16, 52, 20],
[16, 20, 56, 32],
[20, 48, 24, 52],
[36, 48, 44, 52],
[32, 52, 48, 64],
] as const
const LEG_BOTTOM_HALF_REGIONS = [
[24, 48, 28, 52, true],
[16, 58, 32, 64, true],
[8, 16, 12, 20, true],
[0, 26, 16, 32, true],
[8, 32, 12, 36, false],
[0, 42, 16, 48, false],
[8, 48, 12, 52, false],
[0, 58, 16, 64, false],
] as const
const LEG_REGIONS = [
[20, 48, 28, 52, true],
[16, 52, 32, 64, true],
[4, 16, 12, 20, true],
[0, 20, 16, 32, true],
[4, 32, 12, 36, false],
[0, 36, 16, 48, false],
[4, 48, 12, 52, false],
[0, 52, 16, 64, false],
] as const
const BODY_PART_NODES: Record<BodyPart, string> = {
HEAD: 'Head',
TORSO: 'Body',
@@ -303,18 +385,18 @@ function parseV1Features(data: Uint8ClampedArray): EarsFeatures | null {
try {
const reader = new BitReader(new Uint8Array(bytes))
reader.read(8)
const version = reader.read(8)
const ears = reader.read(6)
const earMode = ears === 0 ? 'NONE' : (EAR_MODES[Math.floor((ears - 1) / 3) + 1] ?? 'NONE')
const earAnchor = ears === 0 ? 'CENTER' : (EAR_ANCHORS[(ears - 1) % 3] ?? 'CENTER')
const claws = reader.readBoolean()
const horn = reader.readBoolean()
const tailMode = TAIL_MODES[reader.read(3)] ?? 'NONE'
let protrusionsIndex = reader.read(2)
const tailIndex = reader.read(3)
let tailMode = TAIL_MODES[tailIndex] ?? 'NONE'
let tailSegments = 0
const tailBends: [number, number, number, number] = [0, 0, 0, 0]
if (tailMode !== 'NONE') {
if (tailIndex !== 0) {
tailSegments = reader.read(2) + 1
for (let index = 0; index < tailSegments; index++) {
tailBends[index] = reader.readSignedUnit(6) * 90
@@ -336,12 +418,30 @@ function parseV1Features(data: Uint8ClampedArray): EarsFeatures | null {
const animateWings = wingMode === 'NONE' ? false : reader.readBoolean()
const capeEnabled = reader.readBoolean()
const emissive = reader.readBoolean()
let wingAnimationMode: WingAnimationMode = animateWings ? 'NORMAL' : 'NONE'
let legMode: LegMode = 'PLANTIGRADE'
let animateTail = true
let swapJacketBackAndTail = false
if (version >= 1 && tailIndex === 7) {
tailMode = TAIL_MODES[tailIndex + reader.read(3)] ?? 'NONE'
}
if (version >= 2) {
legMode = LEG_MODES[reader.read(3)] ?? 'PLANTIGRADE'
if (wingMode !== 'NONE') {
wingAnimationMode = WING_ANIMATION_MODES[reader.read(3)] ?? wingAnimationMode
}
animateTail = reader.readBoolean()
swapJacketBackAndTail = reader.readBoolean()
}
if (version >= 3) {
protrusionsIndex |= reader.read(2) << 2
}
return {
earMode,
earAnchor,
claws,
horn,
protrusions: PROTRUSIONS[protrusionsIndex] ?? 'NONE',
tailMode,
tailSegments,
tailBends,
@@ -351,9 +451,12 @@ function parseV1Features(data: Uint8ClampedArray): EarsFeatures | null {
snoutDepth,
chestSize,
wingMode,
animateWings,
wingAnimationMode,
capeEnabled,
emissive,
legMode,
animateTail,
swapJacketBackAndTail,
}
} catch {
return null
@@ -406,12 +509,31 @@ function parseV0Features(data: Uint8ClampedArray): EarsFeatures {
[MAGIC_PIXELS.PURPLE, 'ASYMMETRIC_DUAL'],
[MAGIC_PIXELS.PURPLE2, 'FLAT'],
])
const protrusionsByPixel = new Map<number, Protrusions>([
[MAGIC_PIXELS.BLUE, 'NONE'],
[MAGIC_PIXELS.RED, 'NONE'],
[MAGIC_PIXELS.GREEN, 'CLAWS'],
[MAGIC_PIXELS.PURPLE, 'HORN'],
[MAGIC_PIXELS.CYAN, 'CLAWS_AND_HORN'],
[MAGIC_PIXELS.WHITE, 'HALO'],
[MAGIC_PIXELS.GRAY, 'DOUBLE_HALO'],
[MAGIC_PIXELS.PURPLE2, 'CLAWS_AND_HALO'],
[MAGIC_PIXELS.PINK, 'CLAWS_AND_DOUBLE_HALO'],
])
const wingAnimationModeByPixel = new Map<number, WingAnimationMode>([
[MAGIC_PIXELS.BLUE, 'NORMAL'],
[MAGIC_PIXELS.RED, 'NONE'],
[MAGIC_PIXELS.GREEN, 'NO_FLIGHT'],
])
const legModeByPixel = new Map<number, LegMode>([
[MAGIC_PIXELS.BLUE, 'PLANTIGRADE'],
[MAGIC_PIXELS.GREEN, 'DIGITIGRADE_PARTIAL'],
[MAGIC_PIXELS.PINK, 'DIGITIGRADE_FULL'],
])
const earMode = earModeByPixel.get(getConfigPixel(data, 1).rgb) ?? 'NONE'
const earAnchor = earAnchorByPixel.get(getConfigPixel(data, 2).rgb) ?? 'CENTER'
const protrusions = getConfigPixel(data, 3).rgb
const claws = protrusions === MAGIC_PIXELS.GREEN || protrusions === MAGIC_PIXELS.CYAN
const horn = protrusions === MAGIC_PIXELS.PURPLE || protrusions === MAGIC_PIXELS.CYAN
const protrusions = protrusionsByPixel.get(getConfigPixel(data, 3).rgb) ?? 'NONE'
const tailMode = tailModeByPixel.get(getConfigPixel(data, 4).rgb) ?? 'NONE'
const tailPixel = getConfigPixel(data, 5)
const tailBends: [number, number, number, number] = [0, 0, 0, 0]
@@ -444,12 +566,13 @@ function parseV0Features(data: Uint8ClampedArray): EarsFeatures {
const etcIsDefault = etcPixel.rgb === MAGIC_PIXELS.BLUE
const chestSize = etcIsDefault ? 0 : Math.min(etcPixel.r / 128, 1)
const capeEnabled = !etcIsDefault && (etcPixel.b & 16) !== 0
const bitflags = getConfigPixel(data, 12).rgb
const bitflagsAreDefault = bitflags === MAGIC_PIXELS.BLUE
return {
earMode,
earAnchor,
claws,
horn,
protrusions,
tailMode,
tailSegments,
tailBends,
@@ -459,9 +582,12 @@ function parseV0Features(data: Uint8ClampedArray): EarsFeatures {
snoutDepth,
chestSize,
wingMode: wingModeByPixel.get(getConfigPixel(data, 8).rgb) ?? 'NONE',
animateWings: getConfigPixel(data, 9).rgb !== MAGIC_PIXELS.RED,
wingAnimationMode: wingAnimationModeByPixel.get(getConfigPixel(data, 9).rgb) ?? 'NORMAL',
capeEnabled,
emissive: getConfigPixel(data, 10).rgb === MAGIC_PIXELS.ORANGE,
legMode: legModeByPixel.get(getConfigPixel(data, 11).rgb) ?? 'PLANTIGRADE',
animateTail: bitflagsAreDefault || (bitflags & 1) !== 0,
swapJacketBackAndTail: !bitflagsAreDefault && (bitflags & 2) !== 0,
}
}
@@ -567,8 +693,14 @@ function applyEraseData(data: Uint8ClampedArray, eraseData: Uint8Array | undefin
}
}
function forceOpaqueSkinRegions(data: Uint8ClampedArray) {
for (const [x1, y1, x2, y2] of FORCED_OPAQUE_REGIONS) {
function forceOpaqueSkinRegions(data: Uint8ClampedArray, legMode: LegMode) {
const regions =
legMode === 'DIGITIGRADE_FULL'
? FORCED_OPAQUE_REGIONS_WITHOUT_LEGS
: legMode === 'DIGITIGRADE_PARTIAL'
? FORCED_OPAQUE_REGIONS_WITHOUT_LEG_BOTTOM
: FORCED_OPAQUE_REGIONS
for (const [x1, y1, x2, y2] of regions) {
for (let x = x1; x < x2; x++) {
for (let y = y1; y < y2; y++) {
data[pixelOffset(x, y) + 3] = 255
@@ -577,6 +709,44 @@ function forceOpaqueSkinRegions(data: Uint8ClampedArray) {
}
}
function displaceDigitigradeLegPixels(data: Uint8ClampedArray, legMode: LegMode) {
if (legMode === 'PLANTIGRADE') return null
const displaced = new Uint8ClampedArray(data.length)
const regions = legMode === 'DIGITIGRADE_FULL' ? LEG_REGIONS : LEG_BOTTOM_HALF_REGIONS
for (const [x1, y1, x2, y2, forceOpaque] of regions) {
for (let x = x1; x < x2; x++) {
for (let y = y1; y < y2; y++) {
const offset = pixelOffset(x, y)
displaced.set(data.subarray(offset, offset + 4), offset)
if (forceOpaque) displaced[offset + 3] = 255
data.fill(0, offset, offset + 4)
}
}
}
return displaced
}
function swapJacketBackAndTailPixels(data: Uint8ClampedArray) {
const tail = new Uint8ClampedArray(8 * 12 * 4)
for (let x = 0; x < 8; x++) {
for (let y = 0; y < 12; y++) {
const tailOffset = pixelOffset(56 + x, 16 + y)
const savedOffset = (y * 8 + x) * 4
tail.set(data.subarray(tailOffset, tailOffset + 4), savedOffset)
const jacketOffset = pixelOffset(32 + x, 36 + (11 - y))
data.set(data.subarray(jacketOffset, jacketOffset + 4), tailOffset)
}
}
for (let x = 0; x < 8; x++) {
for (let y = 0; y < 12; y++) {
const savedOffset = (y * 8 + x) * 4
data.set(tail.subarray(savedOffset, savedOffset + 4), pixelOffset(32 + x, 36 + y))
}
}
}
function getEmissivePalette(data: Uint8ClampedArray) {
const palette = new Set<number>()
for (let x = 52; x < 56; x++) {
@@ -857,7 +1027,22 @@ class EarsGeometryBuilder {
flip: TextureFlip = 'NONE',
grow = 0,
) {
this.quad(u, v, width, height, rotation, flip, grow, false)
this.quad(u, v, width, height, rotation, flip, grow, false, 0, 0, 0)
}
frontSkew(
u: number,
v: number,
width: number,
height: number,
xSkew: number,
ySkew: number,
zSkew: number,
rotation: TextureRotation = 'NONE',
flip: TextureFlip = 'NONE',
grow = 0,
) {
this.quad(u, v, width, height, rotation, flip, grow, false, xSkew, ySkew, zSkew)
}
back(
@@ -869,7 +1054,7 @@ class EarsGeometryBuilder {
flip: TextureFlip = 'NONE',
grow = 0,
) {
this.quad(u, v, width, height, rotation, flipHorizontally(flip), grow, true)
this.quad(u, v, width, height, rotation, flipHorizontally(flip), grow, true, 0, 0, 0)
}
doubleSided(
@@ -905,42 +1090,46 @@ class EarsGeometryBuilder {
flip: TextureFlip,
grow: number,
back: boolean,
xSkew: number,
ySkew: number,
zSkew: number,
) {
const anchor = this.state.anchor
const textures = this.textures.get(this.textureSource)
if (!anchor || !textures) return
const textureWidth = this.textureSource === 'skin' ? 64 : 20
const textureHeight = this.textureSource === 'skin' ? 64 : 16
const skinTexture = this.textureSource === 'skin' || this.textureSource === 'displaced-skin'
const textureWidth = skinTexture ? 64 : 20
const textureHeight = skinTexture ? 64 : 16
const uvs = calculateUvs(u, v, width, height, rotation, flip, textureWidth, textureHeight)
const positions = back
? [
grow + xSkew,
grow + ySkew,
-zSkew,
-width - grow + xSkew,
grow,
grow,
0,
-width - grow,
grow,
0,
-width - grow,
-zSkew,
-width - grow - xSkew,
-height - grow,
0,
grow,
-height - grow,
0,
zSkew,
grow - xSkew,
-height - grow + ySkew,
zSkew,
]
: [
grow,
-height - grow,
0,
-width - grow,
-height - grow,
0,
-width - grow,
grow,
0,
grow,
grow,
0,
grow - xSkew,
-height - grow + ySkew,
zSkew,
-width - grow - xSkew,
-height - grow - ySkew,
zSkew,
-width - grow + xSkew,
grow - ySkew,
-zSkew,
grow + xSkew,
grow + ySkew,
-zSkew,
]
const orderedUvs = back ? [uvs[3], uvs[2], uvs[1], uvs[0]] : [uvs[0], uvs[1], uvs[2], uvs[3]]
const geometry = new THREE.BufferGeometry()
@@ -1213,18 +1402,24 @@ function renderTail(builder: EarsGeometryBuilder, features: EarsFeatures) {
builder.pop()
}
function renderClawsAndHorn(builder: EarsGeometryBuilder, features: EarsFeatures, slim: boolean) {
if (features.claws) {
function renderProtrusions(builder: EarsGeometryBuilder, features: EarsFeatures, slim: boolean) {
if (CLAW_PROTRUSIONS.has(features.protrusions)) {
const legOffset =
features.legMode === 'DIGITIGRADE_FULL'
? -1.5
: features.legMode === 'DIGITIGRADE_PARTIAL'
? -1
: 0
builder.push()
builder.anchorTo('LEFT_LEG')
builder.translate(0, 0, -4)
builder.translate(0, 0, -4 + legOffset)
builder.rotate(90, 1, 0, 0)
builder.doubleSided(16, 48, 4, 4, 'NONE', 'HORIZONTAL')
builder.pop()
builder.push()
builder.anchorTo('RIGHT_LEG')
builder.translate(0, 0, -4)
builder.translate(0, 0, -4 + legOffset)
builder.rotate(90, 1, 0, 0)
builder.doubleSided(0, 16, 4, 4, 'NONE', 'HORIZONTAL')
builder.pop()
@@ -1244,7 +1439,7 @@ function renderClawsAndHorn(builder: EarsGeometryBuilder, features: EarsFeatures
builder.pop()
}
if (features.horn) {
if (features.protrusions === 'HORN' || features.protrusions === 'CLAWS_AND_HORN') {
builder.push()
builder.anchorTo('HEAD')
builder.translate(0, -8, 0)
@@ -1253,6 +1448,172 @@ function renderClawsAndHorn(builder: EarsGeometryBuilder, features: EarsFeatures
builder.doubleSided(56, 0, 8, 8)
builder.pop()
}
if (features.protrusions === 'HALO' || features.protrusions === 'CLAWS_AND_HALO') {
builder.push()
builder.anchorTo('HEAD')
builder.translate(0, -12, 0)
builder.rotate(90, 1, 0, 0)
builder.doubleSided(56, 0, 8, 8)
builder.pop()
}
if (features.protrusions === 'DOUBLE_HALO' || features.protrusions === 'CLAWS_AND_DOUBLE_HALO') {
builder.push()
builder.anchorTo('HEAD')
builder.translate(0, -10, 0)
builder.rotate(90, 1, 0, 0)
for (let index = 0; index < 2; index++) {
builder.translate(4, 4, 0)
builder.rotate(45, 0, 0, 1)
builder.translate(-4, -4, 2)
builder.doubleSided(56, 0, 8, 8)
}
builder.pop()
}
}
function drawDigitigradeLeg(
builder: EarsGeometryBuilder,
u: number,
v: number,
grow: number,
bottom: boolean,
mend: boolean,
) {
const width = 4
const height = 12
const depth = 4
const doubleGrow = grow * 2
const skew = 1
const vOffset = bottom ? 10 : 4
builder.push()
builder.translate(width / 2, height / 2, depth / 2)
builder.scale(
(width + doubleGrow) / width,
(height + doubleGrow) / height,
(depth + doubleGrow) / depth,
)
builder.translate(-width / 2, -height / 2, -depth / 2)
builder.push()
builder.translate(0, bottom ? -6 : -12, 0)
if (!mend && bottom) {
builder.push()
builder.translate(0, 2, 0)
builder.frontSkew(u + 4, v + vOffset + 2, 4, 4, 0, 0, -skew)
builder.pop()
} else {
builder.frontSkew(u + 4, v + vOffset, 4, 6, 0, 0, -skew)
}
if (mend && !bottom) {
builder.push()
builder.translate(0, 6, 0)
builder.frontSkew(u + 4, v + vOffset + 6, 4, 2, 0, 0, skew)
builder.pop()
}
builder.rotate(-90, 0, 1, 0)
builder.translate(0, 0, -4)
if (!mend && bottom) {
builder.push()
builder.translate(0, 2, 0)
builder.frontSkew(u + 8, v + vOffset + 2, 4, 4, -skew, 0, 0)
builder.pop()
} else {
builder.frontSkew(u + 8, v + vOffset, 4, 6, -skew, 0, 0)
}
if (mend && !bottom) {
builder.push()
builder.translate(0, 6, 0)
builder.frontSkew(u + 8, v + vOffset + 6, 4, 2, skew, 0, 0)
builder.pop()
}
builder.rotate(-90, 0, 1, 0)
builder.translate(0, 0, -4)
if (!mend && bottom) {
builder.push()
builder.translate(0, 2, 0)
builder.frontSkew(u + 12, v + vOffset + 2, 4, 4, 0, 0, skew)
builder.pop()
} else {
builder.frontSkew(u + 12, v + vOffset, 4, 6, 0, 0, skew)
}
if (mend && !bottom) {
builder.push()
builder.translate(0, 6, 0)
builder.frontSkew(u + 12, v + vOffset + 5, 4, 2, 0, 0, -skew)
builder.pop()
}
builder.rotate(-90, 0, 1, 0)
builder.translate(0, 0, -4)
if (!mend && bottom) {
builder.push()
builder.translate(0, 2, 0)
builder.frontSkew(u, v + vOffset + 2, 4, 4, skew, 0, 0)
builder.pop()
} else {
builder.frontSkew(u, v + vOffset, 4, 6, skew, 0, 0)
}
if (mend && !bottom) {
builder.push()
builder.translate(0, 6, 0)
builder.frontSkew(u, v + vOffset + 6, 4, 2, -skew, 0, 0)
builder.pop()
}
builder.pop()
builder.push()
builder.rotate(90, 1, 0, 0)
if (bottom) {
builder.push()
builder.translate(0, -skew, 0)
builder.front(u + 8, v, 4, 4, 'NONE', 'VERTICAL')
builder.pop()
} else {
builder.push()
builder.translate(0, skew, 12)
builder.back(u + 8, v, 4, 4, 'NONE', 'VERTICAL')
builder.pop()
}
if (mend && bottom) {
builder.translate(0, 0, 6)
builder.front(u + 4, v + vOffset, 4, 1)
builder.translate(0, 4, 0)
builder.back(u + 12, v + vOffset, 4, 1)
}
builder.pop()
builder.pop()
}
function renderDigitigradeLegs(builder: EarsGeometryBuilder, features: EarsFeatures) {
if (features.legMode === 'PLANTIGRADE') return
const full = features.legMode === 'DIGITIGRADE_FULL'
builder.bind('displaced-skin')
for (let layer = 0; layer < 2; layer++) {
const grow = layer === 0 ? 0 : 0.25
builder.push()
builder.anchorTo('LEFT_LEG')
if (full) builder.translate(0, 0, -0.5)
if (layer === 1) builder.translate(0, 0.5, 0)
if (full) drawDigitigradeLeg(builder, layer === 0 ? 16 : 0, 48, grow, false, true)
drawDigitigradeLeg(builder, layer === 0 ? 16 : 0, 48, grow, true, !full)
builder.pop()
builder.push()
builder.anchorTo('RIGHT_LEG')
if (full) builder.translate(0, 0, -0.5)
if (layer === 1) builder.translate(0, 0.5, 0)
if (full) drawDigitigradeLeg(builder, 0, layer === 0 ? 16 : 32, grow, false, true)
drawDigitigradeLeg(builder, 0, layer === 0 ? 16 : 32, grow, true, !full)
builder.pop()
}
builder.bind('skin')
}
function renderSnout(builder: EarsGeometryBuilder, features: EarsFeatures) {
@@ -1366,7 +1727,7 @@ function renderWings(builder: EarsGeometryBuilder, features: EarsFeatures) {
const mode = features.wingMode
if (mode === 'NONE') return
const wiggle = features.animateWings ? Math.sin(8 / 12) * 2 : 0
const wiggle = features.wingAnimationMode !== 'NONE' ? Math.sin(8 / 12) * 2 : 0
builder.push()
builder.anchorTo('TORSO')
builder.bind('wing')
@@ -1604,7 +1965,9 @@ export function applyEarsMod(model: THREE.Object3D, sourceTexture: THREE.Texture
const alfalfa = parseAlfalfa(sourceImage.data)
const processedData = new Uint8ClampedArray(sourceImage.data)
applyEraseData(processedData, alfalfa.get('erase'))
forceOpaqueSkinRegions(processedData)
const displacedData = displaceDigitigradeLegPixels(processedData, features.legMode)
if (features.swapJacketBackAndTail) swapJacketBackAndTailPixels(processedData)
forceOpaqueSkinRegions(processedData, features.legMode)
const palette = features.emissive ? getEmissivePalette(processedData) : new Set<number>()
if (palette.size === 0) features.emissive = false
@@ -1623,6 +1986,20 @@ export function applyEarsMod(model: THREE.Object3D, sourceTexture: THREE.Texture
if (skinTextures.emissive) addEmissiveBodyOverlays(model, skinTextures.emissive, resources)
const textures = new Map<TextureSource, TexturePair>([['skin', skinTextures]])
if (displacedData) {
const displacedPixels = features.emissive
? splitEmissivePixels(displacedData, palette)
: { base: displacedData, emissive: undefined }
const displacedTextures: TexturePair = {
base: createCanvasTexture(displacedPixels.base, SKIN_SIZE, SKIN_SIZE),
emissive: displacedPixels.emissive
? createCanvasTexture(displacedPixels.emissive, SKIN_SIZE, SKIN_SIZE)
: undefined,
}
resources.featureTextures.add(displacedTextures.base)
if (displacedTextures.emissive) resources.featureTextures.add(displacedTextures.emissive)
textures.set('displaced-skin', displacedTextures)
}
const wingData = alfalfa.get('wing')
const wingDimensions = wingData ? readPngDimensions(wingData) : null
if (
@@ -1659,10 +2036,11 @@ export function applyEarsMod(model: THREE.Object3D, sourceTexture: THREE.Texture
const builder = new EarsGeometryBuilder(anchors, textures, resources)
renderEars(builder, features)
renderTail(builder, features)
renderClawsAndHorn(builder, features, isSlimModel(model))
renderProtrusions(builder, features, isSlimModel(model))
renderSnout(builder, features)
renderChest(builder, features)
renderWings(builder, features)
renderCape(builder, features)
renderDigitigradeLegs(builder, features)
return true
}