From 81f58d8e5ef5aac2ca2c31085a8272aa159f3daa Mon Sep 17 00:00:00 2001 From: Syed Zaidi Date: Mon, 17 Aug 2026 03:33:13 -0400 Subject: [PATCH 1/7] feat(inventory): open the 3d embed to sticker, patch, charm, and agent items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The weapons/gloves scope was extension-side gating only — the item kind rides inside the masked inspect hex. Adds an inspect-requested embed event: the sandboxed iframe can't launch steam:// itself, so it forwards the Inspect click and we launch the item's own action link, pattern-pinned end to end. --- src/lib/services/skincraft_embed.ts | 13 +- .../services/skincraft_embed_protocol.test.ts | 1 + src/lib/services/skincraft_embed_protocol.ts | 4 + .../skincraft_inventory_targets.test.ts | 114 ++++++++++++++++++ .../services/skincraft_inventory_targets.ts | 23 +++- .../skincraft_viewer_protocol.test.ts | 25 ++++ src/lib/services/skincraft_viewer_protocol.ts | 6 + 7 files changed, 182 insertions(+), 4 deletions(-) diff --git a/src/lib/services/skincraft_embed.ts b/src/lib/services/skincraft_embed.ts index 84fdc230..837e72ce 100644 --- a/src/lib/services/skincraft_embed.ts +++ b/src/lib/services/skincraft_embed.ts @@ -9,7 +9,11 @@ import { } 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 { + isOpenSkinCraftViewerMessage, + SKINCRAFT_VIEWER_MESSAGE_SOURCE, + STEAM_INSPECT_URL_PATTERN, +} from './skincraft_viewer_protocol'; import type {OpenSkinCraftViewerMessage, SkinCraftItem, SkinCraftViewerTarget} from './skincraft_viewer_protocol'; const LOAD_TIMEOUT_MS = 20_000; @@ -212,6 +216,13 @@ class SkinCraftEmbedService { this.frameHasContent = false; this.modal?.setError(message.message || 'SkinCraft could not load this item.'); break; + case 'inspect-requested': { + // Re-check the shape: targets on the direct open() path never cross the + // validated page boundary. + const url = this.activeTarget?.inspectUrl; + if (this.active && url && STEAM_INSPECT_URL_PATTERN.test(url)) window.location.href = url; + break; + } } }; diff --git a/src/lib/services/skincraft_embed_protocol.test.ts b/src/lib/services/skincraft_embed_protocol.test.ts index 30674c3a..50e45a13 100644 --- a/src/lib/services/skincraft_embed_protocol.test.ts +++ b/src/lib/services/skincraft_embed_protocol.test.ts @@ -11,6 +11,7 @@ describe('SkinCraft embed messages', () => { expect( isSkinCraftEmbedEvent({...envelope, type: 'error', id: '1', code: 'load-failed', message: 'Failed'}) ).toBe(true); + expect(isSkinCraftEmbedEvent({...envelope, type: 'inspect-requested'})).toBe(true); }); it('rejects spoofed, malformed, and unknown messages', () => { diff --git a/src/lib/services/skincraft_embed_protocol.ts b/src/lib/services/skincraft_embed_protocol.ts index b8ce762d..0acdd266 100644 --- a/src/lib/services/skincraft_embed_protocol.ts +++ b/src/lib/services/skincraft_embed_protocol.ts @@ -18,6 +18,9 @@ export type SkinCraftEmbedEvent = EmbedEnvelope & | {type: 'progress'; id?: string; percent: number | null; label?: string} | {type: 'loaded'; id?: string} | {type: 'error'; id?: string; code: string; message: string} + // Inspect click forwarded out of the sandboxed iframe (it can't launch + // steam:// itself); we launch the shown item's own inspect link. + | {type: 'inspect-requested'} ); export function isSkinCraftEmbedEvent(data: unknown): data is SkinCraftEmbedEvent { @@ -35,6 +38,7 @@ export function isSkinCraftEmbedEvent(data: unknown): data is SkinCraftEmbedEven const hasValidId = message.id === undefined || typeof message.id === 'string'; switch (message.type) { case 'ready': + case 'inspect-requested': return true; case 'progress': return hasValidId && (message.label === undefined || typeof message.label === 'string'); diff --git a/src/lib/services/skincraft_inventory_targets.test.ts b/src/lib/services/skincraft_inventory_targets.test.ts index edfa57d9..1fe2a95e 100644 --- a/src/lib/services/skincraft_inventory_targets.test.ts +++ b/src/lib/services/skincraft_inventory_targets.test.ts @@ -3,6 +3,7 @@ import type {ItemInfo} from '../bridge/handlers/fetch_inspect_info'; import type {CAppwideInventory, CInventory, InventoryAsset} from '../types/steam'; import {ContextId} from '../types/steam_constants'; import {getLoadedInventoryTargets, toSkinCraftItem} from './skincraft_inventory_targets'; +import {isOpenSkinCraftViewerMessage, SKINCRAFT_VIEWER_MESSAGE_SOURCE} from './skincraft_viewer_protocol'; function createInventory(assets: InventoryAsset[]): CInventory { return { @@ -29,6 +30,119 @@ describe('SkinCraft inventory targets', () => { expect(toSkinCraftItem(asset)?.inspect).toBe('a'.repeat(80)); }); + it.each([ + ['sticker', 'High Grade Sticker', 'CSGO_Tool_Sticker'], + ['patch', 'High Grade Patch', 'CSGO_Type_Patch'], + ['charm', 'Extraordinary Charm', 'CSGO_Tool_Keychain'], + ['agent', 'Master Agent', 'Type_CustomPlayer'], + ])('accepts %s items with inspect data', (_kind, type, internalName) => { + const asset = { + assetid: '123', + asset_properties: [{propertyid: 6, string_value: 'a'.repeat(80)}], + description: { + market_hash_name: 'name', + type, + tags: [{category: 'Type', internal_name: internalName}], + }, + } as unknown as InventoryAsset; + + expect(toSkinCraftItem(asset)?.inspect).toBe('a'.repeat(80)); + }); + + it('rejects item types SkinCraft cannot render', () => { + const asset = { + assetid: '123', + asset_properties: [{propertyid: 6, string_value: 'a'.repeat(80)}], + description: { + market_hash_name: 'Dreams & Nightmares Case', + type: 'Base Grade Container', + tags: [{category: 'Type', internal_name: 'CSGO_Type_WeaponCase'}], + }, + } as unknown as InventoryAsset; + + expect(toSkinCraftItem(asset)).toBeUndefined(); + }); + + it('derives the steam launch link from the masked inspect action, wherever it sits', () => { + 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'}], + actions: [ + {name: 'View Wiki', link: 'https://example.com/wiki'}, + { + name: 'Inspect in Game...', + link: 'steam://rungame/730/76561202255233023/+csgo_econ_action_preview%20%propid:6%', + }, + ], + }, + } as unknown as InventoryAsset; + + expect(toSkinCraftItem(asset)?.inspectUrl).toBe( + `steam://rungame/730/76561202255233023/+csgo_econ_action_preview%20${'a'.repeat(80)}` + ); + }); + + it('omits the launch link when no action carries the masked placeholder', () => { + 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'}], + actions: [ + {name: 'View Wiki', link: 'https://example.com/wiki'}, + {name: 'Inspect in Game...', link: 'steam://rungame/730/123/+csgo_econ_action_preview%20S1A2D3'}, + ], + }, + } as unknown as InventoryAsset; + + expect(toSkinCraftItem(asset)?.inspectUrl).toBeUndefined(); + }); + + it('produces items the viewer protocol accepts, even for oversized inspects', () => { + const asset = { + assetid: '123', + asset_properties: [{propertyid: 6, string_value: 'a'.repeat(5000)}], + description: { + market_hash_name: 'AK-47 | Redline (Field-Tested)', + tags: [{category: 'Weapon', internal_name: 'weapon_ak47'}], + actions: [ + { + name: 'Inspect in Game...', + link: 'steam://rungame/730/76561202255233023/+csgo_econ_action_preview%20%propid:6%', + }, + ], + }, + } as unknown as InventoryAsset; + const target = toSkinCraftItem(asset); + + expect(target?.inspectUrl).toBeDefined(); + expect( + isOpenSkinCraftViewerMessage({ + source: SKINCRAFT_VIEWER_MESSAGE_SOURCE, + type: 'open', + target, + inventory: [target], + }) + ).toBe(true); + }); + + it('rejects half-hydrated non-skin descriptions without throwing', () => { + const asset = { + assetid: '123', + asset_properties: [{propertyid: 6, string_value: 'a'.repeat(80)}], + description: { + market_hash_name: 'name', + tags: [{category: 'Type', internal_name: 'CSGO_Tool_Sticker'}], + }, + } as unknown as InventoryAsset; + + expect(toSkinCraftItem(asset)).toBeUndefined(); + }); + it('skips Steam assets whose descriptions are not initialized yet', () => { const pendingAsset = { assetid: '123', diff --git a/src/lib/services/skincraft_inventory_targets.ts b/src/lib/services/skincraft_inventory_targets.ts index 3001ec15..4b86b33e 100644 --- a/src/lib/services/skincraft_inventory_targets.ts +++ b/src/lib/services/skincraft_inventory_targets.ts @@ -1,14 +1,15 @@ -import type {CAppwideInventory, CInventory, InventoryAsset, rgAssetProperty} from '../types/steam'; +import type {CAppwideInventory, CInventory, InventoryAsset, rgAsset, 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 {formatFloatWithRank, formatSeed, isAgent, isCharm, isPatch, isSkin, isSticker} 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, + STEAM_INSPECT_URL_PATTERN, } from './skincraft_viewer_protocol'; import type {SkinCraftItem} from './skincraft_viewer_protocol'; @@ -22,11 +23,19 @@ function getAssetProperties(asset: InventoryAsset, fallbackProperties: rgAssetPr return fallbackProperties; } +/** Item types SkinCraft renders (weapons and gloves are both matched by `isSkin`). */ +function isSkinCraftRenderable(description: rgAsset): boolean { + if (isSkin(description)) return true; + // Half-hydrated descriptions may lack `type`, which the predicates below dereference. + if (typeof description.type !== 'string') return false; + return isSticker(description) || isCharm(description) || isPatch(description) || isAgent(description); +} + function getSkinCraftInspect( asset: InventoryAsset | undefined, fallbackProperties: rgAssetProperty[] ): string | undefined { - if (!asset?.description || !isSkin(asset.description)) return; + if (!asset?.description || !isSkinCraftRenderable(asset.description)) return; return ( getAssetProperties(asset, fallbackProperties) @@ -35,6 +44,13 @@ function getSkinCraftInspect( ); } +/** The asset's `steam://` inspect launch link — Steam templates the masked hex slot as `%propid:6%`. */ +function getSteamInspectUrl(asset: InventoryAsset, inspect: string): string | undefined { + const link = asset.description.actions?.find((action) => action.link.includes('%propid:6%'))?.link; + const url = link?.replace('%propid:6%', inspect); + return url && STEAM_INSPECT_URL_PATTERN.test(url) ? url : undefined; +} + export function toSkinCraftItem( asset: InventoryAsset | undefined, fallbackProperties: rgAssetProperty[] = [], @@ -51,6 +67,7 @@ export function toSkinCraftItem( const backgroundColor = asset.description.background_color; return { inspect, + inspectUrl: getSteamInspectUrl(asset, inspect), name: asset.description.market_hash_name, iconUrl: icon ? steamEconomyImageUrl(icon) : undefined, assetId: asset.assetid, diff --git a/src/lib/services/skincraft_viewer_protocol.test.ts b/src/lib/services/skincraft_viewer_protocol.test.ts index 85cb75a4..51ee4be1 100644 --- a/src/lib/services/skincraft_viewer_protocol.test.ts +++ b/src/lib/services/skincraft_viewer_protocol.test.ts @@ -8,6 +8,7 @@ import type {SkinCraftItem} from './skincraft_viewer_protocol'; const target: SkinCraftItem = { inspect: 'a'.repeat(80), + inspectUrl: `steam://rungame/730/76561202255233023/+csgo_econ_action_preview%20${'a'.repeat(80)}`, name: 'AK-47 | Redline', iconUrl: 'https://community.akamai.steamstatic.com/economy/image/example/330x192', assetId: '12345678901234567890', @@ -27,6 +28,14 @@ describe('SkinCraft viewer open messages', () => { inventory: [target], }) ).toBe(true); + expect( + isOpenSkinCraftViewerMessage({ + source: SKINCRAFT_VIEWER_MESSAGE_SOURCE, + type: 'open', + target: {...target, inspectUrl: undefined}, + inventory: [], + }) + ).toBe(true); }); it('rejects malformed targets and unexpected image origins', () => { @@ -54,6 +63,22 @@ describe('SkinCraft viewer open messages', () => { inventory: [{...target, rarityColor: 'not-a-color'}], }) ).toBe(false); + expect( + isOpenSkinCraftViewerMessage({ + source: SKINCRAFT_VIEWER_MESSAGE_SOURCE, + type: 'open', + target: {...target, inspectUrl: 'https://example.com/inspect'}, + inventory: [], + }) + ).toBe(false); + expect( + isOpenSkinCraftViewerMessage({ + source: SKINCRAFT_VIEWER_MESSAGE_SOURCE, + type: 'open', + target: {...target, inspectUrl: 'steam://uninstall/730'}, + inventory: [], + }) + ).toBe(false); }); it('rejects oversized inventory snapshots', () => { diff --git a/src/lib/services/skincraft_viewer_protocol.ts b/src/lib/services/skincraft_viewer_protocol.ts index e6fe518c..a3549d4c 100644 --- a/src/lib/services/skincraft_viewer_protocol.ts +++ b/src/lib/services/skincraft_viewer_protocol.ts @@ -5,11 +5,15 @@ 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; +export const STEAM_INSPECT_URL_PATTERN = + /^steam:\/\/rungame\/730\/\d{1,20}\/\+csgo_econ_action_preview%20[0-9a-f]{40,8192}$/i; const ASSET_ID_PATTERN = /^\d{1,32}$/; /** An item as it crosses the page → content-script boundary. */ export type SkinCraftItem = { inspect: string; + /** The item's own `steam://` launch link, for Inspect clicks forwarded out of the embed. */ + inspectUrl?: string; name: string; iconUrl?: string; assetId?: string; @@ -36,6 +40,7 @@ function isSkinCraftItemShape(data: unknown): data is SkinCraftItem { const item = data as Partial; return ( typeof item.inspect === 'string' && + (item.inspectUrl === undefined || typeof item.inspectUrl === 'string') && typeof item.name === 'string' && (item.assetId === undefined || typeof item.assetId === 'string') && (item.seed === undefined || typeof item.seed === 'string') && @@ -50,6 +55,7 @@ function isSkinCraftItemShape(data: unknown): data is SkinCraftItem { function isValidSkinCraftItem(item: SkinCraftItem): boolean { return ( SKINCRAFT_INSPECT_PATTERN.test(item.inspect) && + (item.inspectUrl === undefined || STEAM_INSPECT_URL_PATTERN.test(item.inspectUrl)) && item.name.length <= 512 && (item.assetId === undefined || ASSET_ID_PATTERN.test(item.assetId)) && (item.seed === undefined || item.seed.length <= 64) && From 63d3acda1ee1df9013de741a50851caf6a63214e Mon Sep 17 00:00:00 2001 From: Syed Zaidi Date: Mon, 17 Aug 2026 03:50:04 -0400 Subject: [PATCH 2/7] fix(inventory): read the masked inspect hex from action links too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Steam ships the hex two ways: skins carry it as asset property 6 with a %propid:6% slot in the action link, but stickers embed it literally in the link with no property at all. Inventory links also use steam://run/730// rather than the market rungame form — accept both. --- .../skincraft_inventory_targets.test.ts | 26 +++++++++++++---- .../services/skincraft_inventory_targets.ts | 28 +++++++++++++------ src/lib/services/skincraft_viewer_protocol.ts | 2 +- 3 files changed, 41 insertions(+), 15 deletions(-) diff --git a/src/lib/services/skincraft_inventory_targets.test.ts b/src/lib/services/skincraft_inventory_targets.test.ts index 1fe2a95e..90a62c30 100644 --- a/src/lib/services/skincraft_inventory_targets.test.ts +++ b/src/lib/services/skincraft_inventory_targets.test.ts @@ -72,20 +72,34 @@ describe('SkinCraft inventory targets', () => { tags: [{category: 'Weapon', internal_name: 'weapon_ak47'}], actions: [ {name: 'View Wiki', link: 'https://example.com/wiki'}, - { - name: 'Inspect in Game...', - link: 'steam://rungame/730/76561202255233023/+csgo_econ_action_preview%20%propid:6%', - }, + {name: 'Inspect in Game...', link: 'steam://run/730//+csgo_econ_action_preview%20%propid:6%'}, ], }, } as unknown as InventoryAsset; expect(toSkinCraftItem(asset)?.inspectUrl).toBe( - `steam://rungame/730/76561202255233023/+csgo_econ_action_preview%20${'a'.repeat(80)}` + `steam://run/730//+csgo_econ_action_preview%20${'a'.repeat(80)}` ); }); - it('omits the launch link when no action carries the masked placeholder', () => { + it('extracts the inspect from action links that embed the hex directly', () => { + const hex = 'A'.repeat(54); + const asset = { + assetid: '123', + description: { + market_hash_name: 'Sticker | Crown (Foil)', + type: 'High Grade Sticker', + tags: [{category: 'Type', internal_name: 'CSGO_Tool_Sticker'}], + actions: [{name: 'Inspect in Game...', link: `steam://run/730//+csgo_econ_action_preview%20${hex}`}], + }, + } as unknown as InventoryAsset; + const target = toSkinCraftItem(asset); + + expect(target?.inspect).toBe(hex); + expect(target?.inspectUrl).toBe(`steam://run/730//+csgo_econ_action_preview%20${hex}`); + }); + + it('omits the launch link when no action is a masked inspect launch', () => { const asset = { assetid: '123', asset_properties: [{propertyid: 6, string_value: 'a'.repeat(80)}], diff --git a/src/lib/services/skincraft_inventory_targets.ts b/src/lib/services/skincraft_inventory_targets.ts index 4b86b33e..eb32713d 100644 --- a/src/lib/services/skincraft_inventory_targets.ts +++ b/src/lib/services/skincraft_inventory_targets.ts @@ -31,23 +31,35 @@ function isSkinCraftRenderable(description: rgAsset): boolean { return isSticker(description) || isCharm(description) || isPatch(description) || isAgent(description); } +const MASKED_ACTION_PATTERN = /\+csgo_econ_action_preview%20(%propid:6%|[0-9a-f]{40,8192})$/i; + +/** The description's masked inspect action. Steam ships the hex two ways: as asset + * property 6 with a `%propid:6%` slot in the link (skins), or embedded directly in + * the link (stickers). */ +function getMaskedInspectAction(description: rgAsset): {link: string; embeddedHex?: string} | undefined { + for (const action of description.actions ?? []) { + const hex = MASKED_ACTION_PATTERN.exec(action.link)?.[1]; + if (hex) return {link: action.link, embeddedHex: hex === '%propid:6%' ? undefined : hex}; + } + return undefined; +} + function getSkinCraftInspect( asset: InventoryAsset | undefined, fallbackProperties: rgAssetProperty[] ): string | undefined { if (!asset?.description || !isSkinCraftRenderable(asset.description)) return; - return ( - getAssetProperties(asset, fallbackProperties) - .find((property) => property.propertyid === 6) - ?.string_value?.trim() || undefined - ); + const property = getAssetProperties(asset, fallbackProperties) + .find((property) => property.propertyid === 6) + ?.string_value?.trim(); + return property || getMaskedInspectAction(asset.description)?.embeddedHex; } -/** The asset's `steam://` inspect launch link — Steam templates the masked hex slot as `%propid:6%`. */ +/** The asset's `steam://` inspect launch link, with any `%propid:6%` slot filled with the masked hex. */ function getSteamInspectUrl(asset: InventoryAsset, inspect: string): string | undefined { - const link = asset.description.actions?.find((action) => action.link.includes('%propid:6%'))?.link; - const url = link?.replace('%propid:6%', inspect); + const action = getMaskedInspectAction(asset.description); + const url = action?.embeddedHex ? action.link : action?.link.replace('%propid:6%', inspect); return url && STEAM_INSPECT_URL_PATTERN.test(url) ? url : undefined; } diff --git a/src/lib/services/skincraft_viewer_protocol.ts b/src/lib/services/skincraft_viewer_protocol.ts index a3549d4c..7bcf8d24 100644 --- a/src/lib/services/skincraft_viewer_protocol.ts +++ b/src/lib/services/skincraft_viewer_protocol.ts @@ -6,7 +6,7 @@ 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; export const STEAM_INSPECT_URL_PATTERN = - /^steam:\/\/rungame\/730\/\d{1,20}\/\+csgo_econ_action_preview%20[0-9a-f]{40,8192}$/i; + /^steam:\/\/(?:run|rungame)\/730\/\d{0,20}\/\+csgo_econ_action_preview%20[0-9a-f]{40,8192}$/i; const ASSET_ID_PATTERN = /^\d{1,32}$/; /** An item as it crosses the page → content-script boundary. */ From bf1e033dea384fa7b34d75048d1266a51df785ba Mon Sep 17 00:00:00 2001 From: Syed Zaidi Date: Mon, 17 Aug 2026 16:07:14 -0400 Subject: [PATCH 3/7] fix(common): identify half-hydrated descriptions by tag instead of throwing isAbstractType dereferenced asset.type unconditionally, but Steam ships descriptions without it despite the declared non-optional shape. Guarding the type branch keeps the tag branch reachable, so a tag-identified item is matched rather than lost, and the latent TypeError is gone for every predicate built on it -- isCharm, isAgent, isSticker, isPatch, isCase, isMusicKit and isPin. --- src/lib/utils/skin.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/utils/skin.ts b/src/lib/utils/skin.ts index 0562302f..2a2fbf64 100644 --- a/src/lib/utils/skin.ts +++ b/src/lib/utils/skin.ts @@ -156,7 +156,8 @@ export function isPin(asset: rgAsset): boolean { } function isAbstractType(asset: rgAsset, type: string, internalName: string): boolean { - if (asset.type.endsWith(type)) { + // Half-hydrated descriptions can arrive without `type`, despite the declared shape. + if (typeof asset.type === 'string' && asset.type.endsWith(type)) { return true; } From 000d57a5b132f6e1e1892a2cb29a03cac60d8935 Mon Sep 17 00:00:00 2001 From: Syed Zaidi Date: Mon, 17 Aug 2026 16:07:19 -0400 Subject: [PATCH 4/7] fix(inventory): build the inspect launch link around the rendered hex getSkinCraftInspect preferred asset property 6 while getSteamInspectUrl preferred the hex embedded in the action link, so an asset carrying both could render one item and launch Steam into another. Splitting the action at the hex slot and rebuilding the link around the resolved hex makes that unrepresentable, and folds the two helpers into one traversal. Also anchors the action pattern to the steam scheme so a lookalike link can no longer shadow the real inspect action, and compares the property slot case-insensitively to match the pattern that captured it. --- .../skincraft_inventory_targets.test.ts | 48 ++++++++++++-- .../services/skincraft_inventory_targets.ts | 65 ++++++++++--------- src/lib/services/skincraft_viewer_protocol.ts | 2 +- 3 files changed, 81 insertions(+), 34 deletions(-) diff --git a/src/lib/services/skincraft_inventory_targets.test.ts b/src/lib/services/skincraft_inventory_targets.test.ts index 90a62c30..8d729d3f 100644 --- a/src/lib/services/skincraft_inventory_targets.test.ts +++ b/src/lib/services/skincraft_inventory_targets.test.ts @@ -33,7 +33,8 @@ describe('SkinCraft inventory targets', () => { it.each([ ['sticker', 'High Grade Sticker', 'CSGO_Tool_Sticker'], ['patch', 'High Grade Patch', 'CSGO_Type_Patch'], - ['charm', 'Extraordinary Charm', 'CSGO_Tool_Keychain'], + // Localized `type`, so this row lands on the tag branch the way non-English Steam does. + ['charm', 'Breloque extraordinaire', 'CSGO_Tool_Keychain'], ['agent', 'Master Agent', 'Type_CustomPlayer'], ])('accepts %s items with inspect data', (_kind, type, internalName) => { const asset = { @@ -113,10 +114,49 @@ describe('SkinCraft inventory targets', () => { }, } as unknown as InventoryAsset; + expect(toSkinCraftItem(asset)).toEqual( + expect.objectContaining({inspect: 'a'.repeat(80), inspectUrl: undefined}) + ); + }); + + it('ignores inspect-shaped actions that are not steam launch links', () => { + const hex = 'a'.repeat(80); + const asset = { + assetid: '123', + asset_properties: [{propertyid: 6, string_value: hex}], + description: { + market_hash_name: 'AK-47 | Redline (Field-Tested)', + tags: [{category: 'Weapon', internal_name: 'weapon_ak47'}], + actions: [ + {name: 'Inspect in Game...', link: `https://example.com/#+csgo_econ_action_preview%20${hex}`}, + ], + }, + } as unknown as InventoryAsset; + expect(toSkinCraftItem(asset)?.inspectUrl).toBeUndefined(); }); - it('produces items the viewer protocol accepts, even for oversized inspects', () => { + it('builds the launch link around the rendered hex, not the one embedded in the link', () => { + const property = 'a'.repeat(80); + const embedded = 'b'.repeat(80); + const asset = { + assetid: '123', + asset_properties: [{propertyid: 6, string_value: property}], + description: { + market_hash_name: 'AK-47 | Redline (Field-Tested)', + tags: [{category: 'Weapon', internal_name: 'weapon_ak47'}], + actions: [ + {name: 'Inspect in Game...', link: `steam://run/730//+csgo_econ_action_preview%20${embedded}`}, + ], + }, + } as unknown as InventoryAsset; + const target = toSkinCraftItem(asset); + + expect(target?.inspect).toBe(property); + expect(target?.inspectUrl).toBe(`steam://run/730//+csgo_econ_action_preview%20${property}`); + }); + + it('produces items the viewer protocol accepts, including very long inspects', () => { const asset = { assetid: '123', asset_properties: [{propertyid: 6, string_value: 'a'.repeat(5000)}], @@ -144,7 +184,7 @@ describe('SkinCraft inventory targets', () => { ).toBe(true); }); - it('rejects half-hydrated non-skin descriptions without throwing', () => { + it('identifies half-hydrated descriptions by tag, without throwing on the missing type', () => { const asset = { assetid: '123', asset_properties: [{propertyid: 6, string_value: 'a'.repeat(80)}], @@ -154,7 +194,7 @@ describe('SkinCraft inventory targets', () => { }, } as unknown as InventoryAsset; - expect(toSkinCraftItem(asset)).toBeUndefined(); + expect(toSkinCraftItem(asset)?.inspect).toBe('a'.repeat(80)); }); it('skips Steam assets whose descriptions are not initialized yet', () => { diff --git a/src/lib/services/skincraft_inventory_targets.ts b/src/lib/services/skincraft_inventory_targets.ts index eb32713d..209e2422 100644 --- a/src/lib/services/skincraft_inventory_targets.ts +++ b/src/lib/services/skincraft_inventory_targets.ts @@ -23,44 +23,52 @@ function getAssetProperties(asset: InventoryAsset, fallbackProperties: rgAssetPr return fallbackProperties; } -/** Item types SkinCraft renders (weapons and gloves are both matched by `isSkin`). */ +/** Item types SkinCraft renders (gloves fall under `isSkin`). */ function isSkinCraftRenderable(description: rgAsset): boolean { - if (isSkin(description)) return true; - // Half-hydrated descriptions may lack `type`, which the predicates below dereference. - if (typeof description.type !== 'string') return false; - return isSticker(description) || isCharm(description) || isPatch(description) || isAgent(description); + return ( + isSkin(description) || + isSticker(description) || + isCharm(description) || + isPatch(description) || + isAgent(description) + ); } -const MASKED_ACTION_PATTERN = /\+csgo_econ_action_preview%20(%propid:6%|[0-9a-f]{40,8192})$/i; +const PROPERTY_SLOT = '%propid:6%'; +const MASKED_ACTION_PATTERN = + /^steam:\/\/(?:run|rungame)\/730\/\d{0,20}\/\+csgo_econ_action_preview%20(%propid:6%|[0-9a-f]{40,8192})$/i; -/** The description's masked inspect action. Steam ships the hex two ways: as asset - * property 6 with a `%propid:6%` slot in the link (skins), or embedded directly in - * the link (stickers). */ -function getMaskedInspectAction(description: rgAsset): {link: string; embeddedHex?: string} | undefined { +/** The description's masked inspect action, split at the hex slot. Steam either leaves a + * `%propid:6%` slot to fill from asset property 6, or embeds the hex in the link itself. */ +function getMaskedInspectAction(description: rgAsset): {prefix: string; embeddedHex?: string} | undefined { for (const action of description.actions ?? []) { - const hex = MASKED_ACTION_PATTERN.exec(action.link)?.[1]; - if (hex) return {link: action.link, embeddedHex: hex === '%propid:6%' ? undefined : hex}; + const slot = MASKED_ACTION_PATTERN.exec(action.link)?.[1]; + if (slot) { + return { + prefix: action.link.slice(0, action.link.length - slot.length), + embeddedHex: slot.toLowerCase() === PROPERTY_SLOT ? undefined : slot, + }; + } } return undefined; } +/** The item's masked inspect hex, plus the `steam://` link that launches that same hex. */ function getSkinCraftInspect( - asset: InventoryAsset | undefined, + asset: InventoryAsset, fallbackProperties: rgAssetProperty[] -): string | undefined { - if (!asset?.description || !isSkinCraftRenderable(asset.description)) return; - - const property = getAssetProperties(asset, fallbackProperties) - .find((property) => property.propertyid === 6) - ?.string_value?.trim(); - return property || getMaskedInspectAction(asset.description)?.embeddedHex; -} +): Pick | undefined { + if (!isSkinCraftRenderable(asset.description)) return; -/** The asset's `steam://` inspect launch link, with any `%propid:6%` slot filled with the masked hex. */ -function getSteamInspectUrl(asset: InventoryAsset, inspect: string): string | undefined { const action = getMaskedInspectAction(asset.description); - const url = action?.embeddedHex ? action.link : action?.link.replace('%propid:6%', inspect); - return url && STEAM_INSPECT_URL_PATTERN.test(url) ? url : undefined; + const inspect = + getAssetProperties(asset, fallbackProperties) + .find((property) => property.propertyid === 6) + ?.string_value?.trim() || action?.embeddedHex; + if (!inspect || !SKINCRAFT_INSPECT_PATTERN.test(inspect)) return; + + const inspectUrl = action && `${action.prefix}${inspect}`; + return {inspect, inspectUrl: inspectUrl && STEAM_INSPECT_URL_PATTERN.test(inspectUrl) ? inspectUrl : undefined}; } export function toSkinCraftItem( @@ -70,16 +78,15 @@ export function toSkinCraftItem( ): 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 inspectFields = getSkinCraftInspect(asset, fallbackProperties); + if (!inspectFields) 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, - inspectUrl: getSteamInspectUrl(asset, inspect), + ...inspectFields, name: asset.description.market_hash_name, iconUrl: icon ? steamEconomyImageUrl(icon) : undefined, assetId: asset.assetid, diff --git a/src/lib/services/skincraft_viewer_protocol.ts b/src/lib/services/skincraft_viewer_protocol.ts index 7bcf8d24..79a9f44f 100644 --- a/src/lib/services/skincraft_viewer_protocol.ts +++ b/src/lib/services/skincraft_viewer_protocol.ts @@ -12,7 +12,7 @@ const ASSET_ID_PATTERN = /^\d{1,32}$/; /** An item as it crosses the page → content-script boundary. */ export type SkinCraftItem = { inspect: string; - /** The item's own `steam://` launch link, for Inspect clicks forwarded out of the embed. */ + /** The `steam://` launch link; `inspect` is the masked hex on its own. */ inspectUrl?: string; name: string; iconUrl?: string; From a778b862da5e725e5dfedb266f8d1bd512e1c572 Mon Sep 17 00:00:00 2001 From: Syed Zaidi Date: Mon, 17 Aug 2026 16:07:24 -0400 Subject: [PATCH 5/7] fix(inventory): ignore inspect requests for a model that is no longer on screen Selecting a strip item reassigns activeTarget synchronously but deliberately keeps the previous model on screen while the next one loads, so an Inspect click landing in that window launched the wrong item. Gating on the loaded phase turns that into a no-op, and a warn gives the dropped-click case an evidence trail. --- src/lib/services/skincraft_embed.ts | 12 +++++++++--- src/lib/services/skincraft_embed_protocol.ts | 3 +-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/lib/services/skincraft_embed.ts b/src/lib/services/skincraft_embed.ts index 837e72ce..3df3bfd6 100644 --- a/src/lib/services/skincraft_embed.ts +++ b/src/lib/services/skincraft_embed.ts @@ -217,10 +217,16 @@ class SkinCraftEmbedService { this.modal?.setError(message.message || 'SkinCraft could not load this item.'); break; case 'inspect-requested': { - // Re-check the shape: targets on the direct open() path never cross the - // validated page boundary. + // Re-checked at the navigation sink. `loaded` means the frame shows `activeTarget` — a + // switch-in-place keeps the old model on screen while the next loads, and it must not launch. + if (!this.active) break; + const url = this.activeTarget?.inspectUrl; - if (this.active && url && STEAM_INSPECT_URL_PATTERN.test(url)) window.location.href = url; + if (this.loadPhase === 'loaded' && url && STEAM_INSPECT_URL_PATTERN.test(url)) { + window.location.href = url; + } else { + console.warn('SkinCraft: no launchable inspect link for the item on screen.'); + } break; } } diff --git a/src/lib/services/skincraft_embed_protocol.ts b/src/lib/services/skincraft_embed_protocol.ts index 0acdd266..82e2bb73 100644 --- a/src/lib/services/skincraft_embed_protocol.ts +++ b/src/lib/services/skincraft_embed_protocol.ts @@ -18,8 +18,7 @@ export type SkinCraftEmbedEvent = EmbedEnvelope & | {type: 'progress'; id?: string; percent: number | null; label?: string} | {type: 'loaded'; id?: string} | {type: 'error'; id?: string; code: string; message: string} - // Inspect click forwarded out of the sandboxed iframe (it can't launch - // steam:// itself); we launch the shown item's own inspect link. + // The sandboxed iframe can't launch steam:// itself. | {type: 'inspect-requested'} ); From 1a72f6c52ab558a2c9687e67c4cbf0194f10882b Mon Sep 17 00:00:00 2001 From: Syed Zaidi Date: Mon, 17 Aug 2026 16:07:24 -0400 Subject: [PATCH 6/7] chore(env): point the dev embed origin at beta.skincraft.gg --- src/environment.dev.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/environment.dev.ts b/src/environment.dev.ts index 1db4c7c5..cddd6d0c 100644 --- a/src/environment.dev.ts +++ b/src/environment.dev.ts @@ -7,5 +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', + skincraft_embed_origin: 'https://beta.skincraft.gg', }; From f4d2893d77e40331062f0bba790b1ea6d0e3534a Mon Sep 17 00:00:00 2001 From: Syed Zaidi Date: Mon, 17 Aug 2026 16:09:54 -0400 Subject: [PATCH 7/7] chore(inventory): trim comments on the inspect resolver --- src/lib/services/skincraft_embed.ts | 4 ++-- src/lib/services/skincraft_inventory_targets.ts | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/lib/services/skincraft_embed.ts b/src/lib/services/skincraft_embed.ts index 3df3bfd6..86371d95 100644 --- a/src/lib/services/skincraft_embed.ts +++ b/src/lib/services/skincraft_embed.ts @@ -217,8 +217,8 @@ class SkinCraftEmbedService { this.modal?.setError(message.message || 'SkinCraft could not load this item.'); break; case 'inspect-requested': { - // Re-checked at the navigation sink. `loaded` means the frame shows `activeTarget` — a - // switch-in-place keeps the old model on screen while the next loads, and it must not launch. + // A switch-in-place keeps the old model on screen while the next loads; only `loaded` + // means the frame shows `activeTarget`. if (!this.active) break; const url = this.activeTarget?.inspectUrl; diff --git a/src/lib/services/skincraft_inventory_targets.ts b/src/lib/services/skincraft_inventory_targets.ts index 209e2422..be4ecab2 100644 --- a/src/lib/services/skincraft_inventory_targets.ts +++ b/src/lib/services/skincraft_inventory_targets.ts @@ -38,8 +38,7 @@ const PROPERTY_SLOT = '%propid:6%'; const MASKED_ACTION_PATTERN = /^steam:\/\/(?:run|rungame)\/730\/\d{0,20}\/\+csgo_econ_action_preview%20(%propid:6%|[0-9a-f]{40,8192})$/i; -/** The description's masked inspect action, split at the hex slot. Steam either leaves a - * `%propid:6%` slot to fill from asset property 6, or embeds the hex in the link itself. */ +/** Split at the hex slot, which Steam either fills from asset property 6 or embeds inline. */ function getMaskedInspectAction(description: rgAsset): {prefix: string; embeddedHex?: string} | undefined { for (const action of description.actions ?? []) { const slot = MASKED_ACTION_PATTERN.exec(action.link)?.[1]; @@ -53,7 +52,6 @@ function getMaskedInspectAction(description: rgAsset): {prefix: string; embedded return undefined; } -/** The item's masked inspect hex, plus the `steam://` link that launches that same hex. */ function getSkinCraftInspect( asset: InventoryAsset, fallbackProperties: rgAssetProperty[]