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', }; diff --git a/src/lib/services/skincraft_embed.ts b/src/lib/services/skincraft_embed.ts index 84fdc230..86371d95 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,19 @@ class SkinCraftEmbedService { this.frameHasContent = false; this.modal?.setError(message.message || 'SkinCraft could not load this item.'); break; + case 'inspect-requested': { + // 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; + 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.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..82e2bb73 100644 --- a/src/lib/services/skincraft_embed_protocol.ts +++ b/src/lib/services/skincraft_embed_protocol.ts @@ -18,6 +18,8 @@ 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} + // The sandboxed iframe can't launch steam:// itself. + | {type: 'inspect-requested'} ); export function isSkinCraftEmbedEvent(data: unknown): data is SkinCraftEmbedEvent { @@ -35,6 +37,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..8d729d3f 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,173 @@ 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'], + // 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 = { + 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://run/730//+csgo_econ_action_preview%20%propid:6%'}, + ], + }, + } as unknown as InventoryAsset; + + expect(toSkinCraftItem(asset)?.inspectUrl).toBe( + `steam://run/730//+csgo_econ_action_preview%20${'a'.repeat(80)}` + ); + }); + + 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)}], + 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)).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('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)}], + 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('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)}], + description: { + market_hash_name: 'name', + tags: [{category: 'Type', internal_name: 'CSGO_Tool_Sticker'}], + }, + } 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', diff --git a/src/lib/services/skincraft_inventory_targets.ts b/src/lib/services/skincraft_inventory_targets.ts index 3001ec15..be4ecab2 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,17 +23,50 @@ function getAssetProperties(asset: InventoryAsset, fallbackProperties: rgAssetPr return fallbackProperties; } +/** Item types SkinCraft renders (gloves fall under `isSkin`). */ +function isSkinCraftRenderable(description: rgAsset): boolean { + return ( + isSkin(description) || + isSticker(description) || + isCharm(description) || + isPatch(description) || + isAgent(description) + ); +} + +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; + +/** 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]; + if (slot) { + return { + prefix: action.link.slice(0, action.link.length - slot.length), + embeddedHex: slot.toLowerCase() === PROPERTY_SLOT ? undefined : slot, + }; + } + } + return undefined; +} + function getSkinCraftInspect( - asset: InventoryAsset | undefined, + asset: InventoryAsset, fallbackProperties: rgAssetProperty[] -): string | undefined { - if (!asset?.description || !isSkin(asset.description)) return; +): Pick | undefined { + if (!isSkinCraftRenderable(asset.description)) return; - return ( + const action = getMaskedInspectAction(asset.description); + const inspect = getAssetProperties(asset, fallbackProperties) .find((property) => property.propertyid === 6) - ?.string_value?.trim() || undefined - ); + ?.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( @@ -42,15 +76,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, + ...inspectFields, 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..79a9f44f 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:\/\/(?: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. */ export type SkinCraftItem = { inspect: string; + /** The `steam://` launch link; `inspect` is the masked hex on its own. */ + 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) && 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; }