Skip to content

Commit 9185647

Browse files
authored
Merge pull request #65 from BambooFury/feat/free-weekend
Feat/free weekend
2 parents d01cfad + 0022b2e commit 9185647

9 files changed

Lines changed: 307 additions & 32 deletions

File tree

.github/workflows/release-please.yml

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,57 @@ permissions:
1111
jobs:
1212
release-please:
1313
runs-on: ubuntu-latest
14+
outputs:
15+
release_created: ${{ steps.release.outputs.release_created }}
16+
tag_name: ${{ steps.release.outputs.tag_name }}
1417
steps:
1518
- uses: googleapis/release-please-action@v4
19+
id: release
1620
with:
1721
token: ${{ secrets.GITHUB_TOKEN }}
1822
config-file: release-please-config.json
1923
manifest-file: .release-please-manifest.json
24+
25+
build-asset:
26+
needs: release-please
27+
if: ${{ needs.release-please.outputs.release_created == 'true' }}
28+
runs-on: ubuntu-latest
29+
permissions:
30+
contents: write
31+
steps:
32+
- uses: actions/checkout@v4
33+
with:
34+
ref: ${{ needs.release-please.outputs.tag_name }}
35+
36+
- name: Setup Node.js
37+
uses: actions/setup-node@v4
38+
with:
39+
node-version: '20'
40+
cache: 'npm'
41+
42+
- name: Install dependencies
43+
run: npm ci
44+
45+
- name: Build plugin
46+
run: npm run build
47+
48+
- name: Stage release files
49+
run: |
50+
mkdir -p _release/auto-claim
51+
cp plugin.json _release/auto-claim/
52+
cp README.md _release/auto-claim/
53+
cp LICENSE _release/auto-claim/
54+
cp -r backend _release/auto-claim/
55+
cp -r .millennium _release/auto-claim/
56+
57+
- name: Create zip archive
58+
run: |
59+
cd _release
60+
zip -r "../auto-claim-${{ needs.release-please.outputs.tag_name }}.zip" auto-claim
61+
62+
- name: Upload to release
63+
uses: softprops/action-gh-release@v2
64+
with:
65+
tag_name: ${{ needs.release-please.outputs.tag_name }}
66+
files: auto-claim-*.zip
67+
token: ${{ secrets.GITHUB_TOKEN }}

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,6 @@ settings.json
1111
widget_settings.json
1212
pending_toasts.json
1313
claim_inflight.json
14+
free_weekend_cache.json
1415

1516
webkit/_assets.generated.ts

backend/main.lua

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ local SETTINGS_FILE = PLUGIN_DIR .. "\\settings.json"
186186
local WIDGETS_FILE = PLUGIN_DIR .. "\\widget_settings.json"
187187
local CACHE_FILE = PLUGIN_DIR .. "\\free_games_cache.json"
188188
local TOASTS_FILE = PLUGIN_DIR .. "\\pending_toasts.json"
189+
local WEEKEND_FILE = PLUGIN_DIR .. "\\free_weekend_cache.json"
189190
local CLAIM_LOCK_FILE = PLUGIN_DIR .. "\\claim_inflight.json"
190191
local CLAIM_LOCK_TTL = 60
191192

@@ -329,6 +330,17 @@ function save_free_games_cache_ipc(data)
329330
return 1
330331
end
331332

333+
function load_free_weekend_cache_ipc()
334+
return read_file(WEEKEND_FILE) or "[]"
335+
end
336+
337+
function save_free_weekend_cache_ipc(data)
338+
local payload = extract_payload(data)
339+
if not _is_valid_json_payload(payload, "array") then return 0 end
340+
write_file(WEEKEND_FILE, payload)
341+
return 1
342+
end
343+
332344

333345
local _CURL_MAX_BYTES = 8 * 1024 * 1024
334346
local _CURL_TIMEOUT_S = 15

frontend/index.tsx

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import React, { useState, useEffect, useCallback } from 'react';
33
import { SettingsTab, WidgetSettings } from './settings';
44
import { MIN_POLL_INTERVAL_MIN } from './constants';
55
import { runScan } from './scanner';
6+
import { scanFreeWeekend, WeekendGame } from './scanner/freeweekend';
67
type Empty = [];
78
type StrIn = [{ payload: string }];
89

@@ -12,6 +13,8 @@ const loadSettings = callable<Empty, string>('load_settings_ipc');
1213
const _logPluginIPC = callable<StrIn, number>('log_plugin');
1314
const saveFreeGamesCache = callable<StrIn, number>('save_free_games_cache_ipc');
1415
const loadFreeGamesCache = callable<Empty, string>('load_free_games_cache_ipc');
16+
const saveFreeWeekendCache = callable<StrIn, number>('save_free_weekend_cache_ipc');
17+
const loadFreeWeekendCache = callable<Empty, string>('load_free_weekend_cache_ipc');
1518
const _loadWidgetIPC = callable<Empty, string>('load_widget_settings_ipc');
1619
const _saveWidgetIPC = callable<StrIn, number>('save_widget_settings_ipc');
1720
const popToasts = callable<Empty, string>('pop_toasts_ipc');
@@ -476,7 +479,7 @@ async function startPolling(): Promise<void> {
476479
const wRaw = await withTimeout(_loadWidgetIPC(), 1000, '');
477480
if (wRaw) {
478481
const w = JSON.parse(wRaw);
479-
if (w && (w.filterMode === 'all' || w.filterMode === 'games')) {
482+
if (w && (w.filterMode === 'all' || w.filterMode === 'games' || w.filterMode === 'weekend')) {
480483
cachedWidgetFilterMode = w.filterMode;
481484
return;
482485
}
@@ -618,6 +621,68 @@ async function startPolling(): Promise<void> {
618621
log(`processGame error for ${game.name}: ${String(e)}`);
619622
}
620623
}
624+
625+
function showWeekendNotification(game: WeekendGame): void {
626+
const untilStr = new Date(game.until * 1000)
627+
.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
628+
toaster.toast({
629+
title: 'Free Weekend!',
630+
body: `${game.name} is free to play until ${untilStr}.`,
631+
logo: React.createElement('img', {
632+
src: `https://cdn.akamai.steamstatic.com/steam/apps/${game.appid}/header.jpg`,
633+
style: { width: '40px', height: '40px', objectFit: 'cover', borderRadius: '4px' },
634+
}),
635+
onClick: () => { (window as any).SteamClient?.Apps?.ShowStore?.(game.appid, 0); },
636+
duration: 12000,
637+
sound: 1,
638+
playSound: true,
639+
showToast: true,
640+
});
641+
}
642+
643+
async function runWeekendScan(): Promise<void> {
644+
try {
645+
const result = await scanFreeWeekend({ info: (m) => log(m), warn: (m) => log(m) });
646+
if (!result) {
647+
log('Weekend scan failed — keeping previous list');
648+
return;
649+
}
650+
651+
let prev: WeekendGame[] = [];
652+
try {
653+
prev = JSON.parse(await withTimeout(loadFreeWeekendCache(), 3000, '[]') || '[]');
654+
} catch {}
655+
const prevIds = new Set(prev.map((g) => g.appid));
656+
657+
const nowSec = Date.now() / 1000;
658+
const merged = [...result];
659+
for (const p of prev) {
660+
if (p.until > nowSec && !merged.some((g) => g.appid === p.appid)) merged.push(p);
661+
}
662+
663+
try {
664+
await withTimeout(saveFreeWeekendCache({ payload: JSON.stringify(merged) }), 3000, 0);
665+
} catch {}
666+
log(`Weekend scan complete — ${merged.length} game(s) playable for free`);
667+
668+
let notify = true;
669+
try {
670+
const sraw = await withTimeout(loadSettings(), 2000, '');
671+
if (sraw) notify = ({ ...DEFAULTS, ...JSON.parse(sraw) } as Settings).notifyOnGrab;
672+
} catch {}
673+
if (!notify) return;
674+
675+
for (const g of result) {
676+
if (prevIds.has(g.appid)) continue;
677+
if (isAlreadyInLibrary(g.appid)) continue;
678+
log(`Free weekend detected: ${g.name} (${g.appid})`);
679+
showWeekendNotification(g);
680+
await new Promise((r) => setTimeout(r, 1500));
681+
}
682+
} catch (e) {
683+
log(`runWeekendScan error: ${String(e)}`);
684+
}
685+
}
621686

622687
async function runOneScan(): Promise<boolean> {
623688
await reloadState();
@@ -737,6 +802,15 @@ async function startPolling(): Promise<void> {
737802
}
738803

739804
await triggerScan('initial');
805+
const WEEKEND_SCAN_INTERVAL_MS = 6 * 60 * 60 * 1000;
806+
let lastWeekendScanMs = Date.now();
807+
void runWeekendScan();
808+
_trackInterval(() => {
809+
if (Date.now() - lastWeekendScanMs >= WEEKEND_SCAN_INTERVAL_MS) {
810+
lastWeekendScanMs = Date.now();
811+
void runWeekendScan();
812+
}
813+
}, 10 * 60 * 1000);
740814

741815
const pollManualScanRequest = async () => {
742816
const manualRequestedAt = await consumeManualScanRequest();

frontend/scanner/freeweekend.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { callable } from '@steambrew/client';
2+
import type { ScannerLogger } from './types';
3+
import { safeFetch, safeParse } from './http';
4+
5+
const fetchUrlViaCurl = callable<[{ payload: string }], string>('fetch_url_via_curl_ipc');
6+
7+
const QUERY_URL = 'https://api.steampowered.com/IStoreQueryService/Query/v1/?input_json=';
8+
const PAGE_SIZE = 1000;
9+
const MAX_PAGES = 30;
10+
11+
export interface WeekendGame {
12+
appid: number;
13+
name: string;
14+
type: 'weekend';
15+
until: number;
16+
}
17+
18+
interface QueryStoreItem {
19+
appid?: number;
20+
name?: string;
21+
free_weekend?: { start_time?: number; end_time?: number };
22+
}
23+
24+
interface QueryResponse {
25+
response?: {
26+
metadata?: { total_matching_records?: number };
27+
store_items?: QueryStoreItem[];
28+
};
29+
}
30+
31+
function buildPageUrl(start: number): string {
32+
const input = {
33+
query: {
34+
start,
35+
count: PAGE_SIZE,
36+
filters: {
37+
type_filters: { include_apps: true },
38+
price_filters: { min_discount_percent: 1 },
39+
},
40+
},
41+
context: { language: 'english', country_code: 'US' },
42+
data_request: {},
43+
};
44+
return QUERY_URL + encodeURIComponent(JSON.stringify(input));
45+
}
46+
47+
async function fetchPage(url: string, log?: ScannerLogger): Promise<string | null> {
48+
const res = await safeFetch(url, 30000, log);
49+
if (res && res.status === 200 && res.body) return res.body;
50+
51+
try {
52+
const body = await fetchUrlViaCurl({ payload: url });
53+
return body || null;
54+
} catch (e: any) {
55+
log?.warn(`[weekend] curl IPC threw: ${e?.message || e}`);
56+
return null;
57+
}
58+
}
59+
60+
export async function scanFreeWeekend(log?: ScannerLogger): Promise<WeekendGame[] | null> {
61+
const found: WeekendGame[] = [];
62+
const seen = new Set<number>();
63+
const now = Date.now() / 1000;
64+
65+
let start = 0;
66+
let total = Infinity;
67+
68+
for (let page = 0; page < MAX_PAGES && start < total; page++) {
69+
const body = await fetchPage(buildPageUrl(start), log);
70+
if (!body) {
71+
log?.warn(`[weekend] page ${page} unreachable — aborting weekend scan`);
72+
return null;
73+
}
74+
75+
const data = safeParse<QueryResponse>(body, log, `(weekend page ${page})`);
76+
if (!data || !data.response) return null;
77+
78+
const items = Array.isArray(data.response.store_items) ? data.response.store_items : [];
79+
const meta = data.response.metadata;
80+
if (meta && typeof meta.total_matching_records === 'number') {
81+
total = meta.total_matching_records;
82+
}
83+
84+
for (const it of items) {
85+
const appid = typeof it.appid === 'number' ? it.appid : 0;
86+
const fw = it.free_weekend;
87+
if (!appid || !fw || seen.has(appid)) continue;
88+
89+
const until = typeof fw.end_time === 'number' ? fw.end_time : 0;
90+
if (until <= now) continue;
91+
92+
seen.add(appid);
93+
found.push({
94+
appid,
95+
name: typeof it.name === 'string' && it.name ? it.name : 'AppID ' + appid,
96+
type: 'weekend',
97+
until,
98+
});
99+
}
100+
101+
if (items.length === 0) break;
102+
start += items.length;
103+
}
104+
105+
log?.info(`[weekend] scan ok — ${found.length} free-weekend game(s)`);
106+
return found;
107+
}

webkit/ipc.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,13 @@ type Empty = [];
55

66
export const loadPluginSettingsIPC = callable<Empty, string>('load_settings_ipc');
77
export const savePluginSettingsIPC = callable<StrIn, number>('save_settings_ipc');
8-
98
export const loadWidgetSettingsIPC = callable<Empty, string>('load_widget_settings_ipc');
109
export const saveWidgetSettingsIPC = callable<StrIn, number>('save_widget_settings_ipc');
11-
1210
export const loadFreeGamesCacheIPC = callable<Empty, string>('load_free_games_cache_ipc');
11+
export const loadFreeWeekendCacheIPC = callable<Empty, string>('load_free_weekend_cache_ipc');
1312
export const loadGrabbedIPC = callable<Empty, string>('load_grabbed_ipc');
14-
1513
export const pushToastIPC = callable<StrIn, number>('push_toast_ipc');
16-
1714
export const logIPC = callable<StrIn, number>('log_plugin');
18-
19-
2015
export const tryAcquireClaimLockIPC = callable<StrIn, number>('try_acquire_claim_lock_ipc');
2116
export const releaseClaimLockIPC = callable<StrIn, number>('release_claim_lock_ipc');
2217

webkit/settings.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export let initialWidgetRaw = '';
3838

3939
const VALID_PANEL_SIDES: PanelSide[] = ['left', 'right'];
4040
const VALID_TAB_STYLES: TabStyle[] = ['slim', 'large', 'floating'];
41-
const VALID_FILTER_MODES: FilterMode[] = ['games', 'all'];
41+
const VALID_FILTER_MODES: FilterMode[] = ['games', 'all', 'weekend'];
4242

4343
function isPanelSide(v: any): v is PanelSide { return VALID_PANEL_SIDES.indexOf(v) !== -1; }
4444
function isTabStyle(v: any): v is TabStyle { return VALID_TAB_STYLES.indexOf(v) !== -1; }

webkit/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ export type FreeGame = {
44
type?: string;
55
header?: string;
66
capsule?: string;
7+
until?: number;
78
};
89

910
export type PanelSide = 'left' | 'right';
1011
export type TabStyle = 'slim' | 'large' | 'floating';
11-
export type FilterMode = 'games' | 'all';
12+
export type FilterMode = 'games' | 'all' | 'weekend';

0 commit comments

Comments
 (0)