Seregon/zftpd

Zero-copy FTP/HTTP Daemon compatible with all POSIX systems

C/11.0 KB/No license
web/js/views/games.js
zftpd / web / js / views / games.js
1/* ══ GAMES VIEW — XMB STYLE ═══════════════════════════════════════════════
2 * PS4-like horizontal shelves with icons and game actions.
3 * ES5 compatible for PS5 browser.
4 * ═════════════════════════════════════════════════════════════════════════ */
5 
6var ZFTPD = ZFTPD || {};
7 
8(function (Z) {
9 'use strict';
10 
11 var D = document;
12 var $ = Z.$;
13 var games = {};
14 
15 var _bound = false;
16 var _statusTimer = null;
17 var _lastTaskId = -1;
18 var _lastMilestone = -1;
19 var _lastErrorCode = 0;
20 var _installed = [];
21 var _images = [];
22 var _scanRoots = ['/mnt/usb0', '/mnt/usb1', '/data', '/user', '/'];
23 var _gameExts = { pkg: 1, fpkg: 1, ffpkg: 1, exfat: 1 };
24 
25 games.refresh = function () {
26 bindUI();
27 loadInstalled();
28 startStatusPolling();
29 pollInstallStatus();
30 };
31 
32 function bindUI() {
33 if (_bound) return;
34 _bound = true;
35 
36 var refreshBtn = $('games-refresh-btn');
37 if (refreshBtn) refreshBtn.onclick = function () {
38 loadInstalled();
39 pollInstallStatus();
40 };
41 
42 var repairBtn = $('games-repair-btn');
43 if (repairBtn) {
44 repairBtn.onclick = function () {
45 Z.api.gamesRepairVisibility().then(function (r) {
46 var extra = '';
47 if (r && r.sqlite_repair) {
48 extra = ' • SQL rows ' + (r.sqlite_repair.rows || 0);
49 }
50 Z.toast(((r && r.message) || 'Visibility repaired') + extra, 'ok');
51 loadInstalled();
52 }).catch(function (e) {
53 Z.toast('Repair failed: ' + (e && e.message ? e.message : 'error'), 'er');
54 });
55 };
56 }
57 
58 var scanBtn = $('games-scan-btn');
59 if (scanBtn) scanBtn.onclick = scanImages;
60 
61 var installBtn = $('games-install-btn');
62 if (installBtn) {
63 installBtn.onclick = function () {
64 var inp = $('games-install-path');
65 var p = inp ? (inp.value || '').trim() : '';
66 if (!p) {
67 Z.toast('Insert a PKG path', 'wn');
68 return;
69 }
70 doInstall(p, false);
71 };
72 }
73 
74 var reinstallBtn = $('games-reinstall-btn');
75 if (reinstallBtn) {
76 reinstallBtn.onclick = function () {
77 var inp = $('games-install-path');
78 var p = inp ? (inp.value || '').trim() : '';
79 if (!p) {
80 Z.toast('Insert a PKG path', 'wn');
81 return;
82 }
83 doInstall(p, true);
84 };
85 }
86 }
87 
88 function loadInstalled() {
89 var el = $('games-installed-list');
90 if (el) el.innerHTML = '<div class="games-empty">Loading installed apps…</div>';
91 
92 Z.api.gamesInstalled().then(function (res) {
93 _installed = (res && res.entries && Array.isArray(res.entries)) ? res.entries : [];
94 renderInstalled();
95 }).catch(function (err) {
96 if (el) el.innerHTML = '<div class="games-empty">Failed to load installed list</div>';
97 Z.toast('Installed list failed: ' + (err && err.message ? err.message : 'error'), 'er');
98 });
99 }
100 
101 function renderInstalled() {
102 var el = $('games-installed-list');
103 if (!el) return;
104 el.innerHTML = '';
105 
106 if (!_installed.length) {
107 el.innerHTML = '<div class="games-empty">No installed apps found</div>';
108 return;
109 }
110 
111 for (var i = 0; i < _installed.length; i++) {
112 var g = _installed[i] || {};
113 var id = g.id || '';
114 var name = g.name || id || 'Unknown';
115 var path = g.path || '';
116 var icon = Z.api.gameInstalledIconUrl(id, path) + '&_t=' + Date.now();
117 
118 var row = D.createElement('article');
119 row.className = 'games-card';
120 row.innerHTML =
121 '<img class="games-card-cover" src="' + esc(icon) + '" alt="' + esc(name) + '">' +
122 '<div class="games-card-body">' +
123 '<div class="games-card-title" title="' + esc(name) + '">' + esc(name) + '</div>' +
124 '<div class="games-card-meta">' + esc(id) + '</div>' +
125 '<div class="games-card-actions">' +
126 '<button class="btn games-btn-launch">Launch</button>' +
127 '<button class="btn games-btn-repair">Repair</button>' +
128 '<button class="btn games-btn-danger">Uninstall</button>' +
129 '</div>' +
130 '</div>' +
131 '';
132 
133 (function (titleId, titleName, launchBtn, repairBtn, uninstallBtn) {
134 if (launchBtn) {
135 launchBtn.onclick = function () {
136 Z.api.gameLaunch(titleId).then(function (r) {
137 var ok = !!(r && (r.ok === true || r.status === 'ok'));
138 Z.toast((r && r.message) || ('Launch sent: ' + titleId), ok ? 'ok' : 'wn');
139 if (!ok && shouldRepairVisibility(r)) {
140 repairTitleVisibility(titleId, true);
141 }
142 if (!ok && Z.notify) Z.notify('Launch not executed', titleId, 'wn');
143 }).catch(function () {
144 Z.toast('Launch failed: ' + titleId, 'er');
145 });
146 };
147 }
148 
149 if (repairBtn) {
150 repairBtn.onclick = function () {
151 repairTitleVisibility(titleId, false);
152 };
153 }
154 
155 if (uninstallBtn) {
156 uninstallBtn.onclick = function () {
157 if (!Z.modal || !Z.modal.confirm) {
158 Z.toast('Modal unavailable', 'er');
159 return;
160 }
161 Z.modal.confirm('Uninstall game', 'Remove ' + titleName + ' (' + titleId + ')?').then(function (yes) {
162 if (!yes) return;
163 Z.api.gameUninstall(titleId).then(function (r) {
164 Z.toast((r && r.message) || ('Uninstalled ' + titleId), 'ok');
165 loadInstalled();
166 }).catch(function (e) {
167 Z.toast('Uninstall failed: ' + (e && e.message ? e.message : titleId), 'er');
168 });
169 });
170 };
171 }
172 })(
173 id,
174 name,
175 row.querySelector('.games-btn-launch'),
176 row.querySelector('.games-btn-repair'),
177 row.querySelector('.games-btn-danger')
178 );
179 
180 el.appendChild(row);
181 }
182 }
183 
184 function scanImages() {
185 var el = $('games-images-list');
186 if (el) {
187 el.innerHTML = '';
188 el.innerHTML = '<div class="games-empty">Scanning PKG/exFAT images…</div>';
189 }
190 
191 _images = [];
192 var seen = {};
193 var pending = 0;
194 
195 function scanDir(dirPath, depth) {
196 if (depth > 2) return;
197 pending++;
198 Z.api.list(dirPath).then(function (d) {
199 var entries = (d && Array.isArray(d.entries)) ? d.entries : [];
200 for (var i = 0; i < entries.length; i++) {
201 var e = entries[i];
202 var fullPath = Z.join(dirPath, e.name);
203 
204 if (e.type === 'directory' && depth < 2) {
205 scanDir(fullPath, depth + 1);
206 continue;
207 }
208 
209 if (e.type !== 'file') continue;
210 if (e.name.indexOf('._') === 0) continue;
211 
212 var ext = Z.extname(e.name);
213 if (!_gameExts[ext]) continue;
214 if (seen[fullPath]) continue;
215 seen[fullPath] = 1;
216 
217 _images.push({ path: fullPath, name: e.name, size: e.size || 0, meta: null });
218 }
219 }).catch(function () {
220 /* ignore per-root errors */
221 }).then(function () {
222 pending--;
223 if (pending === 0) {
224 enrichAndRenderImages();
225 }
226 });
227 }
228 
229 for (var r = 0; r < _scanRoots.length; r++) {
230 scanDir(_scanRoots[r], 0);
231 }
232 }
233 
234 function enrichAndRenderImages() {
235 if (!_images.length) {
236 renderImages();
237 return;
238 }
239 
240 var tasks = [];
241 for (var i = 0; i < _images.length; i++) {
242 (function (img) {
243 var t = Z.api.gameMeta(img.path).then(function (m) {
244 img.meta = m || null;
245 }).catch(function () {});
246 tasks.push(t);
247 })(_images[i]);
248 }
249 
250 Promise.all(tasks).then(renderImages).catch(renderImages);
251 }
252 
253 function renderImages() {
254 var el = $('games-images-list');
255 if (!el) return;
256 el.innerHTML = '';
257 
258 if (!_images.length) {
259 el.innerHTML = '<div class="games-empty">No PKG/exFAT images found</div>';
260 return;
261 }
262 
263 _images.sort(function (a, b) {
264 return (a.name || '').localeCompare(b.name || '');
265 });
266 
267 for (var i = 0; i < _images.length; i++) {
268 var g = _images[i];
269 var title = (g.meta && g.meta.title_name) ? g.meta.title_name : g.name;
270 var tid = (g.meta && g.meta.title_id) ? g.meta.title_id : '';
271 var path = g.path || '';
272 var canInstall = /\.(pkg|fpkg|ffpkg)$/i.test(path);
273 
274 var cover = (g.meta && g.meta.icon_base64)
275 ? ('data:image/png;base64,' + g.meta.icon_base64)
276 : '/assets/zftpd-logo.png';
277 
278 var row = D.createElement('article');
279 row.className = 'games-card';
280 row.innerHTML =
281 '<img class="games-card-cover" src="' + esc(cover) + '" alt="' + esc(title) + '">' +
282 '<div class="games-card-body">' +
283 '<div class="games-card-title" title="' + esc(title) + '">' + esc(title) + '</div>' +
284 '<div class="games-card-meta">' + (tid ? esc(tid) + ' • ' : '') + esc(Z.bytes(g.size || 0)) + '</div>' +
285 '<div class="games-card-actions">' +
286 '<button class="btn games-btn-launch">Launch</button>' +
287 '<button class="btn" ' + (canInstall ? '' : 'disabled') + '>Install</button>' +
288 '<button class="btn" ' + (canInstall ? '' : 'disabled') + '>Reinstall</button>' +
289 '</div>' +
290 '</div>';
291 
292 (function (game, launchBtn, installBtn, reinstallBtn) {
293 if (launchBtn) {
294 launchBtn.onclick = function () {
295 Z.api.gameLaunch(game.meta && game.meta.title_id, game.path).then(function (r) {
296 var ok = !!(r && (r.ok === true || r.status === 'ok'));
297 Z.toast((r && r.message) || 'Launch signal sent', ok ? 'ok' : 'wn');
298 }).catch(function () {
299 Z.toast('Launch failed', 'er');
300 });
301 };
302 }
303 
304 if (installBtn) {
305 installBtn.onclick = function () { doInstall(game.path, false); };
306 }
307 
308 if (reinstallBtn) {
309 reinstallBtn.onclick = function () { doInstall(game.path, true); };
310 }
311 })(g, row.querySelector('.games-btn-launch'), row.querySelectorAll('.btn')[1], row.querySelectorAll('.btn')[2]);
312 
313 el.appendChild(row);
314 }
315 }
316 
317 function doInstall(path, reinstall) {
318 if (!path) {
319 Z.toast('Missing path', 'er');
320 return;
321 }
322 
323 var fn = reinstall ? Z.api.gameReinstall : Z.api.gameInstall;
324 fn(path).then(function (r) {
325 var ok = !!(r && r.ok !== false);
326 Z.toast((r && r.message) || (reinstall ? 'Reinstall started' : 'Install started'), ok ? 'ok' : 'wn');
327 if (ok && Z.notify) {
328 Z.notify(reinstall ? 'Reinstall started' : 'Install started', path, 'ok');
329 }
330 pollInstallStatus();
331 loadInstalled();
332 }).catch(function (e) {
333 Z.toast((reinstall ? 'Reinstall failed: ' : 'Install failed: ') + (e && e.message ? e.message : 'error'), 'er');
334 });
335 }
336 
337 function shouldRepairVisibility(resp) {
338 var msg = (resp && resp.message) ? String(resp.message) : '';
339 var code = (resp && typeof resp.code === 'number') ? resp.code : 0;
340 if (code === -30) return true;
341 if (/0x80940005/i.test(msg)) return true;
342 if (/title not installed/i.test(msg)) return true;
343 return false;
344 }
345 
346 function repairTitleVisibility(titleId, silent) {
347 if (!titleId) return;
348 Z.api.gamesRepairVisibility(titleId).then(function (r) {
349 var rows = (r && r.sqlite_repair && typeof r.sqlite_repair.rows === 'number')
350 ? r.sqlite_repair.rows
351 : 0;
352 if (!silent) {
353 Z.toast('Repair ' + titleId + ' done • SQL rows ' + rows, 'ok');
354 } else if (rows > 0) {
355 Z.toast('Visibility repaired for ' + titleId, 'ok');
356 }
357 loadInstalled();
358 }).catch(function (e) {
359 if (!silent) {
360 Z.toast('Repair failed: ' + (e && e.message ? e.message : titleId), 'er');
361 }
362 });
363 }
364 
365 function startStatusPolling() {
366 if (_statusTimer) return;
367 _statusTimer = setInterval(function () {
368 if (!Z.state || Z.state.view !== 'games') return;
369 pollInstallStatus();
370 }, 2000);
371 }
372 
373 function pollInstallStatus() {
374 if (!Z.api || !Z.api.gameInstallStatus) return;
375 Z.api.gameInstallStatus().then(function (s) {
376 renderInstallStatus(s || {});
377 }).catch(function () {
378 renderInstallStatus({ ok: false, error: -1, active: false, message: 'status unavailable' });
379 });
380 }
381 
382 function renderInstallStatus(s) {
383 var el = $('games-install-status');
384 if (!el) return;
385 
386 el.classList.remove('ok');
387 el.classList.remove('er');
388 
389 var active = !!s.active;
390 var progress = (typeof s.progress === 'number') ? s.progress : 0;
391 var taskId = (typeof s.task_id === 'number') ? s.task_id : -1;
392 var titleId = s.title_id || '';
393 var err = (typeof s.error === 'number') ? s.error : 0;
394 
395 if (taskId !== _lastTaskId) {
396 _lastTaskId = taskId;
397 _lastMilestone = -1;
398 _lastErrorCode = 0;
399 }
400 
401 if (active) {
402 el.classList.add('ok');
403 el.textContent = 'BGFT task #' + taskId + ' • ' + progress + '% • ' + (titleId || 'unknown title');
404 
405 var milestone = -1;
406 if (progress >= 100) milestone = 100;
407 else if (progress >= 75) milestone = 75;
408 else if (progress >= 50) milestone = 50;
409 else if (progress >= 25) milestone = 25;
410 
411 if (milestone > _lastMilestone) {
412 _lastMilestone = milestone;
413 if (Z.notify) {
414 Z.notify('Install progress', (titleId || 'task #' + taskId) + ' • ' + milestone + '%', 'ok');
415 }
416 }
417 return;
418 }
419 
420 if (err && err !== 0) {
421 el.classList.add('er');
422 el.textContent = 'Last BGFT status error: ' + err;
423 if (_lastErrorCode !== err && Z.notify) {
424 _lastErrorCode = err;
425 Z.notify('Install error', (titleId || 'task #' + taskId) + ' • code ' + err, 'er');
426 }
427 return;
428 }
429 
430 if (taskId >= 0 && progress >= 100) {
431 el.classList.add('ok');
432 el.textContent = 'Install task completed • ' + (titleId || 'done');
433 if (_lastMilestone < 100 && Z.notify) {
434 _lastMilestone = 100;
435 Z.notify('Install completed', titleId || ('task #' + taskId), 'ok');
436 }
437 loadInstalled();
438 return;
439 }
440 
441 el.textContent = 'No active install task';
442 }
443 
444 function esc(s) {
445 s = (s === undefined || s === null) ? '' : String(s);
446 return s
447 .replace(/&/g, '&amp;')
448 .replace(/</g, '&lt;')
449 .replace(/>/g, '&gt;')
450 .replace(/"/g, '&quot;')
451 .replace(/'/g, '&#39;');
452 }
453 
454 Z.gamesView = games;
455 
456})(ZFTPD);
457