Skip to content
2 changes: 1 addition & 1 deletion src/environment.dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
19 changes: 18 additions & 1 deletion src/lib/services/skincraft_embed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}
};

Expand Down
1 change: 1 addition & 0 deletions src/lib/services/skincraft_embed_protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
3 changes: 3 additions & 0 deletions src/lib/services/skincraft_embed_protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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');
Expand Down
168 changes: 168 additions & 0 deletions src/lib/services/skincraft_inventory_targets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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',
Expand Down
56 changes: 45 additions & 11 deletions src/lib/services/skincraft_inventory_targets.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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<SkinCraftItem, 'inspect' | 'inspectUrl'> | 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(
Expand All @@ -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,
Expand Down
25 changes: 25 additions & 0 deletions src/lib/services/skincraft_viewer_protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading