diff --git a/src/environment.dev.ts b/src/environment.dev.ts index feed5bff..1db4c7c5 100644 --- a/src/environment.dev.ts +++ b/src/environment.dev.ts @@ -7,4 +7,5 @@ export const environment = { }, reverse_watch_base_api_url: 'http://localhost:3434/api', floatdb_gateway_url: 'https://gateway.floatdb.com', + skincraft_embed_origin: 'https://localhost:3000', }; diff --git a/src/environment.staging.ts b/src/environment.staging.ts index a5fd5eef..900749cf 100644 --- a/src/environment.staging.ts +++ b/src/environment.staging.ts @@ -7,4 +7,5 @@ export const environment = { }, reverse_watch_base_api_url: 'https://reverse.watch/api', floatdb_gateway_url: 'https://gateway.floatdb.com', + skincraft_embed_origin: 'https://skincraft.gg', }; diff --git a/src/environment.ts b/src/environment.ts index 8879f056..0ef408c8 100644 --- a/src/environment.ts +++ b/src/environment.ts @@ -7,4 +7,5 @@ export const environment = { }, reverse_watch_base_api_url: 'https://reverse.watch/api', floatdb_gateway_url: 'https://gateway.floatdb.com', + skincraft_embed_origin: 'https://skincraft.gg', }; diff --git a/src/lib/components/inventory/selected_item_info.ts b/src/lib/components/inventory/selected_item_info.ts index cbb6a3e3..8fde88f9 100644 --- a/src/lib/components/inventory/selected_item_info.ts +++ b/src/lib/components/inventory/selected_item_info.ts @@ -23,6 +23,12 @@ import {Contract} from '../../types/float_market'; import '../common/ui/floatbar'; import {ClientSend} from '../../bridge/client'; import {FetchBluegem, FetchBluegemResponse} from '../../bridge/handlers/fetch_bluegem'; +import {environment} from '../../../environment'; +import {gSkinCraftEmbed} from '../../services/skincraft_embed'; +import {getActiveInventoryAssetProperties, toSkinCraftItem} from '../../services/skincraft_inventory_targets'; +import type {SkinCraftItem} from '../../services/skincraft_viewer_protocol'; +import {gWebGpuAvailability} from '../../services/webgpu_availability'; +import type {WebGpuAvailability} from '../../services/webgpu_availability'; import './list_item_modal'; /** @@ -48,6 +54,13 @@ export class SelectedItemInfo extends FloatElement { margin-bottom: 10px; } + .market-btn-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + } + .market-btn-container { margin: 10px 0 10px 0; padding: 5px; @@ -64,6 +77,43 @@ export class SelectedItemInfo extends FloatElement { color: #ebebeb; text-decoration: none; } + + .view-3d-btn { + display: inline-flex; + align-items: center; + gap: 6px; + height: 32px; + padding: 0 12px; + font-size: 13px; + font-weight: 600; + color: #f5f8ff; + background-color: #5155eb; + border: 0; + border-radius: 8px; + cursor: pointer; + font-family: inherit; + text-decoration: none; + transition: + filter 180ms ease, + transform 180ms ease; + box-shadow: + 0 4px 12px hsl(0 0% 0% / 0.22), + 0 1px 2px hsl(0 0% 0% / 0.16), + inset 0 1px 0 rgba(255, 255, 255, 0.22); + } + + .view-3d-btn img { + filter: brightness(0) invert(1); + } + + .view-3d-btn:hover { + filter: brightness(1.1); + } + + .view-3d-btn:active { + transform: scale(0.98); + filter: brightness(0.95); + } `, ]; @@ -79,6 +129,9 @@ export class SelectedItemInfo extends FloatElement { @state() private showListModal: boolean = false; + @state() + private webGpuStatus: WebGpuAvailability = gWebGpuAvailability.status; + private bluegemData: FetchBluegemResponse | undefined; get asset(): InventoryAsset | undefined { @@ -151,8 +204,14 @@ export class SelectedItemInfo extends FloatElement { ); } + if (this.canListOnCSFloat || this.canViewInSkinCraft) { + containerChildren.push( + html`
${this.renderListOnCSFloat()} ${this.renderViewIn3D()}
` + ); + } + if (isSellableOnCSFloat(this.asset.description)) { - containerChildren.push(html`${this.renderListOnCSFloat()} ${this.renderFloatMarketListing()}`); + containerChildren.push(this.renderFloatMarketListing()); } if (containerChildren.length === 0) { @@ -216,19 +275,39 @@ export class SelectedItemInfo extends FloatElement { `; } - renderListOnCSFloat(): TemplateResult<1> { - if (this.stallListing) { - // Don't tell them to list it if it's already listed... - return html``; - } + private get canListOnCSFloat(): boolean { + return ( + !!this.asset?.description && + isSellableOnCSFloat(this.asset.description) && + !this.stallListing && + g_ActiveInventory?.m_owner?.strSteamId === g_steamID && + !!this.asset.description.tradable + ); + } - if (g_ActiveInventory?.m_owner?.strSteamId !== g_steamID) { - // Not the signed-in user, don't show + private get skinCraftItem(): SkinCraftItem | undefined { + return toSkinCraftItem(this.asset, getActiveInventoryAssetProperties(g_ActiveInventory, this.asset?.assetid)); + } + + private get canViewInSkinCraft(): boolean { + return this.webGpuStatus === 'available' && !!this.skinCraftItem; + } + + renderViewIn3D(): TemplateResult<1> { + if (!this.canViewInSkinCraft) { return html``; } - if (!this.asset?.description?.tradable) { - // Don't show if item isn't tradable + return html` + + `; + } + + renderListOnCSFloat(): TemplateResult<1> { + if (!this.canListOnCSFloat) { return html``; } @@ -249,6 +328,11 @@ export class SelectedItemInfo extends FloatElement { `; } + private handleViewIn3D(): void { + const item = this.skinCraftItem; + if (item) gSkinCraftEmbed.open(item); + } + async processSelectChange() { // Reset state in-case they swap between skin and non-skin this.itemInfo = undefined; @@ -289,9 +373,16 @@ export class SelectedItemInfo extends FloatElement { this.loading = false; } + private async resolveWebGpuStatus(): Promise { + if (this.webGpuStatus === 'checking') this.webGpuStatus = await gWebGpuAvailability.settled(); + } + connectedCallback() { super.connectedCallback(); + // Settles once per session; the 3D button stays hidden until it does + void this.resolveWebGpuStatus(); + // For the initial load, in case an item is pre-selected this.processSelectChange(); diff --git a/src/lib/components/inventory/skincraft_viewer_modal.ts b/src/lib/components/inventory/skincraft_viewer_modal.ts new file mode 100644 index 00000000..f361196c --- /dev/null +++ b/src/lib/components/inventory/skincraft_viewer_modal.ts @@ -0,0 +1,395 @@ +import {html, nothing, render} from 'lit'; +import type {TemplateResult} from 'lit'; +import {classMap} from 'lit/directives/class-map.js'; +import {guard} from 'lit/directives/guard.js'; +import {createRef, ref} from 'lit/directives/ref.js'; +import {styleMap} from 'lit/directives/style-map.js'; +import type {SkinCraftViewerTarget} from '../../services/skincraft_viewer_protocol'; +import {MODAL_TRANSITION_MS, skinCraftViewerModalStyles} from './skincraft_viewer_modal_styles'; + +type LoadPhase = 'loading' | 'revealed' | 'error'; + +function mixHexColors(base: string, tint: string, tintAmount: number): string { + const mixChannel = (offset: number): number => { + const baseChannel = Number.parseInt(base.slice(offset, offset + 2), 16); + const tintChannel = Number.parseInt(tint.slice(offset, offset + 2), 16); + return Math.round(baseChannel + (tintChannel - baseChannel) * tintAmount); + }; + + return `rgb(${mixChannel(0)} ${mixChannel(2)} ${mixChannel(4)})`; +} + +function inventoryCardColors(target: SkinCraftViewerTarget): Record { + const base = target.backgroundColor || '2a2f3a'; + const rarity = target.rarityColor || 'c1ceff'; + + return { + '--inventory-card-background': target.backgroundColor ? `#${base}` : mixHexColors(base, rarity, 0.1), + '--inventory-card-rarity': `#${rarity}`, + '--inventory-card-hover': mixHexColors(base, rarity, 0.16), + '--inventory-card-selected': mixHexColors(base, rarity, 0.26), + }; +} + +function targetKey(target: SkinCraftViewerTarget): string { + return target.assetId || target.inspect; +} + +/** + * Renders with lit-html instead of extending `FloatElement`: the content script builds this modal, + * and Chrome leaves `customElements` null in isolated worlds, so no custom element can be defined + * here. `render()` needs nothing but the DOM, and the embed iframe is created once and then only + * ever diffed, so it is never re-`src`'d. + */ +export class SkinCraftViewerModal { + readonly element = document.createElement('div'); + + private readonly root: ShadowRoot; + private readonly dialogRef = createRef(); + private readonly frameRef = createRef(); + + private target?: SkinCraftViewerTarget; + private inventoryTargets: SkinCraftViewerTarget[] = []; + private phase: LoadPhase = 'loading'; + private progress: number | null = null; + private errorMessage = ''; + private entering = false; + private closing = false; + private iconReady = false; + private backdropPressed = false; + private closeTimer?: number; + private entryFrame?: number; + private iconRequest = 0; + + constructor( + private readonly embedSrc: string, + private readonly onClose: () => void, + private readonly onRetry: () => void, + private readonly onSelect: (target: SkinCraftViewerTarget) => void + ) { + // Closed so page scripts can't reach into the viewer we host on their document. + this.root = this.element.attachShadow({mode: 'closed'}); + const style = document.createElement('style'); + style.textContent = skinCraftViewerModalStyles; + this.root.appendChild(style); + this.update(); + } + + get frameWindow(): Window | null { + return this.frameRef.value?.contentWindow ?? null; + } + + get isOpen(): boolean { + return this.dialogRef.value?.open ?? false; + } + + setInventory(targets: SkinCraftViewerTarget[]): void { + this.inventoryTargets = targets; + this.update(); + } + + show(target: SkinCraftViewerTarget): void { + this.target = target; + this.iconReady = false; + const request = ++this.iconRequest; + + this.cancelClose(); + const opening = !this.isOpen; + if (opening) this.entering = true; + this.update(); + + void this.revealIconWhenDecoded(target.iconUrl, request); + this.scrollSelectedIntoView(); + if (opening) this.openDialog(); + } + + hide(): void { + if (!this.isOpen || this.closing) return; + + this.cancelEntry(); + this.entering = false; + this.closing = true; + this.update(); + this.closeTimer = window.setTimeout(() => this.finishClose(), MODAL_TRANSITION_MS); + } + + setLoading(value: number | null): void { + this.phase = 'loading'; + this.progress = value; + this.update(); + } + + /** Reveals the embed and fades the loading cover out, whether the load just finished or is still running. */ + showFrame(): void { + this.phase = 'revealed'; + this.update(); + } + + setError(message: string): void { + this.phase = 'error'; + this.errorMessage = message; + this.update(); + } + + // `host` binds `this` inside the template's @event handlers, the way LitElement does it. + private update(): void { + render(this.template(), this.root, {host: this}); + } + + private template(): TemplateResult { + const revealed = this.phase === 'revealed'; + + return html` + + + + + `; + } + + private get selectedKey(): string | undefined { + return this.target ? targetKey(this.target) : undefined; + } + + // Both status blocks stay mounted and toggle `hidden` so the item icon survives an error → + // retry cycle; its fade-in is driven by a decode() that needs the to already exist. + private renderLoading(): TemplateResult { + const determinate = this.progress !== null; + const percent = Math.round(this.progress ?? 0); + + return html` +
+ ${this.target?.iconUrl + ? html`` + : nothing} +
${this.target?.name ?? ''}
+
+
+
+
+
+ ${determinate ? `${percent}%` : ''} +
+
+ `; + } + + private renderError(): TemplateResult { + return html` +
+
${this.errorMessage}
+ +
+ `; + } + + private renderInventoryCards(): TemplateResult[] { + const selectedKey = this.selectedKey; + + return this.inventoryTargets.map((target, index) => { + const selected = targetKey(target) === selectedKey; + + return html` + + `; + }); + } + + private openDialog(): void { + const dialog = this.dialogRef.value; + if (!dialog || dialog.open) return; + + dialog.showModal(); + // Two frames: `entering` has to be painted before it is removed, or the transition never runs. + this.entryFrame = requestAnimationFrame(() => { + this.entryFrame = requestAnimationFrame(() => { + this.entryFrame = undefined; + this.entering = false; + this.update(); + this.focusViewer(); + }); + }); + } + + private async revealIconWhenDecoded(iconUrl: string | undefined, request: number): Promise { + if (!iconUrl) return; + + const icon = this.root.querySelector('.item-icon'); + if (!icon || icon.src !== iconUrl) return; + + const decoded = await icon.decode().then( + () => true, + () => false + ); + if (!decoded || request !== this.iconRequest) return; + + requestAnimationFrame(() => { + if (request !== this.iconRequest) return; + this.iconReady = true; + this.update(); + }); + } + + private scrollSelectedIntoView(): void { + const card = this.root.querySelector('.inventory-card.selected'); + if (card) requestAnimationFrame(() => card.scrollIntoView({block: 'nearest', inline: 'nearest'})); + } + + private focusViewer(): void { + this.frameRef.value?.focus({preventScroll: true}); + } + + private handleCancel(event: Event): void { + event.preventDefault(); + this.onClose(); + } + + // A backdrop hit lands on the itself with coordinates outside its rect. + private isBackdropEvent(event: MouseEvent): boolean { + const dialog = this.dialogRef.value; + if (!dialog || event.target !== dialog) return false; + + const rect = dialog.getBoundingClientRect(); + return ( + event.clientX < rect.left || + event.clientX > rect.right || + event.clientY < rect.top || + event.clientY > rect.bottom + ); + } + + private handleDialogPointerDown(event: PointerEvent): void { + this.backdropPressed = this.isBackdropEvent(event); + } + + private handleDialogClick(event: MouseEvent): void { + const pressed = this.backdropPressed; + this.backdropPressed = false; + // The press must start on the backdrop too, so a drag that merely ends there doesn't dismiss. + if (pressed && this.isBackdropEvent(event)) this.onClose(); + } + + private handleTransitionEnd(event: TransitionEvent): void { + if (event.target === this.dialogRef.value && event.propertyName === 'transform') this.finishClose(); + } + + private handleInventoryClick(event: MouseEvent): void { + const node = event.target; + if (!(node instanceof Element)) return; + + const button = node.closest('.inventory-card'); + const index = Number(button?.dataset.index); + if (!button || !Number.isInteger(index)) return; + + const target = this.inventoryTargets[index]; + if (!target) return; + + this.onSelect(target); + this.focusViewer(); + } + + private cancelEntry(): void { + if (this.entryFrame === undefined) return; + cancelAnimationFrame(this.entryFrame); + this.entryFrame = undefined; + } + + private cancelClose(): void { + if (this.closeTimer !== undefined) { + window.clearTimeout(this.closeTimer); + this.closeTimer = undefined; + } + this.closing = false; + } + + private finishClose(): void { + if (!this.closing) return; + + this.cancelClose(); + this.entering = false; + this.update(); + + const dialog = this.dialogRef.value; + if (dialog?.open) dialog.close(); + } +} diff --git a/src/lib/components/inventory/skincraft_viewer_modal_styles.ts b/src/lib/components/inventory/skincraft_viewer_modal_styles.ts new file mode 100644 index 00000000..68e18487 --- /dev/null +++ b/src/lib/components/inventory/skincraft_viewer_modal_styles.ts @@ -0,0 +1,487 @@ +/** Mirrors the longest `dialog` transition below; keep the two in sync. */ +export const MODAL_TRANSITION_MS = 200; + +export const skinCraftViewerModalStyles = ` + :host { + color: #f5f8ff; + font-family: Arial, Helvetica, sans-serif; + } + + dialog { + width: min(1120px, calc(100vw - 48px)); + max-width: none; + padding: 0; + border: 1px solid rgba(193, 206, 255, 0.12); + border-radius: 12px; + overflow: hidden; + color: inherit; + background: #15171c; + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.68); + opacity: 1; + transform: translateY(0) scale(1); + transform-origin: center; + transition: + opacity 180ms ease, + transform 200ms cubic-bezier(0.22, 1, 0.36, 1); + } + + dialog::backdrop { + background: rgba(5, 7, 12, 0.78); + backdrop-filter: blur(6px); + opacity: 1; + transition: + opacity 180ms ease, + backdrop-filter 200ms ease; + } + + dialog.entering, + dialog.closing { + opacity: 0; + transform: translateY(10px) scale(0.97); + } + + dialog.entering::backdrop, + dialog.closing::backdrop { + backdrop-filter: blur(0); + opacity: 0; + } + + dialog.closing { + pointer-events: none; + } + + .modal-body { + min-width: 0; + } + + .modal-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + height: 52px; + padding: 0 12px 0 18px; + border-bottom: 1px solid rgba(193, 206, 255, 0.1); + background: #1b1e25; + } + + .modal-title { + min-width: 0; + overflow: hidden; + font-size: 15px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; + } + + .modal-brand { + color: rgba(245, 248, 255, 0.58); + font-weight: 400; + } + + .close-button { + display: grid; + flex: 0 0 auto; + width: 34px; + height: 34px; + padding: 0; + place-items: center; + color: rgba(245, 248, 255, 0.72); + font: inherit; + font-size: 22px; + line-height: 1; + background: transparent; + border: 0; + border-radius: 8px; + cursor: pointer; + transition: color 150ms ease, background-color 150ms ease; + } + + .close-button:hover { + color: #fff; + background: rgba(255, 255, 255, 0.09); + } + + .inventory-panel { + display: none; + min-width: 0; + min-height: 0; + background: #181b21; + } + + .inventory-panel-header { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 0 14px; + color: rgba(245, 248, 255, 0.82); + font-size: 13px; + font-weight: 600; + } + + .inventory-count { + color: rgba(245, 248, 255, 0.42); + font-size: 11px; + font-variant-numeric: tabular-nums; + font-weight: 500; + } + + .inventory-grid { + min-width: 0; + min-height: 0; + scrollbar-color: rgba(193, 206, 255, 0.2) transparent; + scrollbar-width: thin; + } + + .inventory-grid::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + .inventory-grid::-webkit-scrollbar-thumb { + background: rgba(193, 206, 255, 0.2); + border: 2px solid transparent; + border-radius: 999px; + background-clip: padding-box; + } + + .inventory-card { + position: relative; + box-sizing: border-box; + min-width: 0; + padding: 5px; + overflow: hidden; + color: inherit; + background-color: var(--inventory-card-background, #2a2f3a); + border: 1px solid var(--inventory-card-rarity, rgba(193, 206, 255, 0.18)); + border-radius: 7px; + cursor: pointer; + transition: + background-color 140ms ease, + border-color 140ms ease, + box-shadow 140ms ease, + transform 100ms ease; + } + + .inventory-card:hover { + background-color: var(--inventory-card-hover, #363d4c); + } + + .inventory-card:focus-visible { + outline: 2px solid rgba(132, 136, 255, 0.95); + outline-offset: 1px; + } + + .inventory-card:active { + transform: scale(0.97); + } + + .inventory-card.selected { + background-color: var(--inventory-card-selected, #3e4186); + box-shadow: + 0 0 0 2px rgba(132, 136, 255, 0.92), + 0 6px 18px rgba(0, 0, 0, 0.28); + } + + .inventory-card img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; + opacity: 0.78; + pointer-events: none; + transition: opacity 140ms ease; + } + + .inventory-card:hover img, + .inventory-card.selected img { + opacity: 1; + } + + .inventory-card-seed, + .inventory-card-float { + position: absolute; + right: 5px; + z-index: 1; + max-width: calc(100% - 10px); + overflow: hidden; + color: rgba(225, 230, 239, 0.66); + font-family: Arial, Helvetica, sans-serif; + font-size: 11px; + font-variant-numeric: tabular-nums; + line-height: 1; + text-overflow: ellipsis; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9); + white-space: nowrap; + pointer-events: none; + } + + .inventory-card-seed { + top: 5px; + } + + .inventory-card-float { + bottom: 5px; + } + + .viewer-stage { + position: relative; + aspect-ratio: 16 / 9; + max-height: calc(100vh - 104px); + background: #111318; + } + + iframe { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + border: 0; + opacity: 0; + transition: opacity 280ms ease; + } + + iframe.loaded { + opacity: 1; + } + + .loading-cover { + position: absolute; + inset: 0; + z-index: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + padding: 28px; + background: radial-gradient(circle at 50% 38%, rgba(81, 85, 235, 0.16), transparent 38%), #111318; + opacity: 1; + transition: opacity 280ms ease; + } + + .loading-cover.loaded { + opacity: 0; + pointer-events: none; + } + + .loading-status, + .error-status { + display: flex; + width: 100%; + height: 100%; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + } + + .item-icon { + width: 330px; + max-width: 58%; + max-height: 42%; + aspect-ratio: 330 / 192; + object-fit: contain; + filter: drop-shadow(0 16px 32px rgba(0, 0, 0, 0.5)); + opacity: 1; + transition: opacity 160ms ease-out; + user-select: none; + } + + .item-icon.pending { + opacity: 0; + transition: none; + } + + .item-name { + max-width: 720px; + overflow: hidden; + color: rgba(245, 248, 255, 0.72); + font-size: 15px; + font-weight: 600; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; + } + + .progress { + display: flex; + align-items: center; + gap: 10px; + width: min(320px, 64%); + min-height: 18px; + } + + .progress-track { + position: relative; + flex: 1; + height: 4px; + overflow: hidden; + border-radius: 999px; + background: rgba(255, 255, 255, 0.13); + } + + .progress-fill { + position: absolute; + inset-block: 0; + left: 0; + border-radius: inherit; + background: #5155eb; + box-shadow: 0 0 10px rgba(81, 85, 235, 0.58); + } + + .progress-fill.determinate { + width: 0; + } + + .progress-fill.indeterminate { + width: 38%; + animation: progress-sweep 1.15s ease-in-out infinite; + } + + .progress-value { + width: 4ch; + color: rgba(245, 248, 255, 0.56); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; + font-variant-numeric: tabular-nums; + text-align: right; + } + + .error-message { + max-width: 560px; + color: #ff8585; + font-size: 14px; + line-height: 1.45; + text-align: center; + } + + .error-actions { + display: flex; + gap: 10px; + } + + .error-actions button, + .error-actions a { + padding: 9px 13px; + color: #f5f8ff; + font: inherit; + font-size: 13px; + text-decoration: none; + background: #5155eb; + border: 0; + border-radius: 7px; + cursor: pointer; + } + + .error-actions a { + background: rgba(255, 255, 255, 0.1); + } + + .hidden { + display: none !important; + } + + @keyframes progress-sweep { + from { transform: translateX(-120%); } + to { transform: translateX(320%); } + } + + @media (min-width: 1168px) and (max-width: 1519px) and (min-height: 840px) { + dialog.has-inventory { + width: 1120px; + } + + dialog.has-inventory .modal-body { + display: grid; + grid-template-rows: 122px auto; + } + + dialog.has-inventory .inventory-panel { + display: grid; + grid-template-columns: 122px minmax(0, 1fr); + border-bottom: 1px solid rgba(193, 206, 255, 0.1); + } + + dialog.has-inventory .inventory-panel-header { + border-right: 1px solid rgba(193, 206, 255, 0.08); + } + + dialog.has-inventory .inventory-grid { + display: flex; + gap: 8px; + padding: 11px; + overflow-x: auto; + overflow-y: hidden; + } + + dialog.has-inventory .inventory-card { + flex: 0 0 98px; + height: 98px; + } + } + + @media (min-width: 1520px) { + dialog.has-inventory { + width: min(1440px, calc(100vw - 48px)); + } + + dialog.has-inventory .modal-body { + display: grid; + grid-template-columns: minmax(280px, 320px) 1120px; + } + + dialog.has-inventory .inventory-panel { + display: flex; + flex-direction: column; + height: min(630px, calc(100vh - 104px)); + border-right: 1px solid rgba(193, 206, 255, 0.1); + } + + dialog.has-inventory .inventory-panel-header { + height: 48px; + border-bottom: 1px solid rgba(193, 206, 255, 0.08); + } + + dialog.has-inventory .inventory-grid { + display: grid; + flex: 1; + grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-auto-rows: 92px; + align-content: start; + gap: 8px; + padding: 10px; + overflow-x: hidden; + overflow-y: auto; + } + + dialog.has-inventory .inventory-card { + height: 92px; + } + + dialog.has-inventory .viewer-stage { + width: 1120px; + } + } + + @media (max-width: 640px) { + dialog { + width: calc(100vw - 20px); + } + + .modal-header { + height: 46px; + padding-left: 12px; + } + + .modal-brand { + display: none; + } + + .viewer-stage { + max-height: calc(100vh - 70px); + } + } +`; diff --git a/src/lib/services/float_fetcher.ts b/src/lib/services/float_fetcher.ts index 33ec1fea..74d40ccf 100644 --- a/src/lib/services/float_fetcher.ts +++ b/src/lib/services/float_fetcher.ts @@ -45,6 +45,10 @@ class FloatFetcher { return deferred.promise(); } + getCached(assetId: string): ItemInfo | undefined { + return this.cache.get(assetId); + } + private async flush() { this.flushScheduled = false; diff --git a/src/lib/services/skincraft_embed.ts b/src/lib/services/skincraft_embed.ts new file mode 100644 index 00000000..e8e2b31b --- /dev/null +++ b/src/lib/services/skincraft_embed.ts @@ -0,0 +1,271 @@ +import {environment} from '../../environment'; +import {SkinCraftViewerModal} from '../components/inventory/skincraft_viewer_modal'; +import {inPageContext} from '../utils/snips'; +import { + isSkinCraftEmbedEvent, + mergeSkinCraftProgress, + SKINCRAFT_EMBED_MESSAGE_SOURCE, + SKINCRAFT_EMBED_PROTOCOL_VERSION, +} from './skincraft_embed_protocol'; +import type {SkinCraftEmbedCommand} from './skincraft_embed_protocol'; +import {getLoadedInventoryTargets} from './skincraft_inventory_targets'; +import {isOpenSkinCraftViewerMessage, SKINCRAFT_VIEWER_MESSAGE_SOURCE} from './skincraft_viewer_protocol'; +import type {OpenSkinCraftViewerMessage, SkinCraftItem, SkinCraftViewerTarget} from './skincraft_viewer_protocol'; + +const LOAD_TIMEOUT_MS = 20_000; +type LoadPhase = 'idle' | 'loading' | 'loaded' | 'error'; + +class SkinCraftEmbedService { + private readonly runsInPage = inPageContext(); + private readonly embedOrigin = new URL(environment.skincraft_embed_origin).origin; + private readonly embedSrc = `${this.embedOrigin}/embed?parentOrigin=${encodeURIComponent(window.location.origin)}`; + + private modal?: SkinCraftViewerModal; + private activeTarget?: SkinCraftViewerTarget; + private ready = false; + private active = false; + private pendingInspect?: string; + private loadSequence = 0; + private latestLoadId?: string; + private loadTimer?: number; + private loadProgress: number | null = null; + private loadPhase: LoadPhase = 'idle'; + private showLoadingCover = false; + private frameHasContent = false; + + constructor() { + if (!this.runsInPage) window.addEventListener('message', this.handleOpenRequest); + } + + open(target: SkinCraftItem): void { + if (!target.inspect) return; + + if (this.runsInPage) { + window.postMessage( + { + source: SKINCRAFT_VIEWER_MESSAGE_SOURCE, + type: 'open', + target, + inventory: + typeof g_ActiveInventory === 'undefined' || !g_ActiveInventory + ? [] + : getLoadedInventoryTargets(g_ActiveInventory), + } satisfies OpenSkinCraftViewerMessage, + window.location.origin + ); + return; + } + + this.openEmbeddedViewer(target, []); + } + + close(): void { + if (!this.active) return; + + this.active = false; + this.pendingInspect = undefined; + this.latestLoadId = undefined; + this.loadProgress = null; + this.loadPhase = 'idle'; + this.showLoadingCover = false; + this.frameHasContent = false; + this.clearLoadTimeout(); + this.post({type: 'clear'}); + this.modal?.hide(); + document.removeEventListener('visibilitychange', this.handleVisibilityChange); + } + + private handleOpenRequest = (event: MessageEvent): void => { + if (event.source !== window || event.origin !== window.location.origin) return; + if (!isOpenSkinCraftViewerMessage(event.data)) return; + this.openEmbeddedViewer(event.data.target, event.data.inventory); + }; + + private openEmbeddedViewer(target: SkinCraftItem, inventory: SkinCraftItem[]): void { + const modal = this.ensureModal(); + modal.setInventory(inventory.map((item) => this.toViewerTarget(item))); + this.selectEmbeddedTarget(this.toViewerTarget(target)); + } + + private selectEmbeddedTarget(target: SkinCraftViewerTarget): void { + const modal = this.ensureModal(); + // Switch in place only when the frame is showing a model; otherwise dropping the cover + // would leave an empty or errored iframe on screen until the next terminal event. + const showLoadingCover = !modal.isOpen || !this.frameHasContent; + this.active = true; + this.activeTarget = target; + modal.show(this.activeTarget); + this.requestLoad(target.inspect, showLoadingCover); + document.addEventListener('visibilitychange', this.handleVisibilityChange); + } + + private toViewerTarget(target: SkinCraftItem): SkinCraftViewerTarget { + return { + ...target, + name: target.name.trim() || 'SkinCraft 3D Viewer', + itemUrl: `${this.embedOrigin}/i/${encodeURIComponent(target.inspect)}`, + }; + } + + private ensureModal(): SkinCraftViewerModal { + if (this.modal?.element.isConnected) return this.modal; + + this.ready = false; + this.frameHasContent = false; + const modal = new SkinCraftViewerModal( + this.embedSrc, + () => this.close(), + () => { + if (this.activeTarget) this.requestLoad(this.activeTarget.inspect, true); + }, + (target) => this.selectEmbeddedTarget(target) + ); + document.body.appendChild(modal.element); + window.addEventListener('message', this.handleEmbedMessage); + this.modal = modal; + return modal; + } + + private requestLoad(inspect: string, showLoadingCover: boolean): void { + this.pendingInspect = inspect; + this.loadProgress = null; + this.loadPhase = 'loading'; + this.showLoadingCover = showLoadingCover; + if (showLoadingCover) { + this.modal?.setLoading(null); + } else { + this.modal?.showFrame(); + } + this.startLoadTimeout(); + + if (this.ready) { + this.pendingInspect = undefined; + this.sendLoad(inspect); + } + } + + private sendLoad(inspect: string): void { + const id = String(++this.loadSequence); + this.latestLoadId = id; + this.loadProgress = null; + this.loadPhase = 'loading'; + this.startLoadTimeout(); + this.post({type: 'load', id, inspect}); + } + + private handleEmbedMessage = (event: MessageEvent): void => { + if (event.origin !== this.embedOrigin || event.source !== this.modal?.frameWindow) return; + if (!isSkinCraftEmbedEvent(event.data)) return; + + const message = event.data; + switch (message.type) { + case 'ready': { + // A repeat `ready` means the embed rebooted (crash or self-navigation) and lost + // its model — reload the current item behind the cover. + const rebooted = this.ready; + this.ready = true; + if (rebooted) this.frameHasContent = false; + if (this.active && this.pendingInspect) { + const inspect = this.pendingInspect; + this.pendingInspect = undefined; + this.sendLoad(inspect); + } else if (rebooted && this.active && this.activeTarget) { + this.showLoadingCover = true; + this.modal?.setLoading(null); + this.sendLoad(this.activeTarget.inspect); + } + break; + } + case 'progress': { + if (this.loadPhase !== 'loading' || !this.acceptsLoadEvent(message.id)) return; + this.loadProgress = mergeSkinCraftProgress(this.loadProgress, message.percent); + if (this.showLoadingCover) this.modal?.setLoading(this.loadProgress); + this.startLoadTimeout(); + break; + } + case 'loaded': + if (!this.acceptsTerminalEvent(message.id)) return; + this.clearLoadTimeout(); + this.loadPhase = 'loaded'; + this.showLoadingCover = false; + this.frameHasContent = true; + this.modal?.showFrame(); + break; + case 'error': + if (!this.acceptsErrorEvent(message.id)) return; + this.clearLoadTimeout(); + this.loadPhase = 'error'; + this.showLoadingCover = false; + this.frameHasContent = false; + this.modal?.setError(message.message || 'SkinCraft could not load this item.'); + break; + } + }; + + // Id-less events stay accepted here because the embed's boot-time progress predates our first + // `load` command and so carries no id. + private acceptsLoadEvent(id?: string): boolean { + return this.active && (!id || id === this.latestLoadId); + } + + // Terminal events echo our load id, so they must correlate — a stale or timed-out load can't + // dismiss newer UI. + private isLatestLoad(id?: string): boolean { + return this.active && id !== undefined && id === this.latestLoadId; + } + + private acceptsTerminalEvent(id?: string): boolean { + return (this.loadPhase === 'loading' || this.loadPhase === 'error') && this.isLatestLoad(id); + } + + /** Unlike `loaded`, an error can also invalidate a model that already loaded (e.g. a lost GPU device). */ + private acceptsErrorEvent(id?: string): boolean { + return this.loadPhase !== 'idle' && this.isLatestLoad(id); + } + + private handleVisibilityChange = (): void => { + if (!this.active) return; + // A paused embed emits no progress, so hold the load watchdog while the tab is hidden. + if (document.hidden) { + this.clearLoadTimeout(); + this.post({type: 'pause'}); + } else { + if (this.loadPhase === 'loading') this.startLoadTimeout(); + this.post({type: 'resume'}); + } + }; + + private post(command: SkinCraftEmbedCommand): void { + this.modal?.frameWindow?.postMessage( + { + source: SKINCRAFT_EMBED_MESSAGE_SOURCE, + v: SKINCRAFT_EMBED_PROTOCOL_VERSION, + ...command, + }, + this.embedOrigin + ); + } + + private startLoadTimeout(): void { + this.clearLoadTimeout(); + this.loadTimer = window.setTimeout(() => { + if (!this.active) return; + this.loadPhase = 'error'; + this.showLoadingCover = false; + this.frameHasContent = false; + // Drop the queued load and forget the in-flight id so neither a late `ready` nor a + // late `loaded` can act under the error UI — recovery goes through the Retry button. + this.pendingInspect = undefined; + this.latestLoadId = undefined; + this.modal?.setError('The 3D viewer took too long to load.'); + }, LOAD_TIMEOUT_MS); + } + + private clearLoadTimeout(): void { + if (this.loadTimer === undefined) return; + window.clearTimeout(this.loadTimer); + this.loadTimer = undefined; + } +} + +export const gSkinCraftEmbed = new SkinCraftEmbedService(); diff --git a/src/lib/services/skincraft_embed_protocol.test.ts b/src/lib/services/skincraft_embed_protocol.test.ts new file mode 100644 index 00000000..30674c3a --- /dev/null +++ b/src/lib/services/skincraft_embed_protocol.test.ts @@ -0,0 +1,39 @@ +import {describe, expect, it} from 'vitest'; +import {isSkinCraftEmbedEvent, mergeSkinCraftProgress, normalizeSkinCraftProgress} from './skincraft_embed_protocol'; + +const envelope = {source: 'skincraft-embed', v: 1} as const; + +describe('SkinCraft embed messages', () => { + it('accepts supported messages with the expected envelope', () => { + expect(isSkinCraftEmbedEvent({...envelope, type: 'ready'})).toBe(true); + expect(isSkinCraftEmbedEvent({...envelope, type: 'progress', id: '1', percent: 25})).toBe(true); + expect(isSkinCraftEmbedEvent({...envelope, type: 'loaded', id: '1'})).toBe(true); + expect( + isSkinCraftEmbedEvent({...envelope, type: 'error', id: '1', code: 'load-failed', message: 'Failed'}) + ).toBe(true); + }); + + it('rejects spoofed, malformed, and unknown messages', () => { + expect(isSkinCraftEmbedEvent({source: 'other', v: 1, type: 'ready'})).toBe(false); + expect(isSkinCraftEmbedEvent({source: 'skincraft-embed', v: 2, type: 'ready'})).toBe(false); + expect(isSkinCraftEmbedEvent({...envelope, type: 'error', code: 'failed', message: 5})).toBe(false); + expect(isSkinCraftEmbedEvent({...envelope, type: 'future-event'})).toBe(false); + }); +}); + +describe('SkinCraft embed progress', () => { + it('normalizes untrusted progress values', () => { + expect(normalizeSkinCraftProgress(-4)).toBe(0); + expect(normalizeSkinCraftProgress(40.6)).toBe(41); + expect(normalizeSkinCraftProgress(140)).toBe(100); + expect(normalizeSkinCraftProgress(Number.NaN)).toBeNull(); + expect(normalizeSkinCraftProgress('40')).toBeNull(); + }); + + it('keeps progress monotonic within a load', () => { + expect(mergeSkinCraftProgress(null, 12)).toBe(12); + expect(mergeSkinCraftProgress(60, 32)).toBe(60); + expect(mergeSkinCraftProgress(60, 80)).toBe(80); + expect(mergeSkinCraftProgress(60, null)).toBe(60); + }); +}); diff --git a/src/lib/services/skincraft_embed_protocol.ts b/src/lib/services/skincraft_embed_protocol.ts new file mode 100644 index 00000000..b8ce762d --- /dev/null +++ b/src/lib/services/skincraft_embed_protocol.ts @@ -0,0 +1,59 @@ +export const SKINCRAFT_EMBED_MESSAGE_SOURCE = 'skincraft-embed' as const; +export const SKINCRAFT_EMBED_PROTOCOL_VERSION = 1 as const; + +type EmbedEnvelope = { + source: typeof SKINCRAFT_EMBED_MESSAGE_SOURCE; + v: typeof SKINCRAFT_EMBED_PROTOCOL_VERSION; +}; + +export type SkinCraftEmbedCommand = + | {type: 'load'; id: string; inspect: string} + | {type: 'pause'} + | {type: 'resume'} + | {type: 'clear'}; + +export type SkinCraftEmbedEvent = EmbedEnvelope & + ( + | {type: 'ready'} + | {type: 'progress'; id?: string; percent: number | null; label?: string} + | {type: 'loaded'; id?: string} + | {type: 'error'; id?: string; code: string; message: string} + ); + +export function isSkinCraftEmbedEvent(data: unknown): data is SkinCraftEmbedEvent { + if (!data || typeof data !== 'object') return false; + + const message = data as Partial & Record; + if ( + message.source !== SKINCRAFT_EMBED_MESSAGE_SOURCE || + message.v !== SKINCRAFT_EMBED_PROTOCOL_VERSION || + typeof message.type !== 'string' + ) { + return false; + } + + const hasValidId = message.id === undefined || typeof message.id === 'string'; + switch (message.type) { + case 'ready': + return true; + case 'progress': + return hasValidId && (message.label === undefined || typeof message.label === 'string'); + case 'loaded': + return hasValidId; + case 'error': + return hasValidId && typeof message.code === 'string' && typeof message.message === 'string'; + default: + return false; + } +} + +export function normalizeSkinCraftProgress(value: unknown): number | null { + if (typeof value !== 'number' || !Number.isFinite(value)) return null; + return Math.max(0, Math.min(100, Math.round(value))); +} + +export function mergeSkinCraftProgress(current: number | null, incoming: unknown): number | null { + const normalized = normalizeSkinCraftProgress(incoming); + if (normalized === null) return current; + return current === null ? normalized : Math.max(current, normalized); +} diff --git a/src/lib/services/skincraft_inventory_targets.test.ts b/src/lib/services/skincraft_inventory_targets.test.ts new file mode 100644 index 00000000..1a12e3af --- /dev/null +++ b/src/lib/services/skincraft_inventory_targets.test.ts @@ -0,0 +1,71 @@ +import {describe, expect, it} from 'vitest'; +import type {ItemInfo} from '../bridge/handlers/fetch_inspect_info'; +import type {CInventory, InventoryAsset} from '../types/steam'; +import {getLoadedInventoryTargets, toSkinCraftItem} from './skincraft_inventory_targets'; + +function createInventory(assets: InventoryAsset[]): CInventory { + return { + initialized: true, + m_rgAssetProperties: {}, + m_rgAssets: Object.fromEntries(assets.map((asset) => [asset.assetid, asset])), + m_parentInventory: null, + rgInventory: {}, + } as unknown as CInventory; +} + +describe('SkinCraft inventory targets', () => { + it('allows skins with inspect data to be viewed independently of listing eligibility', () => { + const asset = { + assetid: '123', + asset_properties: [{propertyid: 6, string_value: 'a'.repeat(80)}], + description: { + market_hash_name: 'AK-47 | Redline (Field-Tested)', + tags: [{category: 'Weapon', internal_name: 'weapon_ak47'}], + tradable: 0, + }, + } as unknown as InventoryAsset; + + expect(toSkinCraftItem(asset)?.inspect).toBe('a'.repeat(80)); + }); + + it('skips Steam assets whose descriptions are not initialized yet', () => { + const pendingAsset = { + assetid: '123', + description: undefined, + } as unknown as InventoryAsset; + + expect(getLoadedInventoryTargets(createInventory([pendingAsset]))).toEqual([]); + }); + + it('includes cached float metadata and Steam rarity colors', () => { + const asset = { + assetid: '123', + asset_properties: [{propertyid: 6, string_value: 'a'.repeat(80)}], + description: { + background_color: '20242d', + icon_url: 'icon', + icon_url_large: 'large-icon', + market_hash_name: 'USP-S | Sleeping Potion (Factory New)', + tags: [ + {category: 'Weapon', internal_name: 'weapon_usp_silencer'}, + {category: 'Rarity', internal_name: 'Rarity_Mythical', color: '8847ff'}, + ], + }, + } as unknown as InventoryAsset; + const itemInfo = { + paintindex: 0, + paintseed: 977, + floatvalue: 0.2953754, + } as ItemInfo; + + expect(getLoadedInventoryTargets(createInventory([asset]), () => itemInfo)).toEqual([ + expect.objectContaining({ + assetId: '123', + seed: '977', + float: '0.295375', + rarityColor: '8847ff', + backgroundColor: '20242d', + }), + ]); + }); +}); diff --git a/src/lib/services/skincraft_inventory_targets.ts b/src/lib/services/skincraft_inventory_targets.ts new file mode 100644 index 00000000..747dcc16 --- /dev/null +++ b/src/lib/services/skincraft_inventory_targets.ts @@ -0,0 +1,115 @@ +import type {CAppwideInventory, CInventory, InventoryAsset, rgAssetProperty} from '../types/steam'; +import type {ItemInfo} from '../bridge/handlers/fetch_inspect_info'; +import {ContextId} from '../types/steam_constants'; +import {isCAppwideInventory} from '../utils/checkers'; +import {formatFloatWithRank, formatSeed, isSkin} from '../utils/skin'; +import {steamEconomyImageUrl} from '../utils/steam_images'; +import {gFloatFetcher} from './float_fetcher'; +import { + HEX_COLOR_PATTERN, + MAX_SKINCRAFT_INVENTORY_TARGETS, + SKINCRAFT_INSPECT_PATTERN, +} from './skincraft_viewer_protocol'; +import type {SkinCraftItem} from './skincraft_viewer_protocol'; + +type CachedItemInfoLookup = (assetId: string) => ItemInfo | undefined; + +const cachedItemInfo: CachedItemInfoLookup = (assetId) => gFloatFetcher.getCached(assetId); + +function getAssetProperties(asset: InventoryAsset, fallbackProperties: rgAssetProperty[]): rgAssetProperty[] { + if (asset.asset_properties?.length) return asset.asset_properties; + if (asset.description.asset_properties?.length) return asset.description.asset_properties; + return fallbackProperties; +} + +function getSkinCraftInspect( + asset: InventoryAsset | undefined, + fallbackProperties: rgAssetProperty[] +): string | undefined { + if (!asset?.description || !isSkin(asset.description)) return; + + return ( + getAssetProperties(asset, fallbackProperties) + .find((property) => property.propertyid === 6) + ?.string_value?.trim() || undefined + ); +} + +export function toSkinCraftItem( + asset: InventoryAsset | undefined, + fallbackProperties: rgAssetProperty[] = [], + getCachedItemInfo: CachedItemInfoLookup = cachedItemInfo +): SkinCraftItem | undefined { + if (!asset?.description || typeof asset.description.market_hash_name !== 'string') return; + + const inspect = getSkinCraftInspect(asset, fallbackProperties); + if (!inspect || !SKINCRAFT_INSPECT_PATTERN.test(inspect)) return; + + const icon = asset.description.icon_url_large || asset.description.icon_url; + const itemInfo = getCachedItemInfo(asset.assetid); + const rarityColor = asset.description.tags?.find((tag) => tag.category === 'Rarity')?.color; + const backgroundColor = asset.description.background_color; + return { + inspect, + name: asset.description.market_hash_name, + iconUrl: icon ? steamEconomyImageUrl(icon) : undefined, + assetId: asset.assetid, + seed: itemInfo ? formatSeed(itemInfo) : undefined, + float: itemInfo ? formatFloatWithRank(itemInfo, 6) : undefined, + rarityColor: rarityColor && HEX_COLOR_PATTERN.test(rarityColor) ? rarityColor : undefined, + backgroundColor: backgroundColor && HEX_COLOR_PATTERN.test(backgroundColor) ? backgroundColor : undefined, + }; +} + +/** Property lookup for a single active-inventory asset, covering appwide child inventories. */ +export function getActiveInventoryAssetProperties( + activeInventory: CInventory | CAppwideInventory | undefined, + assetId: string | undefined +): rgAssetProperty[] | undefined { + if (!activeInventory || !assetId) return; + + const inventories = isCAppwideInventory(activeInventory) + ? [ + activeInventory.m_rgChildInventories[ContextId.PRIMARY], + activeInventory.m_rgChildInventories[ContextId.PROTECTED], + activeInventory, + ] + : [activeInventory]; + + for (const inventory of inventories) { + const properties = inventory?.m_rgAssetProperties?.[assetId]; + if (properties?.length) return properties; + } + return undefined; +} + +export function getLoadedInventoryTargets( + activeInventory: CInventory | CAppwideInventory, + getCachedItemInfo: CachedItemInfoLookup = cachedItemInfo +): SkinCraftItem[] { + const inventories = isCAppwideInventory(activeInventory) + ? [ + activeInventory.m_rgChildInventories[ContextId.PRIMARY], + activeInventory.m_rgChildInventories[ContextId.PROTECTED], + ] + : [activeInventory]; + const targets: SkinCraftItem[] = []; + const seenAssets = new Set(); + + for (const inventory of inventories) { + if (!inventory) continue; + + for (const asset of Object.values(inventory.m_rgAssets)) { + if (seenAssets.has(asset.assetid)) continue; + + const target = toSkinCraftItem(asset, inventory.m_rgAssetProperties?.[asset.assetid], getCachedItemInfo); + if (!target) continue; + + seenAssets.add(asset.assetid); + targets.push(target); + if (targets.length === MAX_SKINCRAFT_INVENTORY_TARGETS) return targets; + } + } + + return targets; +} diff --git a/src/lib/services/skincraft_viewer_protocol.test.ts b/src/lib/services/skincraft_viewer_protocol.test.ts new file mode 100644 index 00000000..85cb75a4 --- /dev/null +++ b/src/lib/services/skincraft_viewer_protocol.test.ts @@ -0,0 +1,69 @@ +import {describe, expect, it} from 'vitest'; +import { + isOpenSkinCraftViewerMessage, + MAX_SKINCRAFT_INVENTORY_TARGETS, + SKINCRAFT_VIEWER_MESSAGE_SOURCE, +} from './skincraft_viewer_protocol'; +import type {SkinCraftItem} from './skincraft_viewer_protocol'; + +const target: SkinCraftItem = { + inspect: 'a'.repeat(80), + name: 'AK-47 | Redline', + iconUrl: 'https://community.akamai.steamstatic.com/economy/image/example/330x192', + assetId: '12345678901234567890', + seed: '977', + float: '0.295375', + rarityColor: '8847ff', + backgroundColor: '20242d', +}; + +describe('SkinCraft viewer open messages', () => { + it('accepts a bounded inventory snapshot of valid targets', () => { + expect( + isOpenSkinCraftViewerMessage({ + source: SKINCRAFT_VIEWER_MESSAGE_SOURCE, + type: 'open', + target, + inventory: [target], + }) + ).toBe(true); + }); + + it('rejects malformed targets and unexpected image origins', () => { + expect( + isOpenSkinCraftViewerMessage({ + source: SKINCRAFT_VIEWER_MESSAGE_SOURCE, + type: 'open', + target: {...target, inspect: 'not-an-inspect'}, + inventory: [], + }) + ).toBe(false); + expect( + isOpenSkinCraftViewerMessage({ + source: SKINCRAFT_VIEWER_MESSAGE_SOURCE, + type: 'open', + target, + inventory: [{...target, iconUrl: 'https://example.com/item.png'}], + }) + ).toBe(false); + expect( + isOpenSkinCraftViewerMessage({ + source: SKINCRAFT_VIEWER_MESSAGE_SOURCE, + type: 'open', + target, + inventory: [{...target, rarityColor: 'not-a-color'}], + }) + ).toBe(false); + }); + + it('rejects oversized inventory snapshots', () => { + expect( + isOpenSkinCraftViewerMessage({ + source: SKINCRAFT_VIEWER_MESSAGE_SOURCE, + type: 'open', + target, + inventory: Array.from({length: MAX_SKINCRAFT_INVENTORY_TARGETS + 1}, () => target), + }) + ).toBe(false); + }); +}); diff --git a/src/lib/services/skincraft_viewer_protocol.ts b/src/lib/services/skincraft_viewer_protocol.ts new file mode 100644 index 00000000..eea77f02 --- /dev/null +++ b/src/lib/services/skincraft_viewer_protocol.ts @@ -0,0 +1,66 @@ +import {STEAM_ECONOMY_IMAGE_PREFIX} from '../utils/steam_images'; + +export const SKINCRAFT_VIEWER_MESSAGE_SOURCE = 'csfloat-skincraft-viewer' as const; +export const MAX_SKINCRAFT_INVENTORY_TARGETS = 2_000; + +export const SKINCRAFT_INSPECT_PATTERN = /^[0-9a-f]{40,8192}$/i; +export const HEX_COLOR_PATTERN = /^[0-9a-f]{6}$/i; + +/** An item as it crosses the page → content-script boundary. */ +export type SkinCraftItem = { + inspect: string; + name: string; + iconUrl?: string; + assetId?: string; + seed?: string; + float?: string; + rarityColor?: string; + backgroundColor?: string; +}; + +/** A {@link SkinCraftItem} the modal can render, with its SkinCraft permalink resolved. */ +export type SkinCraftViewerTarget = SkinCraftItem & {itemUrl: string}; + +export type OpenSkinCraftViewerMessage = { + source: typeof SKINCRAFT_VIEWER_MESSAGE_SOURCE; + type: 'open'; + target: SkinCraftItem; + inventory: SkinCraftItem[]; +}; + +function isSkinCraftItem(data: unknown): data is SkinCraftItem { + if (!data || typeof data !== 'object') return false; + + const target = data as Partial; + return ( + typeof target.inspect === 'string' && + SKINCRAFT_INSPECT_PATTERN.test(target.inspect) && + typeof target.name === 'string' && + target.name.length <= 512 && + (target.assetId === undefined || (typeof target.assetId === 'string' && /^\d{1,32}$/.test(target.assetId))) && + (target.seed === undefined || (typeof target.seed === 'string' && target.seed.length <= 64)) && + (target.float === undefined || (typeof target.float === 'string' && target.float.length <= 64)) && + (target.rarityColor === undefined || + (typeof target.rarityColor === 'string' && HEX_COLOR_PATTERN.test(target.rarityColor))) && + (target.backgroundColor === undefined || + (typeof target.backgroundColor === 'string' && HEX_COLOR_PATTERN.test(target.backgroundColor))) && + (target.iconUrl === undefined || + (typeof target.iconUrl === 'string' && + target.iconUrl.length <= 4096 && + target.iconUrl.startsWith(STEAM_ECONOMY_IMAGE_PREFIX))) + ); +} + +export function isOpenSkinCraftViewerMessage(data: unknown): data is OpenSkinCraftViewerMessage { + if (!data || typeof data !== 'object') return false; + + const message = data as Partial; + return ( + message.source === SKINCRAFT_VIEWER_MESSAGE_SOURCE && + message.type === 'open' && + isSkinCraftItem(message.target) && + Array.isArray(message.inventory) && + message.inventory.length <= MAX_SKINCRAFT_INVENTORY_TARGETS && + message.inventory.every(isSkinCraftItem) + ); +} diff --git a/src/lib/services/webgpu_availability.ts b/src/lib/services/webgpu_availability.ts new file mode 100644 index 00000000..97492102 --- /dev/null +++ b/src/lib/services/webgpu_availability.ts @@ -0,0 +1,63 @@ +export type WebGpuAvailability = 'checking' | 'available' | 'unavailable'; + +interface WebGpuApi { + requestAdapter(): Promise; +} + +interface WebGpuNavigator extends Navigator { + gpu?: WebGpuApi; +} + +/** + * Requesting an adapter verifies WebGPU actually works for this browser/GPU configuration — + * including hardware acceleration and driver support — without allocating a GPUDevice. A present + * `navigator.gpu` is not enough on its own. + */ +async function detectWebGpuAvailability(browserNavigator?: Pick): Promise { + const gpu = browserNavigator?.gpu; + if (!gpu) return false; + + try { + return (await gpu.requestAdapter()) != null; + } catch { + return false; + } +} + +/** + * Browser-level WebGPU capability check, gating every 3D affordance. + * + * `navigator.gpu` is not Baseline — absent in Firefox before 141 and platform-dependent after + * (https://developer.mozilla.org/en-US/docs/Web/API/Navigator/gpu#browser_compatibility), and the + * manifest supports Firefox 127+. Those clients see no 3D button at all, which is useful context + * for support tickets asking where it went. + */ +class WebGpuAvailabilityService { + private readonly browserNavigator = typeof navigator === 'undefined' ? undefined : (navigator as WebGpuNavigator); + + private state: WebGpuAvailability = this.browserNavigator?.gpu ? 'checking' : 'unavailable'; + private probe?: Promise; + + /** Pessimistic until the probe settles: 'checking' is not treated as available. */ + get status(): WebGpuAvailability { + return this.state; + } + + get available(): boolean { + return this.state === 'available'; + } + + /** Resolves once the probe settles, starting it on first call. */ + async settled(): Promise { + if (this.state === 'checking') { + this.probe ??= detectWebGpuAvailability(this.browserNavigator).then((available) => { + this.state = available ? 'available' : 'unavailable'; + }); + await this.probe; + } + + return this.state; + } +} + +export const gWebGpuAvailability = new WebGpuAvailabilityService(); diff --git a/src/lib/types/steam.d.ts b/src/lib/types/steam.d.ts index 09457c2a..4a402c81 100644 --- a/src/lib/types/steam.d.ts +++ b/src/lib/types/steam.d.ts @@ -64,6 +64,7 @@ export interface rgDescription { type: string; tags?: { category: string; + color?: string; name?: string; internal_name: string; localized_category_name?: string; diff --git a/src/lib/utils/steam_images.ts b/src/lib/utils/steam_images.ts new file mode 100644 index 00000000..53fc935e --- /dev/null +++ b/src/lib/utils/steam_images.ts @@ -0,0 +1,5 @@ +export const STEAM_ECONOMY_IMAGE_PREFIX = 'https://community.akamai.steamstatic.com/economy/image/'; + +export function steamEconomyImageUrl(icon: string, size = '330x192'): string { + return `${STEAM_ECONOMY_IMAGE_PREFIX}${icon}/${size}`; +}