mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 21:26:40 +00:00
Merge branch 'main' into chyz/misused-slug-quick-fix-patch
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -582,6 +582,8 @@ pub async fn init_ads_window<R: Runtime>(
|
||||
}
|
||||
})?;
|
||||
|
||||
webview.open_devtools();
|
||||
|
||||
Some(webview)
|
||||
} else {
|
||||
None
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user