Zero-copy FTP/HTTP Daemon compatible with all POSIX systems
| 1 | /* ══ GAMES VIEW — XMB STYLE ═══════════════════════════════════════════════ |
| 2 | * PS4-like horizontal shelves with icons and game actions. |
| 3 | * ES5 compatible for PS5 browser. |
| 4 | * ═════════════════════════════════════════════════════════════════════════ */ |
| 5 | |
| 6 | var 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, '&') |
| 448 | .replace(/</g, '<') |
| 449 | .replace(/>/g, '>') |
| 450 | .replace(/"/g, '"') |
| 451 | .replace(/'/g, '''); |
| 452 | } |
| 453 | |
| 454 | Z.gamesView = games; |
| 455 | |
| 456 | })(ZFTPD); |
| 457 |