Initial Commit
This commit is contained in:
131
server/db.js
Normal file
131
server/db.js
Normal file
@@ -0,0 +1,131 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const DB_PATH = process.env.DB_PATH || path.join(__dirname, '..', 'data', 'ads.json');
|
||||
const APPS_DB_PATH = process.env.APPS_DB_PATH || path.join(__dirname, '..', 'data', 'apps.json');
|
||||
|
||||
// ── ads ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
function readDb() {
|
||||
try { return JSON.parse(fs.readFileSync(DB_PATH, 'utf8')); }
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
function writeDb(ads) {
|
||||
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
||||
fs.writeFileSync(DB_PATH, JSON.stringify(ads, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
function listAds(appId) {
|
||||
const ads = readDb();
|
||||
if (appId) return ads.filter(ad => ad.appId === appId);
|
||||
return ads;
|
||||
}
|
||||
|
||||
function getAd(id) {
|
||||
return readDb().find(ad => ad.id === id) || null;
|
||||
}
|
||||
|
||||
function insertAd({ id, filename, title, appId, tags, developer, repo, branch, notes }) {
|
||||
const ads = readDb();
|
||||
const now = Date.now();
|
||||
const ad = { id, filename, title, appId: appId || null, tags: tags || [], developer: developer || '', repo: repo || '', branch: branch || '', notes: notes || '', thumbnail: null, uploaded_at: now, updated_at: now };
|
||||
ads.unshift(ad);
|
||||
writeDb(ads);
|
||||
return ad;
|
||||
}
|
||||
|
||||
function updateAd(id, { title, appId, tags, developer, repo, branch, notes }) {
|
||||
const ads = readDb();
|
||||
const idx = ads.findIndex(ad => ad.id === id);
|
||||
if (idx === -1) return null;
|
||||
ads[idx] = {
|
||||
...ads[idx],
|
||||
title,
|
||||
appId: appId ?? ads[idx].appId ?? null,
|
||||
tags: tags ?? ads[idx].tags ?? [],
|
||||
developer: developer ?? ads[idx].developer ?? '',
|
||||
repo: repo ?? ads[idx].repo ?? '',
|
||||
branch: branch ?? ads[idx].branch ?? '',
|
||||
notes: notes ?? ads[idx].notes ?? '',
|
||||
updated_at: Date.now(),
|
||||
};
|
||||
writeDb(ads);
|
||||
return ads[idx];
|
||||
}
|
||||
|
||||
function updateAdFile(id, filename) {
|
||||
const ads = readDb();
|
||||
const idx = ads.findIndex(ad => ad.id === id);
|
||||
if (idx === -1) return null;
|
||||
ads[idx] = { ...ads[idx], filename, updated_at: Date.now() };
|
||||
writeDb(ads);
|
||||
return ads[idx];
|
||||
}
|
||||
|
||||
function updateAdThumbnail(id, thumbnail) {
|
||||
const ads = readDb();
|
||||
const idx = ads.findIndex(ad => ad.id === id);
|
||||
if (idx === -1) return null;
|
||||
ads[idx] = { ...ads[idx], thumbnail, updated_at: Date.now() };
|
||||
writeDb(ads);
|
||||
return ads[idx];
|
||||
}
|
||||
|
||||
function deleteAd(id) {
|
||||
const ads = readDb();
|
||||
const idx = ads.findIndex(ad => ad.id === id);
|
||||
if (idx === -1) return null;
|
||||
const [ad] = ads.splice(idx, 1);
|
||||
writeDb(ads);
|
||||
return ad;
|
||||
}
|
||||
|
||||
// ── apps ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function readAppsDb() {
|
||||
try { return JSON.parse(fs.readFileSync(APPS_DB_PATH, 'utf8')); }
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
function writeAppsDb(apps) {
|
||||
fs.mkdirSync(path.dirname(APPS_DB_PATH), { recursive: true });
|
||||
fs.writeFileSync(APPS_DB_PATH, JSON.stringify(apps, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
function listApps() {
|
||||
return readAppsDb();
|
||||
}
|
||||
|
||||
function getApp(id) {
|
||||
return readAppsDb().find(a => a.id === id) || null;
|
||||
}
|
||||
|
||||
function insertApp({ name, ios_url, android_url, icon_url }) {
|
||||
const apps = readAppsDb();
|
||||
const app = { id: crypto.randomUUID(), name, icon_url: icon_url || '', ios_url: ios_url || '', android_url: android_url || '', created_at: Date.now() };
|
||||
apps.unshift(app);
|
||||
writeAppsDb(apps);
|
||||
return app;
|
||||
}
|
||||
|
||||
function updateApp(id, { name, ios_url, android_url, icon_url }) {
|
||||
const apps = readAppsDb();
|
||||
const idx = apps.findIndex(a => a.id === id);
|
||||
if (idx === -1) return null;
|
||||
apps[idx] = { ...apps[idx], name, icon_url: icon_url ?? apps[idx].icon_url ?? '', ios_url: ios_url || '', android_url: android_url || '' };
|
||||
writeAppsDb(apps);
|
||||
return apps[idx];
|
||||
}
|
||||
|
||||
function deleteApp(id) {
|
||||
const apps = readAppsDb();
|
||||
const idx = apps.findIndex(a => a.id === id);
|
||||
if (idx === -1) return null;
|
||||
const [app] = apps.splice(idx, 1);
|
||||
writeAppsDb(apps);
|
||||
return app;
|
||||
}
|
||||
|
||||
module.exports = { listAds, getAd, insertAd, updateAd, updateAdFile, updateAdThumbnail, deleteAd, listApps, getApp, insertApp, updateApp, deleteApp };
|
||||
49
server/index.js
Normal file
49
server/index.js
Normal file
@@ -0,0 +1,49 @@
|
||||
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
|
||||
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const UPLOADS_DIR = process.env.UPLOADS_DIR || path.join(__dirname, '..', 'uploads');
|
||||
|
||||
// 10mb limit needed for base64-encoded screenshot payloads
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
|
||||
// Auth middleware for protected API routes
|
||||
function requireAuth(req, res, next) {
|
||||
const password = process.env.SHOWCASE_PASSWORD;
|
||||
if (!password) return next(); // not configured, skip
|
||||
|
||||
const auth = req.headers['authorization'] || '';
|
||||
const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
|
||||
const expected = crypto.createHmac('sha256', password).update('showcase-session').digest('hex');
|
||||
|
||||
if (token !== expected) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
// Auth route (public)
|
||||
app.use('/api/auth', require('./routes/auth').router);
|
||||
|
||||
// Protected API routes
|
||||
app.use('/api/apps', requireAuth, require('./routes/apps'));
|
||||
app.use('/api/ads', requireAuth, require('./routes/upload'));
|
||||
app.use('/api/ads', requireAuth, require('./routes/ads'));
|
||||
|
||||
// Serve uploaded HTML files
|
||||
app.use('/uploads', express.static(UPLOADS_DIR));
|
||||
|
||||
// Serve React build in production
|
||||
const publicDir = path.join(__dirname, '..', 'public');
|
||||
app.use(express.static(publicDir));
|
||||
app.get('*', (_req, res) => {
|
||||
res.sendFile(path.join(publicDir, 'index.html'));
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Playable Showcase running on http://localhost:${PORT}`);
|
||||
});
|
||||
2684
server/package-lock.json
generated
Normal file
2684
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
server/package.json
Normal file
19
server/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "playable-showcase-server",
|
||||
"version": "1.0.0",
|
||||
"type": "commonjs",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"dev": "nodemon index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^4.18.3",
|
||||
"multer": "^2.0.0",
|
||||
"puppeteer": "^22.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.0"
|
||||
}
|
||||
}
|
||||
119
server/routes/ads.js
Normal file
119
server/routes/ads.js
Normal file
@@ -0,0 +1,119 @@
|
||||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const multer = require('multer');
|
||||
const { listAds, getAd, updateAd, updateAdFile, updateAdThumbnail, deleteAd } = require('../db');
|
||||
const { captureScreenshot } = require('../utils/screenshot');
|
||||
|
||||
const UPLOADS_DIR = process.env.UPLOADS_DIR || path.join(__dirname, '..', '..', 'uploads');
|
||||
|
||||
const fileStorage = multer.diskStorage({
|
||||
destination: (_req, _file, cb) => cb(null, UPLOADS_DIR),
|
||||
filename: (_req, _file, cb) => cb(null, `${crypto.randomUUID()}.html`),
|
||||
});
|
||||
|
||||
const uploadFile = multer({
|
||||
storage: fileStorage,
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (file.mimetype === 'text/html' || file.originalname.endsWith('.html')) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only .html files are allowed'));
|
||||
}
|
||||
},
|
||||
limits: { fileSize: 50 * 1024 * 1024 },
|
||||
});
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
res.json(listAds(req.query.appId));
|
||||
});
|
||||
|
||||
router.get('/:id', (req, res) => {
|
||||
const ad = getAd(req.params.id);
|
||||
if (!ad) return res.status(404).json({ error: 'Not found' });
|
||||
res.json(ad);
|
||||
});
|
||||
|
||||
router.patch('/:id', (req, res) => {
|
||||
const existing = getAd(req.params.id);
|
||||
if (!existing) return res.status(404).json({ error: 'Not found' });
|
||||
|
||||
const { title, appId, tags, developer, repo, branch, notes } = req.body;
|
||||
const updated = updateAd(req.params.id, {
|
||||
title: title ?? existing.title,
|
||||
appId: appId ?? existing.appId ?? null,
|
||||
tags: tags ?? existing.tags ?? [],
|
||||
developer: developer ?? existing.developer ?? '',
|
||||
repo: repo ?? existing.repo ?? '',
|
||||
branch: branch ?? existing.branch ?? '',
|
||||
notes: notes ?? existing.notes ?? '',
|
||||
});
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
router.post('/:id/thumbnail', express.json({ limit: '10mb' }), (req, res) => {
|
||||
const ad = getAd(req.params.id);
|
||||
if (!ad) return res.status(404).json({ error: 'Not found' });
|
||||
|
||||
const { dataUrl } = req.body;
|
||||
if (!dataUrl || !dataUrl.startsWith('data:image/')) {
|
||||
return res.status(400).json({ error: 'Invalid dataUrl' });
|
||||
}
|
||||
|
||||
const thumbFilename = `${req.params.id}-thumb.png`;
|
||||
const base64Data = dataUrl.replace(/^data:image\/\w+;base64,/, '');
|
||||
fs.writeFileSync(path.join(UPLOADS_DIR, thumbFilename), Buffer.from(base64Data, 'base64'));
|
||||
|
||||
const updated = updateAdThumbnail(req.params.id, thumbFilename);
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
router.post('/:id/screenshot', async (req, res) => {
|
||||
const ad = getAd(req.params.id);
|
||||
if (!ad) return res.status(404).json({ error: 'Not found' });
|
||||
|
||||
try {
|
||||
const port = process.env.PORT || 3000;
|
||||
const url = `http://127.0.0.1:${port}/uploads/${ad.filename}`;
|
||||
const buffer = await captureScreenshot(url);
|
||||
|
||||
const thumbFilename = `${req.params.id}-thumb.png`;
|
||||
fs.writeFileSync(path.join(UPLOADS_DIR, thumbFilename), buffer);
|
||||
|
||||
const updated = updateAdThumbnail(req.params.id, thumbFilename);
|
||||
res.json(updated);
|
||||
} catch (err) {
|
||||
console.error('Screenshot failed:', err.message);
|
||||
res.status(500).json({ error: 'Screenshot failed' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:id/file', uploadFile.single('file'), (req, res) => {
|
||||
const ad = getAd(req.params.id);
|
||||
if (!ad) return res.status(404).json({ error: 'Not found' });
|
||||
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||
|
||||
const oldPath = path.join(UPLOADS_DIR, ad.filename);
|
||||
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
|
||||
|
||||
const updated = updateAdFile(req.params.id, req.file.filename);
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
router.delete('/:id', (req, res) => {
|
||||
const ad = deleteAd(req.params.id);
|
||||
if (!ad) return res.status(404).json({ error: 'Not found' });
|
||||
|
||||
const filePath = path.join(UPLOADS_DIR, ad.filename);
|
||||
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||
|
||||
const thumbPath = path.join(UPLOADS_DIR, `${ad.id}-thumb.png`);
|
||||
if (fs.existsSync(thumbPath)) fs.unlinkSync(thumbPath);
|
||||
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
115
server/routes/apps.js
Normal file
115
server/routes/apps.js
Normal file
@@ -0,0 +1,115 @@
|
||||
const express = require('express');
|
||||
const { URL } = require('url');
|
||||
const { listApps, getApp, insertApp, updateApp, deleteApp } = require('../db');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// ── store lookup helper ───────────────────────────────────────────────────────
|
||||
|
||||
function metaContent(html, property) {
|
||||
// handles both attribute orderings
|
||||
const patterns = [
|
||||
new RegExp(`<meta[^>]+property="${property}"[^>]+content="([^"]*)"`, 'i'),
|
||||
new RegExp(`<meta[^>]+content="([^"]*)"[^>]+property="${property}"`, 'i'),
|
||||
];
|
||||
for (const re of patterns) {
|
||||
const m = html.match(re);
|
||||
if (m) return m[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchText(url) {
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
},
|
||||
signal: AbortSignal.timeout(10000),
|
||||
redirect: 'follow',
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.text();
|
||||
}
|
||||
|
||||
async function lookupAppStore(storeUrl) {
|
||||
const m = storeUrl.match(/\/id(\d+)/);
|
||||
if (!m) throw new Error('Could not parse App Store app ID from URL');
|
||||
const body = await fetchText(`https://itunes.apple.com/lookup?id=${m[1]}`);
|
||||
const data = JSON.parse(body);
|
||||
const r = data.results?.[0];
|
||||
if (!r) throw new Error('App not found in iTunes catalogue');
|
||||
return {
|
||||
name: r.trackName,
|
||||
icon_url: r.artworkUrl512 || r.artworkUrl100 || r.artworkUrl60 || '',
|
||||
};
|
||||
}
|
||||
|
||||
async function lookupPlayStore(storeUrl) {
|
||||
const m = storeUrl.match(/[?&]id=([^&]+)/);
|
||||
if (!m) throw new Error('Could not parse Play Store package ID from URL');
|
||||
const body = await fetchText(`https://play.google.com/store/apps/details?id=${m[1]}&hl=en`);
|
||||
const title = metaContent(body, 'og:title');
|
||||
if (!title) throw new Error('Could not extract app name from Play Store page');
|
||||
const name = title.replace(/\s*[-–]\s*Apps on Google Play\s*$/i, '').trim();
|
||||
const icon_url = metaContent(body, 'og:image') || '';
|
||||
return { name, icon_url };
|
||||
}
|
||||
|
||||
// ── routes ────────────────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/lookup', async (req, res) => {
|
||||
const { url } = req.query;
|
||||
if (!url) return res.status(400).json({ error: 'url query param is required' });
|
||||
|
||||
try {
|
||||
new URL(url); // validate
|
||||
} catch {
|
||||
return res.status(400).json({ error: 'Invalid URL' });
|
||||
}
|
||||
|
||||
try {
|
||||
if (url.includes('apps.apple.com')) {
|
||||
return res.json(await lookupAppStore(url));
|
||||
}
|
||||
if (url.includes('play.google.com')) {
|
||||
return res.json(await lookupPlayStore(url));
|
||||
}
|
||||
return res.status(422).json({ error: 'URL must be an App Store or Play Store link' });
|
||||
} catch (err) {
|
||||
console.error('App lookup failed:', err.message);
|
||||
res.status(502).json({ error: err.message || 'Lookup failed' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/', (_req, res) => {
|
||||
res.json(listApps());
|
||||
});
|
||||
|
||||
router.post('/', (req, res) => {
|
||||
const { name, ios_url, android_url, icon_url } = req.body;
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'name is required' });
|
||||
const app = insertApp({ name: name.trim(), ios_url: ios_url || '', android_url: android_url || '', icon_url: icon_url || '' });
|
||||
res.status(201).json(app);
|
||||
});
|
||||
|
||||
router.patch('/:id', (req, res) => {
|
||||
const existing = getApp(req.params.id);
|
||||
if (!existing) return res.status(404).json({ error: 'Not found' });
|
||||
const { name, ios_url, android_url, icon_url } = req.body;
|
||||
const updated = updateApp(req.params.id, {
|
||||
name: name ?? existing.name,
|
||||
ios_url: ios_url ?? existing.ios_url,
|
||||
android_url: android_url ?? existing.android_url,
|
||||
icon_url: icon_url ?? existing.icon_url,
|
||||
});
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
router.delete('/:id', (req, res) => {
|
||||
const app = deleteApp(req.params.id);
|
||||
if (!app) return res.status(404).json({ error: 'Not found' });
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
25
server/routes/auth.js
Normal file
25
server/routes/auth.js
Normal file
@@ -0,0 +1,25 @@
|
||||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function makeToken(password) {
|
||||
return crypto.createHmac('sha256', password).update('showcase-session').digest('hex');
|
||||
}
|
||||
|
||||
router.post('/login', (req, res) => {
|
||||
const { password } = req.body;
|
||||
const expected = process.env.SHOWCASE_PASSWORD;
|
||||
|
||||
if (!expected) {
|
||||
return res.status(500).json({ error: 'Server not configured' });
|
||||
}
|
||||
|
||||
if (!password || password !== expected) {
|
||||
return res.status(401).json({ error: 'Invalid password' });
|
||||
}
|
||||
|
||||
res.json({ token: makeToken(expected) });
|
||||
});
|
||||
|
||||
module.exports = { router, makeToken };
|
||||
52
server/routes/upload.js
Normal file
52
server/routes/upload.js
Normal file
@@ -0,0 +1,52 @@
|
||||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const { insertAd } = require('../db');
|
||||
const { parseTitle } = require('../utils/parseTitle');
|
||||
|
||||
const UPLOADS_DIR = process.env.UPLOADS_DIR || path.join(__dirname, '..', '..', 'uploads');
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (_req, _file, cb) => cb(null, UPLOADS_DIR),
|
||||
filename: (_req, _file, cb) => cb(null, `${crypto.randomUUID()}.html`),
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (file.mimetype === 'text/html' || file.originalname.endsWith('.html')) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only .html files are allowed'));
|
||||
}
|
||||
},
|
||||
limits: { fileSize: 50 * 1024 * 1024 },
|
||||
});
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post('/', upload.single('file'), (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||
|
||||
const html = fs.readFileSync(req.file.path, 'utf8');
|
||||
const extractedTitle = parseTitle(html);
|
||||
const id = path.basename(req.file.filename, '.html');
|
||||
|
||||
const ad = insertAd({
|
||||
id,
|
||||
filename: req.file.filename,
|
||||
title: req.body.title || extractedTitle || req.file.originalname.replace(/\.html$/i, ''),
|
||||
appId: req.body.appId || null,
|
||||
tags: req.body.tags ? JSON.parse(req.body.tags) : [],
|
||||
developer: req.body.developer || '',
|
||||
repo: req.body.repo || '',
|
||||
branch: req.body.branch || '',
|
||||
notes: req.body.notes || '',
|
||||
});
|
||||
|
||||
res.status(201).json(ad);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
6
server/utils/parseTitle.js
Normal file
6
server/utils/parseTitle.js
Normal file
@@ -0,0 +1,6 @@
|
||||
function parseTitle(html) {
|
||||
const match = html.match(/<title[^>]*>([^<]*)<\/title>/i);
|
||||
return match ? match[1].trim() : '';
|
||||
}
|
||||
|
||||
module.exports = { parseTitle };
|
||||
89
server/utils/screenshot.js
Normal file
89
server/utils/screenshot.js
Normal file
@@ -0,0 +1,89 @@
|
||||
const puppeteer = require('puppeteer');
|
||||
const fs = require('fs');
|
||||
|
||||
const FINAL_SETTLE_MS = 800;
|
||||
const CANDIDATE_DELAYS_MS = [0, 500, 500];
|
||||
const EDGE_PATHS = [
|
||||
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
|
||||
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
|
||||
];
|
||||
|
||||
function wait(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function getBrowserExecutablePath() {
|
||||
if (process.env.PUPPETEER_EXECUTABLE_PATH) return process.env.PUPPETEER_EXECUTABLE_PATH;
|
||||
return EDGE_PATHS.find(p => fs.existsSync(p));
|
||||
}
|
||||
|
||||
async function captureScreenshot(url, width = 390, height = 844) {
|
||||
const executablePath = getBrowserExecutablePath();
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
executablePath,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--enable-webgl',
|
||||
'--ignore-gpu-blocklist',
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width, height, deviceScaleFactor: 1 });
|
||||
page.on('console', msg => console.log(`[screenshot:${msg.type()}] ${msg.text()}`));
|
||||
|
||||
// Unity clears WebGL buffers by default, so force readable buffers before
|
||||
// page scripts create their contexts.
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
const orig = HTMLCanvasElement.prototype.getContext;
|
||||
HTMLCanvasElement.prototype.getContext = function (type, attrs) {
|
||||
if (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl') {
|
||||
attrs = Object.assign({}, attrs || {}, { preserveDrawingBuffer: true });
|
||||
}
|
||||
return orig.call(this, type, attrs);
|
||||
};
|
||||
});
|
||||
|
||||
console.log(`[screenshot] loading ${url}`);
|
||||
await page.goto(url, { waitUntil: 'load', timeout: 60000 });
|
||||
|
||||
try {
|
||||
await page.waitForFunction(() => {
|
||||
const canvases = Array.from(document.querySelectorAll('canvas'))
|
||||
.filter(c => c.width > 0 && c.height > 0);
|
||||
return canvases.some(c => {
|
||||
try {
|
||||
const d = c.toDataURL('image/png');
|
||||
return (d.split(',')[1] || '').length > 2000;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}, { timeout: 20000, polling: 500 });
|
||||
console.log('[screenshot] canvas pixels detected');
|
||||
} catch (err) {
|
||||
console.log(`[screenshot] canvas readiness timed out: ${err.message}`);
|
||||
}
|
||||
|
||||
// HTML overlays and Unity's first fully composited frame can lag behind
|
||||
// canvas readiness, so capture a short burst and keep the richest PNG.
|
||||
await wait(FINAL_SETTLE_MS);
|
||||
let bestBuffer = null;
|
||||
for (const delay of CANDIDATE_DELAYS_MS) {
|
||||
if (delay) await wait(delay);
|
||||
const buffer = await page.screenshot({ type: 'png' });
|
||||
if (!bestBuffer || buffer.length > bestBuffer.length) bestBuffer = buffer;
|
||||
}
|
||||
|
||||
console.log(`[screenshot] selected ${bestBuffer.length} bytes`);
|
||||
return bestBuffer;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { captureScreenshot };
|
||||
Reference in New Issue
Block a user