Spaces:
Running
Running
Publish MASSFRONT web v1.33.60 (part 2)
Browse files- assets/data/manifest.json +3 -2
- assets/data/runtime-compatibility.json +41 -36
- boot.js +263 -11
- index.html +150 -48
- src/assetpack.js +910 -166
- src/authportal.js +149 -28
assets/data/manifest.json
CHANGED
|
@@ -91,8 +91,9 @@
|
|
| 91 |
"src/daily.js",
|
| 92 |
"src/account.js",
|
| 93 |
"src/economy-net.js",
|
| 94 |
-
"src/updater.js",
|
| 95 |
-
"src/intro.js",
|
|
|
|
| 96 |
"src/session.js",
|
| 97 |
"src/faction-id.js",
|
| 98 |
"src/glrecover.js",
|
|
|
|
| 91 |
"src/daily.js",
|
| 92 |
"src/account.js",
|
| 93 |
"src/economy-net.js",
|
| 94 |
+
"src/updater.js",
|
| 95 |
+
"src/intro.js",
|
| 96 |
+
"src/launcher.js",
|
| 97 |
"src/session.js",
|
| 98 |
"src/faction-id.js",
|
| 99 |
"src/glrecover.js",
|
assets/data/runtime-compatibility.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
| 1 |
{
|
| 2 |
"schema": "massfront.runtime-compatibility",
|
| 3 |
"schemaVersion": 1,
|
| 4 |
-
"buildVersion": "1.33.
|
| 5 |
"channel": "packaged-www",
|
| 6 |
-
"manifestHash": "
|
| 7 |
-
"balanceHash": "
|
| 8 |
"algorithm": {
|
| 9 |
"leaf": "sha256(raw-artifact-bytes)",
|
| 10 |
"root": "sha256(domain\\0 + utf8ByteLength(path):path\\0decimalSize\\0leaf\\n for each ordered entry)",
|
|
@@ -16,23 +16,23 @@
|
|
| 16 |
"manifestEntries": [
|
| 17 |
{
|
| 18 |
"path": "index.html",
|
| 19 |
-
"size":
|
| 20 |
-
"sha256": "
|
| 21 |
},
|
| 22 |
{
|
| 23 |
"path": "boot.js",
|
| 24 |
-
"size":
|
| 25 |
-
"sha256": "
|
| 26 |
},
|
| 27 |
{
|
| 28 |
"path": "sw.js",
|
| 29 |
"size": 6419,
|
| 30 |
-
"sha256": "
|
| 31 |
},
|
| 32 |
{
|
| 33 |
"path": "src/styles/ui.css",
|
| 34 |
-
"size":
|
| 35 |
-
"sha256": "
|
| 36 |
},
|
| 37 |
{
|
| 38 |
"path": "assets/ui.css",
|
|
@@ -121,8 +121,8 @@
|
|
| 121 |
},
|
| 122 |
{
|
| 123 |
"path": "src/engine/perf.js",
|
| 124 |
-
"size":
|
| 125 |
-
"sha256": "
|
| 126 |
},
|
| 127 |
{
|
| 128 |
"path": "src/engine/noisegen.js",
|
|
@@ -371,8 +371,8 @@
|
|
| 371 |
},
|
| 372 |
{
|
| 373 |
"path": "src/game/meta.js",
|
| 374 |
-
"size":
|
| 375 |
-
"sha256": "
|
| 376 |
},
|
| 377 |
{
|
| 378 |
"path": "src/game/ai.js",
|
|
@@ -391,8 +391,8 @@
|
|
| 391 |
},
|
| 392 |
{
|
| 393 |
"path": "src/ui/hud.js",
|
| 394 |
-
"size":
|
| 395 |
-
"sha256": "
|
| 396 |
},
|
| 397 |
{
|
| 398 |
"path": "src/ui/render3d.js",
|
|
@@ -441,8 +441,8 @@
|
|
| 441 |
},
|
| 442 |
{
|
| 443 |
"path": "src/assetpack.js",
|
| 444 |
-
"size":
|
| 445 |
-
"sha256": "
|
| 446 |
},
|
| 447 |
{
|
| 448 |
"path": "src/hazards.js",
|
|
@@ -451,13 +451,13 @@
|
|
| 451 |
},
|
| 452 |
{
|
| 453 |
"path": "src/game/statehash.js",
|
| 454 |
-
"size":
|
| 455 |
-
"sha256": "
|
| 456 |
},
|
| 457 |
{
|
| 458 |
"path": "src/authportal.js",
|
| 459 |
-
"size":
|
| 460 |
-
"sha256": "
|
| 461 |
},
|
| 462 |
{
|
| 463 |
"path": "src/tutorial.js",
|
|
@@ -501,8 +501,8 @@
|
|
| 501 |
},
|
| 502 |
{
|
| 503 |
"path": "src/socialui.js",
|
| 504 |
-
"size":
|
| 505 |
-
"sha256": "
|
| 506 |
},
|
| 507 |
{
|
| 508 |
"path": "src/game/matchconsumer.js",
|
|
@@ -526,13 +526,18 @@
|
|
| 526 |
},
|
| 527 |
{
|
| 528 |
"path": "src/updater.js",
|
| 529 |
-
"size":
|
| 530 |
-
"sha256": "
|
| 531 |
},
|
| 532 |
{
|
| 533 |
"path": "src/intro.js",
|
| 534 |
-
"size":
|
| 535 |
-
"sha256": "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 536 |
},
|
| 537 |
{
|
| 538 |
"path": "src/session.js",
|
|
@@ -551,8 +556,8 @@
|
|
| 551 |
},
|
| 552 |
{
|
| 553 |
"path": "src/main.js",
|
| 554 |
-
"size":
|
| 555 |
-
"sha256": "
|
| 556 |
},
|
| 557 |
{
|
| 558 |
"path": "src/intel.js",
|
|
@@ -586,8 +591,8 @@
|
|
| 586 |
},
|
| 587 |
{
|
| 588 |
"path": "src/ui/hudflow.js",
|
| 589 |
-
"size":
|
| 590 |
-
"sha256": "
|
| 591 |
},
|
| 592 |
{
|
| 593 |
"path": "src/ui/hotslots.js",
|
|
@@ -698,8 +703,8 @@
|
|
| 698 |
},
|
| 699 |
{
|
| 700 |
"path": "src/game/meta.js",
|
| 701 |
-
"size":
|
| 702 |
-
"sha256": "
|
| 703 |
},
|
| 704 |
{
|
| 705 |
"path": "src/game/ai.js",
|
|
@@ -763,8 +768,8 @@
|
|
| 763 |
},
|
| 764 |
{
|
| 765 |
"path": "src/main.js",
|
| 766 |
-
"size":
|
| 767 |
-
"sha256": "
|
| 768 |
},
|
| 769 |
{
|
| 770 |
"path": "src/intel.js",
|
|
|
|
| 1 |
{
|
| 2 |
"schema": "massfront.runtime-compatibility",
|
| 3 |
"schemaVersion": 1,
|
| 4 |
+
"buildVersion": "1.33.60",
|
| 5 |
"channel": "packaged-www",
|
| 6 |
+
"manifestHash": "addd4716b37b8595da29a6becfc8add0a0fba3e2150dd86281e50dfdff8b1168",
|
| 7 |
+
"balanceHash": "4154ba8ba6f0fea81cc28e3af073d1949680d4a67a9cb696a9e5861c5fb129fe",
|
| 8 |
"algorithm": {
|
| 9 |
"leaf": "sha256(raw-artifact-bytes)",
|
| 10 |
"root": "sha256(domain\\0 + utf8ByteLength(path):path\\0decimalSize\\0leaf\\n for each ordered entry)",
|
|
|
|
| 16 |
"manifestEntries": [
|
| 17 |
{
|
| 18 |
"path": "index.html",
|
| 19 |
+
"size": 435619,
|
| 20 |
+
"sha256": "bf991b42fc3b9b472c85b215b8c862d43a73e07eeb5b9719bd7ea3cefc4dd032"
|
| 21 |
},
|
| 22 |
{
|
| 23 |
"path": "boot.js",
|
| 24 |
+
"size": 46254,
|
| 25 |
+
"sha256": "14ec9d1c4d84304a9a768823e4958c80abf29383c070eff90925154d74bca573"
|
| 26 |
},
|
| 27 |
{
|
| 28 |
"path": "sw.js",
|
| 29 |
"size": 6419,
|
| 30 |
+
"sha256": "604a58aa0f607a303c37e68a4e93c52d333ca78fe403243a2c9603bdf385b14b"
|
| 31 |
},
|
| 32 |
{
|
| 33 |
"path": "src/styles/ui.css",
|
| 34 |
+
"size": 372493,
|
| 35 |
+
"sha256": "39c2a056e6299458f38b7448a6a56cdc240e6c534ed52636758ec64e5c713d1b"
|
| 36 |
},
|
| 37 |
{
|
| 38 |
"path": "assets/ui.css",
|
|
|
|
| 121 |
},
|
| 122 |
{
|
| 123 |
"path": "src/engine/perf.js",
|
| 124 |
+
"size": 25202,
|
| 125 |
+
"sha256": "e74249688bef2ede6a885f62e0f51300fe5d42aa4a1929924249194e3e713b2a"
|
| 126 |
},
|
| 127 |
{
|
| 128 |
"path": "src/engine/noisegen.js",
|
|
|
|
| 371 |
},
|
| 372 |
{
|
| 373 |
"path": "src/game/meta.js",
|
| 374 |
+
"size": 127622,
|
| 375 |
+
"sha256": "5180c016ba355f55dc9230c9501e68b80c8b93b430a8fe6fede012752d77c332"
|
| 376 |
},
|
| 377 |
{
|
| 378 |
"path": "src/game/ai.js",
|
|
|
|
| 391 |
},
|
| 392 |
{
|
| 393 |
"path": "src/ui/hud.js",
|
| 394 |
+
"size": 183281,
|
| 395 |
+
"sha256": "9014df151281a0ba20b85d67b962e394e7902d6cda1e34105fe9373735dc0b87"
|
| 396 |
},
|
| 397 |
{
|
| 398 |
"path": "src/ui/render3d.js",
|
|
|
|
| 441 |
},
|
| 442 |
{
|
| 443 |
"path": "src/assetpack.js",
|
| 444 |
+
"size": 55805,
|
| 445 |
+
"sha256": "4d3f45ce562e56ee5137516be79bc6faa4fc3db75c483ef1c7ce0300d47363b6"
|
| 446 |
},
|
| 447 |
{
|
| 448 |
"path": "src/hazards.js",
|
|
|
|
| 451 |
},
|
| 452 |
{
|
| 453 |
"path": "src/game/statehash.js",
|
| 454 |
+
"size": 12260,
|
| 455 |
+
"sha256": "f2c22000002e6b125f8876deedac28a932448890663257614e1c873129a901d2"
|
| 456 |
},
|
| 457 |
{
|
| 458 |
"path": "src/authportal.js",
|
| 459 |
+
"size": 109260,
|
| 460 |
+
"sha256": "893c17c4c6ae384b875660284bab99be28b796f24b34af4647d3931eda6b13c7"
|
| 461 |
},
|
| 462 |
{
|
| 463 |
"path": "src/tutorial.js",
|
|
|
|
| 501 |
},
|
| 502 |
{
|
| 503 |
"path": "src/socialui.js",
|
| 504 |
+
"size": 94822,
|
| 505 |
+
"sha256": "906face8283ca5cf2ba416c79e11e22ce61afcedf6d39eba75907e5de169bd55"
|
| 506 |
},
|
| 507 |
{
|
| 508 |
"path": "src/game/matchconsumer.js",
|
|
|
|
| 526 |
},
|
| 527 |
{
|
| 528 |
"path": "src/updater.js",
|
| 529 |
+
"size": 146225,
|
| 530 |
+
"sha256": "fc1024feb7d7bff0239e0c8792219d28c1b488ce1e1c263b75e8f7ae9d0cbcd8"
|
| 531 |
},
|
| 532 |
{
|
| 533 |
"path": "src/intro.js",
|
| 534 |
+
"size": 207551,
|
| 535 |
+
"sha256": "d4ecb5df0a0353870f66697b9221872b2fbe3e6982f3f061275eb46d2caa5387"
|
| 536 |
+
},
|
| 537 |
+
{
|
| 538 |
+
"path": "src/launcher.js",
|
| 539 |
+
"size": 28822,
|
| 540 |
+
"sha256": "d8bd5b2ab2722aacf3576b886ea2da5305ebef9af9f15e8baa40cfe8e8b3850f"
|
| 541 |
},
|
| 542 |
{
|
| 543 |
"path": "src/session.js",
|
|
|
|
| 556 |
},
|
| 557 |
{
|
| 558 |
"path": "src/main.js",
|
| 559 |
+
"size": 184506,
|
| 560 |
+
"sha256": "18036eba44152f565ba9e37d32f0e87031f1d767451d1d9a70b0336edd7e1456"
|
| 561 |
},
|
| 562 |
{
|
| 563 |
"path": "src/intel.js",
|
|
|
|
| 591 |
},
|
| 592 |
{
|
| 593 |
"path": "src/ui/hudflow.js",
|
| 594 |
+
"size": 42564,
|
| 595 |
+
"sha256": "9496dd95562674ba8357536e68b2f76b398a959abcfc9e415749c9c569633f6a"
|
| 596 |
},
|
| 597 |
{
|
| 598 |
"path": "src/ui/hotslots.js",
|
|
|
|
| 703 |
},
|
| 704 |
{
|
| 705 |
"path": "src/game/meta.js",
|
| 706 |
+
"size": 127622,
|
| 707 |
+
"sha256": "5180c016ba355f55dc9230c9501e68b80c8b93b430a8fe6fede012752d77c332"
|
| 708 |
},
|
| 709 |
{
|
| 710 |
"path": "src/game/ai.js",
|
|
|
|
| 768 |
},
|
| 769 |
{
|
| 770 |
"path": "src/main.js",
|
| 771 |
+
"size": 184506,
|
| 772 |
+
"sha256": "18036eba44152f565ba9e37d32f0e87031f1d767451d1d9a70b0336edd7e1456"
|
| 773 |
},
|
| 774 |
{
|
| 775 |
"path": "src/intel.js",
|
boot.js
CHANGED
|
@@ -24,21 +24,26 @@
|
|
| 24 |
'./assets/data/theatreprofiles-stage10.js',
|
| 25 |
'./assets/data/interiortopology-stage10.js','./assets/data/orbitaltopology-stage10.js',
|
| 26 |
'./src/game/determinism.js',
|
| 27 |
-
'./assets/data/unitrows.js','./assets/data/unitsheet.js','./assets/data/itemart.js','./assets/data/planetart.js','./src/engine/gl.js','./src/engine/planetpreview.js','./src/engine/perf.js','./src/engine/noisegen.js','./src/engine/factionenergy.js','./src/engine/ordnancetrails.js','./src/engine/terragen.js','./src/terralab.js','./src/engine/mesh.js','./src/engine/billboard.js','./src/engine/macrofx.js','./src/engine/shieldfx.js','./src/engine/tacticons.js','./src/engine/gpufx.js','./src/engine/volfx.js','./src/engine/shockwave.js','./src/engine/vfxlayers.js','./src/engine/organicfx.js','./assets/basis/basis_transcoder.js','./src/engine/materials.js','./src/engine/materials-v2.js','./src/engine/terrain.js','./src/engine/models-world-data.js','./src/engine/models-world-loader.js','./src/engine/models.js','./assets/data/worldkit.js','./assets/data/locationgrammar.js','./assets/data/sitetemplates.js','./assets/data/sitetemplates-stage9.js','./assets/data/locationplans.js','./assets/data/battlefieldtopology-stage10.js','./src/engine/worldsites.js','./assets/data/meshes.js','./src/engine/models-legion.js','./src/engine/models-machine.js','./src/engine/models-infestation.js','./src/engine/models-civic.js','./src/engine/models-skyline.js','./src/engine/materials-world-v2.js','./src/engine/models-units-nova.js','./src/engine/models-units-legion.js','./src/engine/models-units-syndicate.js','./src/engine/models-units-brood.js','./src/engine/modkit.js','./src/engine/physics.js','./src/engine/cloudfx.js','./src/engine/cloudpostfx.js','./src/game/utilityjobs.js','./src/game/sim.js','./src/game/airwarfare.js','./src/game/economy.js','./src/game/commander.js','./src/game/meta.js','./src/game/ai.js','./src/ui/input.js','./src/ui/facticons.js','./src/ui/hud.js','./src/ui/render3d.js','./src/ui/orderfx.js','./src/airlift.js','./src/airlift-factions.js','./src/rumble.js','./src/factions.js','./src/factext.js','./src/offline.js','./src/audio.js','./src/assetpack.js','./src/hazards.js','./src/game/statehash.js','./src/authportal.js','./src/tutorial.js','./src/adboards.js','./src/storeui.js','./src/restree3d.js','./src/develop.js','./src/factiondoctrine.js','./src/endgame.js','./src/story.js','./src/socialui.js','./src/game/matchconsumer.js','./src/daily.js','./src/account.js','./src/economy-net.js','./src/updater.js','./src/intro.js','./src/session.js','./src/faction-id.js','./src/glrecover.js','./src/main.js','./src/intel.js','./src/repairbay.js','./src/galaxyui.js','./src/departure.js','./src/warprimer.js','./src/uistack.js','./src/ui/hudflow.js','./src/ui/hotslots.js','./src/galactic-operations.js','./src/onboarding.js','./src/career-faction-gate.js'
|
| 28 |
];
|
| 29 |
/* Packaged scripts need a release key. WebViews and development browsers can
|
| 30 |
otherwise reuse a stale source even after the installer or local preview
|
| 31 |
has changed, which made new settings appear to be missing until cache was
|
| 32 |
cleared manually. Patch bundles keep their content-addressed Blob URLs. */
|
| 33 |
-
var PACKAGED_REV='1.33.
|
| 34 |
/* Source-cache revision is deliberately independent from the user-facing
|
| 35 |
release number. Local/hotfix rebuilds of the same release must not reuse
|
| 36 |
an older gl.js merely because its ?v= version string is unchanged. */
|
| 37 |
/* shell2 changes recovery delivery only. Keep the already-verified runtime
|
| 38 |
source revision so a service-worker repair does not cold-fetch all 108
|
| 39 |
scripts from the host at once. */
|
| 40 |
-
var PACKAGED_SRC_REV=PACKAGED_REV+'-
|
| 41 |
var DB='massfront-updates', STORE='bundles';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
var bootShield=null, bootShieldTimer=0, bootShieldWatchdog=0;
|
| 43 |
var bootShieldEvents=['pointerdown','pointerup','touchstart','touchend','click'];
|
| 44 |
/* Absolute ceiling on how long the shield/guard may live. The guard is only
|
|
@@ -107,12 +112,35 @@
|
|
| 107 |
function bundleMeta(rec){
|
| 108 |
if(!rec) return null;
|
| 109 |
return {version:rec.version,channel:rec.channel||'stable',at:rec.at,
|
|
|
|
| 110 |
notes:rec.notes||'',severity:rec.severity||'recommended',
|
| 111 |
-
kind:rec.kind||'full',patchedFrom:rec.patchedFrom||''
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
}
|
| 113 |
function sameBundle(a,b){
|
| 114 |
if(!a||!b||String(a.version)!==String(b.version)||
|
| 115 |
-
patchChannel(a)!==patchChannel(b)) return false;
|
| 116 |
if(a.at!=null||b.at!=null) return String(a.at||'')===String(b.at||'');
|
| 117 |
return true;
|
| 118 |
}
|
|
@@ -145,19 +173,21 @@
|
|
| 145 |
}
|
| 146 |
function probationMatchesBundle(prob,bundle){
|
| 147 |
if(!prob||!bundle||String(prob.version)!==String(bundle.version)||
|
| 148 |
-
patchChannel(prob)!==patchChannel(bundle)) return false;
|
| 149 |
return prob.pendingAt==null||String(prob.pendingAt)===String(bundle.at||'');
|
| 150 |
}
|
| 151 |
function probationOwnsMetaExact(prob,meta){
|
| 152 |
return !!(prob&&meta&&prob.pendingAt!=null&&meta.at!=null&&
|
| 153 |
String(prob.version)===String(meta.version)&&
|
| 154 |
patchChannel(prob)===patchChannel(meta)&&
|
|
|
|
| 155 |
String(prob.pendingAt)===String(meta.at));
|
| 156 |
}
|
| 157 |
function pendingMatchesFailure(pending,prob,bundle,version){
|
| 158 |
if(!pending||String(pending.version)!==String(version)) return false;
|
| 159 |
var owner=prob||bundle;
|
| 160 |
if(owner&&patchChannel(pending)!==patchChannel(owner)) return false;
|
|
|
|
| 161 |
var token=prob&&prob.pendingAt!=null?prob.pendingAt:
|
| 162 |
(bundle&&bundle.at!=null?bundle.at:null);
|
| 163 |
/* Old records without an attempt token cannot be distinguished from a
|
|
@@ -215,7 +245,58 @@
|
|
| 215 |
function validBundle(b){
|
| 216 |
if(!b||!b.files||!verNewer(b.version,PACKAGED_REV)) return false;
|
| 217 |
var order=b.order&&b.order.length?b.order:MANIFEST;
|
| 218 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
return true;
|
| 220 |
}
|
| 221 |
function evictSuperseded(db){
|
|
@@ -353,6 +434,15 @@
|
|
| 353 |
:(!meta||sameBundle(meta,value)));
|
| 354 |
if(good){
|
| 355 |
store.put(value,'active'); store.put(bundleMeta(value),'activeMeta');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
clearPreviousRecords(store);
|
| 357 |
clearFinishedRollback(value);
|
| 358 |
decision={kind:'recovered',bundle:value,failedVersion:version,
|
|
@@ -404,6 +494,8 @@
|
|
| 404 |
return;
|
| 405 |
}
|
| 406 |
var claimed={version:bundle.version,channel:bundle.channel||prob.channel,
|
|
|
|
|
|
|
| 407 |
pendingAt:bundle.at!=null?bundle.at:prob.pendingAt,
|
| 408 |
at:prob.at||Date.now(),tries:(prob.tries|0)+1};
|
| 409 |
store.put(claimed,'probation');
|
|
@@ -415,6 +507,8 @@
|
|
| 415 |
/* A mismatched legacy/torn guard must never be used to validate a
|
| 416 |
different active record. Replace it with an exact first claim. */
|
| 417 |
store.put({version:bundle.version,channel:bundle.channel||'stable',
|
|
|
|
|
|
|
| 418 |
pendingAt:bundle.at,at:Date.now(),tries:1},'probation');
|
| 419 |
clearFinishedRollback(bundle);
|
| 420 |
decision={kind:'run',bundle:bundle,claimed:true,repaired:true};
|
|
@@ -509,10 +603,158 @@
|
|
| 509 |
injectScripts(function(s,i){ s.src=MANIFEST[i]+'?v='+PACKAGED_SRC_REV; },
|
| 510 |
MANIFEST.length,rendererGateIndex(MANIFEST));
|
| 511 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 512 |
function runBundle(b){
|
| 513 |
/* Blob URLs rather than inline text: the browser keeps a real filename for
|
| 514 |
each source, so a stack trace from a patched build is still readable. */
|
| 515 |
var order=b.order&&b.order.length? b.order : MANIFEST;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 516 |
injectScripts(function(s,i){
|
| 517 |
var path=order[i], src=b.files[path];
|
| 518 |
if(src==null) s.src=path;
|
|
@@ -533,6 +775,9 @@
|
|
| 533 |
window.__MASSFRONT_PATCHED=decision.bundle.version||'?';
|
| 534 |
window.__MASSFRONT_PATCH_AT=decision.bundle.at;
|
| 535 |
window.__MASSFRONT_PATCH_CHANNEL=decision.bundle.channel||'stable';
|
|
|
|
|
|
|
|
|
|
| 536 |
window.__MASSFRONT_RECOVERED_PATCH=decision.failedVersion||'?';
|
| 537 |
console.warn('boot: restored validated patch '+decision.bundle.version+
|
| 538 |
' after '+decision.failedVersion+' failed');
|
|
@@ -543,6 +788,9 @@
|
|
| 543 |
window.__MASSFRONT_PATCHED=decision.bundle.version||'?';
|
| 544 |
window.__MASSFRONT_PATCH_AT=decision.bundle.at;
|
| 545 |
window.__MASSFRONT_PATCH_CHANNEL=decision.bundle.channel||'stable';
|
|
|
|
|
|
|
|
|
|
| 546 |
runBundle(decision.bundle);
|
| 547 |
return;
|
| 548 |
}
|
|
@@ -560,12 +808,16 @@
|
|
| 560 |
failed before it selected `active`. It has NOT proved the stored patch
|
| 561 |
good, so it must not erase that patch's probation record. Leaving it in
|
| 562 |
place means the next healthy launch retries under the normal rollback
|
| 563 |
-
guard instead of running an unguarded payload.
|
| 564 |
-
|
| 565 |
-
|
|
|
|
| 566 |
idb().then(function(db){
|
| 567 |
return confirmPatch(db,{version:window.__MASSFRONT_PATCHED,
|
| 568 |
channel:window.__MASSFRONT_PATCH_CHANNEL||'stable',
|
|
|
|
|
|
|
|
|
|
| 569 |
at:window.__MASSFRONT_PATCH_AT});
|
| 570 |
}).catch(function(){});
|
| 571 |
};
|
|
@@ -583,7 +835,7 @@
|
|
| 583 |
if(!secure) return;
|
| 584 |
window.__mfPwaDiag={supported:true,registered:false,controlled:!!navigator.serviceWorker.controller,error:null};
|
| 585 |
window.addEventListener('load',function(){
|
| 586 |
-
navigator.serviceWorker.register('./sw.js?v=1.33.
|
| 587 |
.then(function(reg){
|
| 588 |
window.__mfPwaDiag.registered=true;
|
| 589 |
window.__mfPwaDiag.scope=reg.scope;
|
|
|
|
| 24 |
'./assets/data/theatreprofiles-stage10.js',
|
| 25 |
'./assets/data/interiortopology-stage10.js','./assets/data/orbitaltopology-stage10.js',
|
| 26 |
'./src/game/determinism.js',
|
| 27 |
+
'./assets/data/unitrows.js','./assets/data/unitsheet.js','./assets/data/itemart.js','./assets/data/planetart.js','./src/engine/gl.js','./src/engine/planetpreview.js','./src/engine/perf.js','./src/engine/noisegen.js','./src/engine/factionenergy.js','./src/engine/ordnancetrails.js','./src/engine/terragen.js','./src/terralab.js','./src/engine/mesh.js','./src/engine/billboard.js','./src/engine/macrofx.js','./src/engine/shieldfx.js','./src/engine/tacticons.js','./src/engine/gpufx.js','./src/engine/volfx.js','./src/engine/shockwave.js','./src/engine/vfxlayers.js','./src/engine/organicfx.js','./assets/basis/basis_transcoder.js','./src/engine/materials.js','./src/engine/materials-v2.js','./src/engine/terrain.js','./src/engine/models-world-data.js','./src/engine/models-world-loader.js','./src/engine/models.js','./assets/data/worldkit.js','./assets/data/locationgrammar.js','./assets/data/sitetemplates.js','./assets/data/sitetemplates-stage9.js','./assets/data/locationplans.js','./assets/data/battlefieldtopology-stage10.js','./src/engine/worldsites.js','./assets/data/meshes.js','./src/engine/models-legion.js','./src/engine/models-machine.js','./src/engine/models-infestation.js','./src/engine/models-civic.js','./src/engine/models-skyline.js','./src/engine/materials-world-v2.js','./src/engine/models-units-nova.js','./src/engine/models-units-legion.js','./src/engine/models-units-syndicate.js','./src/engine/models-units-brood.js','./src/engine/modkit.js','./src/engine/physics.js','./src/engine/cloudfx.js','./src/engine/cloudpostfx.js','./src/game/utilityjobs.js','./src/game/sim.js','./src/game/airwarfare.js','./src/game/economy.js','./src/game/commander.js','./src/game/meta.js','./src/game/ai.js','./src/ui/input.js','./src/ui/facticons.js','./src/ui/hud.js','./src/ui/render3d.js','./src/ui/orderfx.js','./src/airlift.js','./src/airlift-factions.js','./src/rumble.js','./src/factions.js','./src/factext.js','./src/offline.js','./src/audio.js','./src/assetpack.js','./src/hazards.js','./src/game/statehash.js','./src/authportal.js','./src/tutorial.js','./src/adboards.js','./src/storeui.js','./src/restree3d.js','./src/develop.js','./src/factiondoctrine.js','./src/endgame.js','./src/story.js','./src/socialui.js','./src/game/matchconsumer.js','./src/daily.js','./src/account.js','./src/economy-net.js','./src/updater.js','./src/intro.js','./src/launcher.js','./src/session.js','./src/faction-id.js','./src/glrecover.js','./src/main.js','./src/intel.js','./src/repairbay.js','./src/galaxyui.js','./src/departure.js','./src/warprimer.js','./src/uistack.js','./src/ui/hudflow.js','./src/ui/hotslots.js','./src/galactic-operations.js','./src/onboarding.js','./src/career-faction-gate.js'
|
| 28 |
];
|
| 29 |
/* Packaged scripts need a release key. WebViews and development browsers can
|
| 30 |
otherwise reuse a stale source even after the installer or local preview
|
| 31 |
has changed, which made new settings appear to be missing until cache was
|
| 32 |
cleared manually. Patch bundles keep their content-addressed Blob URLs. */
|
| 33 |
+
var PACKAGED_REV='1.33.60';
|
| 34 |
/* Source-cache revision is deliberately independent from the user-facing
|
| 35 |
release number. Local/hotfix rebuilds of the same release must not reuse
|
| 36 |
an older gl.js merely because its ?v= version string is unchanged. */
|
| 37 |
/* shell2 changes recovery delivery only. Keep the already-verified runtime
|
| 38 |
source revision so a service-worker repair does not cold-fetch all 108
|
| 39 |
scripts from the host at once. */
|
| 40 |
+
var PACKAGED_SRC_REV=PACKAGED_REV+'-boot8';
|
| 41 |
var DB='massfront-updates', STORE='bundles';
|
| 42 |
+
/* Packaged boot capability flag. OTA source checks this before staging a
|
| 43 |
+
descriptor that references per-artifact records. Old APK-bound boot
|
| 44 |
+
loaders never set it and therefore continue receiving the legacy complete
|
| 45 |
+
in-record bundle they understand. */
|
| 46 |
+
window.__MF_ARTIFACT_BOOT_V1=true;
|
| 47 |
var bootShield=null, bootShieldTimer=0, bootShieldWatchdog=0;
|
| 48 |
var bootShieldEvents=['pointerdown','pointerup','touchstart','touchend','click'];
|
| 49 |
/* Absolute ceiling on how long the shield/guard may live. The guard is only
|
|
|
|
| 112 |
function bundleMeta(rec){
|
| 113 |
if(!rec) return null;
|
| 114 |
return {version:rec.version,channel:rec.channel||'stable',at:rec.at,
|
| 115 |
+
schema:rec.schema||1,
|
| 116 |
notes:rec.notes||'',severity:rec.severity||'recommended',
|
| 117 |
+
kind:rec.kind||'full',patchedFrom:rec.patchedFrom||'',
|
| 118 |
+
manifestRoot:rec.manifestRoot||'',payloadRoot:rec.payloadRoot||'',
|
| 119 |
+
targetRoot:rec.targetRoot||'',sourcePayloadRoot:rec.sourcePayloadRoot||'',
|
| 120 |
+
fullRoot:rec.fullRoot||'',manifestKind:rec.manifestKind||'',
|
| 121 |
+
manifestCategory:rec.manifestCategory||'',
|
| 122 |
+
manifestPatchFrom:rec.manifestPatchFrom||'',
|
| 123 |
+
runtimeRoot:rec.runtimeRoot||'',
|
| 124 |
+
storage:rec.storage||''};
|
| 125 |
+
}
|
| 126 |
+
function sameRoots(a,b){
|
| 127 |
+
if(!a||!b) return false;
|
| 128 |
+
var ah=!!(a.manifestRoot||a.targetRoot||a.runtimeRoot),
|
| 129 |
+
bh=!!(b.manifestRoot||b.targetRoot||b.runtimeRoot);
|
| 130 |
+
if(!ah&&!bh) return true;
|
| 131 |
+
if(!(a.manifestRoot&&b.manifestRoot&&a.targetRoot&&b.targetRoot&&
|
| 132 |
+
String(a.manifestRoot).toLowerCase()===String(b.manifestRoot).toLowerCase()&&
|
| 133 |
+
String(a.targetRoot).toLowerCase()===String(b.targetRoot).toLowerCase())) return false;
|
| 134 |
+
/* Schema-1/2 records from before rooted identities remain compatible only
|
| 135 |
+
when both sides omit the runtime root. Once either side names one, both
|
| 136 |
+
must name the same ordered runtime or it cannot bless/replace the other. */
|
| 137 |
+
if(a.runtimeRoot||b.runtimeRoot) return !!(a.runtimeRoot&&b.runtimeRoot&&
|
| 138 |
+
String(a.runtimeRoot).toLowerCase()===String(b.runtimeRoot).toLowerCase());
|
| 139 |
+
return true;
|
| 140 |
}
|
| 141 |
function sameBundle(a,b){
|
| 142 |
if(!a||!b||String(a.version)!==String(b.version)||
|
| 143 |
+
patchChannel(a)!==patchChannel(b)||!sameRoots(a,b)) return false;
|
| 144 |
if(a.at!=null||b.at!=null) return String(a.at||'')===String(b.at||'');
|
| 145 |
return true;
|
| 146 |
}
|
|
|
|
| 173 |
}
|
| 174 |
function probationMatchesBundle(prob,bundle){
|
| 175 |
if(!prob||!bundle||String(prob.version)!==String(bundle.version)||
|
| 176 |
+
patchChannel(prob)!==patchChannel(bundle)||!sameRoots(prob,bundle)) return false;
|
| 177 |
return prob.pendingAt==null||String(prob.pendingAt)===String(bundle.at||'');
|
| 178 |
}
|
| 179 |
function probationOwnsMetaExact(prob,meta){
|
| 180 |
return !!(prob&&meta&&prob.pendingAt!=null&&meta.at!=null&&
|
| 181 |
String(prob.version)===String(meta.version)&&
|
| 182 |
patchChannel(prob)===patchChannel(meta)&&
|
| 183 |
+
sameRoots(prob,meta)&&
|
| 184 |
String(prob.pendingAt)===String(meta.at));
|
| 185 |
}
|
| 186 |
function pendingMatchesFailure(pending,prob,bundle,version){
|
| 187 |
if(!pending||String(pending.version)!==String(version)) return false;
|
| 188 |
var owner=prob||bundle;
|
| 189 |
if(owner&&patchChannel(pending)!==patchChannel(owner)) return false;
|
| 190 |
+
if(owner&&!sameRoots(pending,owner)) return false;
|
| 191 |
var token=prob&&prob.pendingAt!=null?prob.pendingAt:
|
| 192 |
(bundle&&bundle.at!=null?bundle.at:null);
|
| 193 |
/* Old records without an attempt token cannot be distinguished from a
|
|
|
|
| 245 |
function validBundle(b){
|
| 246 |
if(!b||!b.files||!verNewer(b.version,PACKAGED_REV)) return false;
|
| 247 |
var order=b.order&&b.order.length?b.order:MANIFEST;
|
| 248 |
+
var schema=Number(b.schema||1);
|
| 249 |
+
if(schema>=3){
|
| 250 |
+
var hex=/^[0-9a-f]{64}$/;
|
| 251 |
+
if(!Number.isSafeInteger(schema)||
|
| 252 |
+
!hex.test(String(b.manifestRoot||''))||
|
| 253 |
+
!hex.test(String(b.payloadRoot||''))||
|
| 254 |
+
!hex.test(String(b.sourcePayloadRoot||''))||
|
| 255 |
+
!hex.test(String(b.fullRoot||''))||
|
| 256 |
+
!hex.test(String(b.targetRoot||''))||
|
| 257 |
+
!hex.test(String(b.runtimeRoot||''))||
|
| 258 |
+
String(b.targetRoot)!==String(b.fullRoot)||
|
| 259 |
+
(String(b.payloadRoot)!==String(b.sourcePayloadRoot)&&
|
| 260 |
+
String(b.payloadRoot)!==String(b.fullRoot))||
|
| 261 |
+
(b.manifestKind!=='full'&&b.manifestKind!=='patch')||
|
| 262 |
+
['system','hotfix','content','overhaul'].indexOf(b.manifestCategory)<0||
|
| 263 |
+
(b.manifestKind==='patch'&&!/^\d+\.\d+\.\d+$/.test(String(b.manifestPatchFrom||'')))||
|
| 264 |
+
(b.manifestKind==='full'&&String(b.manifestPatchFrom||'')!=='')||
|
| 265 |
+
(b.channel!=='stable'&&b.channel!=='preview')||
|
| 266 |
+
(b.storage!=='artifact-v1'&&b.storage!=='bundle-v1')||
|
| 267 |
+
!Array.isArray(b.order)||!b.order.length||
|
| 268 |
+
!b.files||typeof b.files!=='object'||Array.isArray(b.files)) return false;
|
| 269 |
+
var own=Object.keys(b.files),seen={};
|
| 270 |
+
if(own.length!==b.order.length) return false;
|
| 271 |
+
for(var p=0;p<b.order.length;p++){
|
| 272 |
+
var path=b.order[p];
|
| 273 |
+
if(typeof path!=='string'||path.length>512||
|
| 274 |
+
!/^[A-Za-z0-9._/-]+$/.test(path)||path.charAt(0)==='/'||
|
| 275 |
+
path.slice(-1)==='/'||path.indexOf('//')>=0||
|
| 276 |
+
path.split('/').some(function(part){return !part||part==='.'||part==='..';})||
|
| 277 |
+
seen[path]||!Object.prototype.hasOwnProperty.call(b.files,path)) return false;
|
| 278 |
+
seen[path]=true;
|
| 279 |
+
}
|
| 280 |
+
for(var o=0;o<own.length;o++) if(!seen[own[o]]) return false;
|
| 281 |
+
}
|
| 282 |
+
for(var i=0;i<order.length;i++){
|
| 283 |
+
var file=b.files[order[i]];
|
| 284 |
+
if(typeof file==='string'){
|
| 285 |
+
if(schema>=3&&b.storage!=='bundle-v1') return false;
|
| 286 |
+
continue;
|
| 287 |
+
}
|
| 288 |
+
if(!file||typeof file!=='object'||typeof file.key!=='string'||!file.key||
|
| 289 |
+
typeof file.sha256!=='string'||!/^[0-9a-f]{64}$/i.test(file.sha256)||
|
| 290 |
+
!Number.isSafeInteger(file.size)||file.size<=0) return false;
|
| 291 |
+
if(schema>=3){
|
| 292 |
+
if(b.storage!=='artifact-v1') return false;
|
| 293 |
+
var suffix=':file:'+order[i];
|
| 294 |
+
if(file.key.slice(-suffix.length)!==suffix||
|
| 295 |
+
!/^transfer-v1:(stable|preview)-\d+\.\d+\.\d+-[0-9a-f]{64}-(payload|full)$/.test(
|
| 296 |
+
file.key.slice(0,-suffix.length)))
|
| 297 |
+
return false;
|
| 298 |
+
}
|
| 299 |
+
}
|
| 300 |
return true;
|
| 301 |
}
|
| 302 |
function evictSuperseded(db){
|
|
|
|
| 434 |
:(!meta||sameBundle(meta,value)));
|
| 435 |
if(good){
|
| 436 |
store.put(value,'active'); store.put(bundleMeta(value),'activeMeta');
|
| 437 |
+
/* Re-validate the rollback payload on this launch. Its metadata
|
| 438 |
+
was previously trusted, but descriptor records may since have
|
| 439 |
+
been evicted or corrupted. tries:1 makes the next launch fail
|
| 440 |
+
closed to packaged code unless this exact recovered runtime
|
| 441 |
+
reaches __bootOk now. */
|
| 442 |
+
store.put({version:value.version,channel:value.channel||'stable',
|
| 443 |
+
manifestRoot:value.manifestRoot||'',targetRoot:value.targetRoot||'',
|
| 444 |
+
runtimeRoot:value.runtimeRoot||'',
|
| 445 |
+
pendingAt:value.at,at:Date.now(),tries:1,recoveryOf:version},'probation');
|
| 446 |
clearPreviousRecords(store);
|
| 447 |
clearFinishedRollback(value);
|
| 448 |
decision={kind:'recovered',bundle:value,failedVersion:version,
|
|
|
|
| 494 |
return;
|
| 495 |
}
|
| 496 |
var claimed={version:bundle.version,channel:bundle.channel||prob.channel,
|
| 497 |
+
manifestRoot:bundle.manifestRoot||'',targetRoot:bundle.targetRoot||'',
|
| 498 |
+
runtimeRoot:bundle.runtimeRoot||'',
|
| 499 |
pendingAt:bundle.at!=null?bundle.at:prob.pendingAt,
|
| 500 |
at:prob.at||Date.now(),tries:(prob.tries|0)+1};
|
| 501 |
store.put(claimed,'probation');
|
|
|
|
| 507 |
/* A mismatched legacy/torn guard must never be used to validate a
|
| 508 |
different active record. Replace it with an exact first claim. */
|
| 509 |
store.put({version:bundle.version,channel:bundle.channel||'stable',
|
| 510 |
+
manifestRoot:bundle.manifestRoot||'',targetRoot:bundle.targetRoot||'',
|
| 511 |
+
runtimeRoot:bundle.runtimeRoot||'',
|
| 512 |
pendingAt:bundle.at,at:Date.now(),tries:1},'probation');
|
| 513 |
clearFinishedRollback(bundle);
|
| 514 |
decision={kind:'run',bundle:bundle,claimed:true,repaired:true};
|
|
|
|
| 603 |
injectScripts(function(s,i){ s.src=MANIFEST[i]+'?v='+PACKAGED_SRC_REV; },
|
| 604 |
MANIFEST.length,rendererGateIndex(MANIFEST));
|
| 605 |
}
|
| 606 |
+
function runArtifactBundle(b){
|
| 607 |
+
/* Descriptor bundles keep each verified source artifact in its own IDB
|
| 608 |
+
record. Load, re-hash, execute and release one file at a time so a large
|
| 609 |
+
source update does not deserialize a ~93 MB object or create 110 Blob
|
| 610 |
+
URLs simultaneously. Strict sequencing preserves the classic shared
|
| 611 |
+
global scope just like async=false script tags. */
|
| 612 |
+
var order=b.order&&b.order.length?b.order:MANIFEST;
|
| 613 |
+
var gate=rendererGateIndex(order),index=0,total=order.length,executed=0,stopped=false;
|
| 614 |
+
var runtimeRows=[];
|
| 615 |
+
bootProgress(0,total);
|
| 616 |
+
function rangeFailure(path,reason){
|
| 617 |
+
if(stopped) return;
|
| 618 |
+
stopped=true;
|
| 619 |
+
console.error('boot: failed',path,reason||'stored artifact unavailable');
|
| 620 |
+
clearBootShield();
|
| 621 |
+
try{if(typeof window.__MASSFRONT_CLEAR_INPUT_GUARD==='function')
|
| 622 |
+
window.__MASSFRONT_CLEAR_INPUT_GUARD();}catch(e){}
|
| 623 |
+
if(executed===0){
|
| 624 |
+
/* The verification pass failed before one downloaded script executed,
|
| 625 |
+
so a packaged session is safe. Keep probation in IndexedDB; clearing
|
| 626 |
+
only the window identity prevents that fallback frame from blessing
|
| 627 |
+
the broken patch through __bootOk. */
|
| 628 |
+
try{
|
| 629 |
+
delete window.__MASSFRONT_PATCHED; delete window.__MASSFRONT_PATCH_AT;
|
| 630 |
+
delete window.__MASSFRONT_PATCH_CHANNEL;
|
| 631 |
+
delete window.__MASSFRONT_PATCH_MANIFEST_ROOT;
|
| 632 |
+
delete window.__MASSFRONT_PATCH_TARGET_ROOT;
|
| 633 |
+
delete window.__MASSFRONT_PATCH_RUNTIME_ROOT;
|
| 634 |
+
}catch(e){}
|
| 635 |
+
runPackaged();
|
| 636 |
+
}else{
|
| 637 |
+
/* A record changed between preflight and execution. Never mix a partial
|
| 638 |
+
classic-script global scope with packaged code; restart so the armed
|
| 639 |
+
probation transaction performs normal recovery. */
|
| 640 |
+
console.warn('boot: stored artifact changed during execution; restarting for recovery');
|
| 641 |
+
try{ if(window.location&&typeof window.location.reload==='function') window.location.reload(); }
|
| 642 |
+
catch(e){}
|
| 643 |
+
}
|
| 644 |
+
}
|
| 645 |
+
function textDigest(text){
|
| 646 |
+
if(!window.crypto||!window.crypto.subtle||typeof TextEncoder==='undefined')
|
| 647 |
+
return Promise.reject(new Error('integrity checks unavailable'));
|
| 648 |
+
var bytes=new TextEncoder().encode(text);
|
| 649 |
+
return window.crypto.subtle.digest('SHA-256',bytes).then(function(value){
|
| 650 |
+
var a=new Uint8Array(value),hex='';
|
| 651 |
+
for(var i=0;i<a.length;i++) hex+=a[i].toString(16).padStart(2,'0');
|
| 652 |
+
return {size:bytes.byteLength,sha256:hex.toLowerCase()};
|
| 653 |
+
});
|
| 654 |
+
}
|
| 655 |
+
function digest(text,ref,path){
|
| 656 |
+
return textDigest(text).then(function(actual){
|
| 657 |
+
if(!Number.isSafeInteger(ref.size)||ref.size<=0||actual.size!==ref.size)
|
| 658 |
+
throw new Error('stored size mismatch');
|
| 659 |
+
if(actual.sha256!==String(ref.sha256).toLowerCase())
|
| 660 |
+
throw new Error('stored hash mismatch');
|
| 661 |
+
return text;
|
| 662 |
+
});
|
| 663 |
+
}
|
| 664 |
+
function append(text,path){
|
| 665 |
+
var blob=URL.createObjectURL(new Blob([text+'\n//# sourceURL='+path],{type:'text/javascript'}));
|
| 666 |
+
var script=document.createElement('script'); script.async=false; script.src=blob;
|
| 667 |
+
script.onload=function(){
|
| 668 |
+
URL.revokeObjectURL(blob);
|
| 669 |
+
var current=index++; executed++;
|
| 670 |
+
bootProgress(index,total);
|
| 671 |
+
if(current===gate&&window.__MF_GL_BOOT_FAILED===true){
|
| 672 |
+
console.warn('boot: renderer phase stopped after WebGL2 initialization failed');
|
| 673 |
+
clearBootShield();
|
| 674 |
+
try{if(typeof window.__MASSFRONT_CLEAR_INPUT_GUARD==='function')
|
| 675 |
+
window.__MASSFRONT_CLEAR_INPUT_GUARD();}catch(e){}
|
| 676 |
+
return;
|
| 677 |
+
}
|
| 678 |
+
next();
|
| 679 |
+
};
|
| 680 |
+
script.onerror=function(){ URL.revokeObjectURL(blob); rangeFailure(path,'script load error'); };
|
| 681 |
+
document.body.appendChild(script);
|
| 682 |
+
}
|
| 683 |
+
var dbPromise=idb();
|
| 684 |
+
function readArtifact(path,ref){
|
| 685 |
+
if(typeof ref==='string') return Promise.resolve(ref);
|
| 686 |
+
return dbPromise.then(function(db){
|
| 687 |
+
return new Promise(function(res,rej){
|
| 688 |
+
try{
|
| 689 |
+
var q=db.transaction(STORE,'readonly').objectStore(STORE).get(ref.key);
|
| 690 |
+
q.onsuccess=function(){ res(q.result); };
|
| 691 |
+
q.onerror=function(){ rej(q.error||new Error('artifact read failed')); };
|
| 692 |
+
}catch(e){ rej(e); }
|
| 693 |
+
});
|
| 694 |
+
}).then(function(rec){
|
| 695 |
+
if(!rec||typeof rec.text!=='string'||!Number.isSafeInteger(rec.size)||
|
| 696 |
+
rec.size<=0||rec.size!==ref.size||
|
| 697 |
+
String(rec.sha256).toLowerCase()!==String(ref.sha256).toLowerCase())
|
| 698 |
+
throw new Error('stored artifact metadata mismatch');
|
| 699 |
+
return digest(rec.text,ref,path);
|
| 700 |
+
});
|
| 701 |
+
}
|
| 702 |
+
function preflight(i){
|
| 703 |
+
if(stopped) return;
|
| 704 |
+
if(i>=total){
|
| 705 |
+
textDigest(runtimeRows.join('\n')).then(function(actual){
|
| 706 |
+
if(Number(b.schema||1)<3) return actual;
|
| 707 |
+
if(actual.sha256!==String(b.runtimeRoot||'').toLowerCase())
|
| 708 |
+
throw new Error('ordered runtime root mismatch');
|
| 709 |
+
var contract=[
|
| 710 |
+
'schema='+b.schema,'channel='+b.channel,'version='+b.version,
|
| 711 |
+
'kind='+b.manifestKind,'category='+b.manifestCategory,
|
| 712 |
+
'patchFrom='+b.manifestPatchFrom,
|
| 713 |
+
'payload='+b.sourcePayloadRoot,'full='+b.fullRoot,
|
| 714 |
+
'runtime='+actual.sha256
|
| 715 |
+
].join('\n');
|
| 716 |
+
return textDigest(contract).then(function(root){
|
| 717 |
+
if(root.sha256!==String(b.manifestRoot||'').toLowerCase())
|
| 718 |
+
throw new Error('manifest root contract mismatch');
|
| 719 |
+
return actual;
|
| 720 |
+
});
|
| 721 |
+
}).then(function(){
|
| 722 |
+
index=0;bootProgress(0,total);next();
|
| 723 |
+
}).catch(function(error){ rangeFailure('__runtime__',error&&error.message); });
|
| 724 |
+
return;
|
| 725 |
+
}
|
| 726 |
+
var path=order[i],ref=b.files[path];
|
| 727 |
+
if(typeof ref==='string'){
|
| 728 |
+
textDigest(ref).then(function(actual){
|
| 729 |
+
if(!Number.isSafeInteger(actual.size)||actual.size<=0)
|
| 730 |
+
throw new Error('stored size mismatch');
|
| 731 |
+
runtimeRows.push(path+'|'+actual.size+'|'+actual.sha256);
|
| 732 |
+
bootProgress(i+1,total);preflight(i+1);
|
| 733 |
+
}).catch(function(error){ rangeFailure(path,error&&error.message); });
|
| 734 |
+
return;
|
| 735 |
+
}
|
| 736 |
+
readArtifact(path,ref).then(function(){
|
| 737 |
+
runtimeRows.push(path+'|'+ref.size+'|'+String(ref.sha256).toLowerCase());
|
| 738 |
+
bootProgress(i+1,total);preflight(i+1);
|
| 739 |
+
}).catch(function(error){ rangeFailure(path,error&&error.message); });
|
| 740 |
+
}
|
| 741 |
+
function next(){
|
| 742 |
+
if(stopped||index>=total) return;
|
| 743 |
+
var path=order[index],ref=b.files[path];
|
| 744 |
+
if(typeof ref==='string'){ append(ref,path); return; }
|
| 745 |
+
readArtifact(path,ref).then(function(text){ append(text,path); })
|
| 746 |
+
.catch(function(error){ rangeFailure(path,error&&error.message); });
|
| 747 |
+
}
|
| 748 |
+
preflight(0);
|
| 749 |
+
}
|
| 750 |
function runBundle(b){
|
| 751 |
/* Blob URLs rather than inline text: the browser keeps a real filename for
|
| 752 |
each source, so a stack trace from a patched build is still readable. */
|
| 753 |
var order=b.order&&b.order.length? b.order : MANIFEST;
|
| 754 |
+
if(Number(b.schema||1)>=3&&b.runtimeRoot){ runArtifactBundle(b);return; }
|
| 755 |
+
for(var i=0;i<order.length;i++) if(typeof b.files[order[i]]!=='string'){
|
| 756 |
+
runArtifactBundle(b); return;
|
| 757 |
+
}
|
| 758 |
injectScripts(function(s,i){
|
| 759 |
var path=order[i], src=b.files[path];
|
| 760 |
if(src==null) s.src=path;
|
|
|
|
| 775 |
window.__MASSFRONT_PATCHED=decision.bundle.version||'?';
|
| 776 |
window.__MASSFRONT_PATCH_AT=decision.bundle.at;
|
| 777 |
window.__MASSFRONT_PATCH_CHANNEL=decision.bundle.channel||'stable';
|
| 778 |
+
window.__MASSFRONT_PATCH_MANIFEST_ROOT=decision.bundle.manifestRoot||'';
|
| 779 |
+
window.__MASSFRONT_PATCH_TARGET_ROOT=decision.bundle.targetRoot||'';
|
| 780 |
+
window.__MASSFRONT_PATCH_RUNTIME_ROOT=decision.bundle.runtimeRoot||'';
|
| 781 |
window.__MASSFRONT_RECOVERED_PATCH=decision.failedVersion||'?';
|
| 782 |
console.warn('boot: restored validated patch '+decision.bundle.version+
|
| 783 |
' after '+decision.failedVersion+' failed');
|
|
|
|
| 788 |
window.__MASSFRONT_PATCHED=decision.bundle.version||'?';
|
| 789 |
window.__MASSFRONT_PATCH_AT=decision.bundle.at;
|
| 790 |
window.__MASSFRONT_PATCH_CHANNEL=decision.bundle.channel||'stable';
|
| 791 |
+
window.__MASSFRONT_PATCH_MANIFEST_ROOT=decision.bundle.manifestRoot||'';
|
| 792 |
+
window.__MASSFRONT_PATCH_TARGET_ROOT=decision.bundle.targetRoot||'';
|
| 793 |
+
window.__MASSFRONT_PATCH_RUNTIME_ROOT=decision.bundle.runtimeRoot||'';
|
| 794 |
runBundle(decision.bundle);
|
| 795 |
return;
|
| 796 |
}
|
|
|
|
| 808 |
failed before it selected `active`. It has NOT proved the stored patch
|
| 809 |
good, so it must not erase that patch's probation record. Leaving it in
|
| 810 |
place means the next healthy launch retries under the normal rollback
|
| 811 |
+
guard instead of running an unguarded payload. Recovered previous code
|
| 812 |
+
now owns a fresh probation guard and must confirm its own descriptor
|
| 813 |
+
records before becoming the new last-known-good runtime. */
|
| 814 |
+
if(!window.__MASSFRONT_PATCHED) return;
|
| 815 |
idb().then(function(db){
|
| 816 |
return confirmPatch(db,{version:window.__MASSFRONT_PATCHED,
|
| 817 |
channel:window.__MASSFRONT_PATCH_CHANNEL||'stable',
|
| 818 |
+
manifestRoot:window.__MASSFRONT_PATCH_MANIFEST_ROOT||'',
|
| 819 |
+
targetRoot:window.__MASSFRONT_PATCH_TARGET_ROOT||'',
|
| 820 |
+
runtimeRoot:window.__MASSFRONT_PATCH_RUNTIME_ROOT||'',
|
| 821 |
at:window.__MASSFRONT_PATCH_AT});
|
| 822 |
}).catch(function(){});
|
| 823 |
};
|
|
|
|
| 835 |
if(!secure) return;
|
| 836 |
window.__mfPwaDiag={supported:true,registered:false,controlled:!!navigator.serviceWorker.controller,error:null};
|
| 837 |
window.addEventListener('load',function(){
|
| 838 |
+
navigator.serviceWorker.register('./sw.js?v=1.33.60-shell2',{scope:'./',updateViaCache:'none'})
|
| 839 |
.then(function(reg){
|
| 840 |
window.__mfPwaDiag.registered=true;
|
| 841 |
window.__mfPwaDiag.scope=reg.scope;
|
index.html
CHANGED
|
@@ -17,19 +17,19 @@
|
|
| 17 |
<meta name="mobile-web-app-capable" content="yes">
|
| 18 |
<meta name="theme-color" content="#060c16">
|
| 19 |
<meta name="format-detection" content="telephone=no">
|
| 20 |
-
<link rel="apple-touch-icon" href="./assets/icons/icon-180.png?v=1.33.
|
| 21 |
-
<link rel="apple-touch-icon" sizes="152x152" href="./assets/icons/icon-152.png?v=1.33.
|
| 22 |
-
<link rel="apple-touch-startup-image" href="./assets/icons/splash-2732.png?v=1.33.
|
| 23 |
-
<link rel="icon" href="./assets/icons/icon-192.png?v=1.33.
|
| 24 |
-
<link rel="manifest" href="./assets/app.webmanifest?v=1.33.
|
| 25 |
-
|
| 26 |
-
<link rel="stylesheet" href="./assets/ui.css?v=1.33.
|
| 27 |
-
<link rel="stylesheet" href="./src/styles/auth.css?v=1.33.
|
| 28 |
-
<link rel="stylesheet" href="./src/styles/tutorial.css?v=1.33.
|
| 29 |
-
<link rel="stylesheet" href="./src/styles/ads.css?v=1.33.
|
| 30 |
-
<link rel="stylesheet" href="./src/styles/store.css?v=1.33.
|
| 31 |
-
<link rel="stylesheet" href="./src/styles/restree.css?v=1.33.
|
| 32 |
-
<link rel="stylesheet" href="./src/styles/intro.css?v=1.33.
|
| 33 |
<style>
|
| 34 |
/* CRITICAL — inline so it holds on the FIRST painted frame even if the linked
|
| 35 |
intro.css above is slow or fails. The launch reveal (src/intro.js, z-index 950)
|
|
@@ -73,14 +73,17 @@ body:not(.mfIntroDone) .overlay{display:none!important}
|
|
| 73 |
<div id="goalBar" role="status" aria-live="polite"></div>
|
| 74 |
</div>
|
| 75 |
|
| 76 |
-
<!--
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
on the unit itself in the 3D view. Now: rank symbol + level + XP, pinned to
|
| 80 |
-
the top-left corner. Still tappable to select and center the Commander. -->
|
| 81 |
<div id="heroBar" role="button" tabindex="0" aria-label="Select and center the Commander">
|
| 82 |
-
<
|
| 83 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
</div>
|
| 85 |
|
| 86 |
<div id="toast"></div>
|
|
@@ -105,11 +108,12 @@ body:not(.mfIntroDone) .overlay{display:none!important}
|
|
| 105 |
</span>
|
| 106 |
</div>
|
| 107 |
</div>
|
| 108 |
-
<div id="hudDeckTabs" aria-label="Command dock">
|
| 109 |
-
<button class="hudDeckBtn on" data-deck="orders"><span>⌁</span>ORDERS</button>
|
| 110 |
-
<button class="hudDeckBtn" data-deck="platoons"><span>Ⅳ</span>PLATOONS</button>
|
| 111 |
-
<button class="hudDeckBtn"
|
| 112 |
-
<button class="hudDeckBtn" data-deck="
|
|
|
|
| 113 |
</div>
|
| 114 |
<div class="btnrow" id="camRow">
|
| 115 |
<button class="cbtn camBtn" id="rotL"><span class="em">⟲</span><span>Rot</span></button>
|
|
@@ -156,7 +160,6 @@ body:not(.mfIntroDone) .overlay{display:none!important}
|
|
| 156 |
<button class="cbtn" id="boxBtn"><span class="em">⬚</span><span>Select</span></button>
|
| 157 |
<button class="cbtn" id="stopBtn"><span class="em">✋</span><span>Stop</span></button>
|
| 158 |
<button class="cbtn" id="buildBtn"><span class="em">🏗</span><span>Build</span></button>
|
| 159 |
-
<button class="cbtn" id="baseFindBtn"><span class="em">⌖</span><span>Base</span></button>
|
| 160 |
</div>
|
| 161 |
</div>
|
| 162 |
|
|
@@ -526,30 +529,129 @@ body:not(.mfIntroDone) .overlay{display:none!important}
|
|
| 526 |
<!-- The version block used to sit permanently at the bottom of the menu. It is
|
| 527 |
now a status dot in the header — grey unchecked, amber checking or update
|
| 528 |
waiting, green current, red failed — and this is where the dot leads. -->
|
| 529 |
-
<
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
<div class="
|
| 535 |
-
<
|
| 536 |
-
<div class="
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 540 |
</div>
|
| 541 |
-
<div class="
|
| 542 |
-
<
|
| 543 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
| 544 |
</div>
|
| 545 |
-
</
|
| 546 |
-
|
| 547 |
-
<
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 551 |
</div>
|
| 552 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
| 553 |
</div>
|
| 554 |
|
| 555 |
<div class="overlay" id="inboxScr">
|
|
@@ -707,6 +809,6 @@ body:not(.mfIntroDone) .overlay{display:none!important}
|
|
| 707 |
<button class="mbtn alt" id="pauseSettings">⚙ SETTINGS</button>
|
| 708 |
<button class="mbtn alt" id="quitBtn">✦ ABANDON MATCH</button>
|
| 709 |
</div>
|
| 710 |
-
<script src="./boot.js?v=1.33.
|
| 711 |
</body>
|
| 712 |
</html>
|
|
|
|
| 17 |
<meta name="mobile-web-app-capable" content="yes">
|
| 18 |
<meta name="theme-color" content="#060c16">
|
| 19 |
<meta name="format-detection" content="telephone=no">
|
| 20 |
+
<link rel="apple-touch-icon" href="./assets/icons/icon-180.png?v=1.33.60">
|
| 21 |
+
<link rel="apple-touch-icon" sizes="152x152" href="./assets/icons/icon-152.png?v=1.33.60">
|
| 22 |
+
<link rel="apple-touch-startup-image" href="./assets/icons/splash-2732.png?v=1.33.60">
|
| 23 |
+
<link rel="icon" href="./assets/icons/icon-192.png?v=1.33.60">
|
| 24 |
+
<link rel="manifest" href="./assets/app.webmanifest?v=1.33.60">
|
| 25 |
+
<link rel="stylesheet" href="./src/styles/ui.css?v=1.33.60-hud2">
|
| 26 |
+
<link rel="stylesheet" href="./assets/ui.css?v=1.33.60-ui-safety">
|
| 27 |
+
<link rel="stylesheet" href="./src/styles/auth.css?v=1.33.60">
|
| 28 |
+
<link rel="stylesheet" href="./src/styles/tutorial.css?v=1.33.60">
|
| 29 |
+
<link rel="stylesheet" href="./src/styles/ads.css?v=1.33.60">
|
| 30 |
+
<link rel="stylesheet" href="./src/styles/store.css?v=1.33.60">
|
| 31 |
+
<link rel="stylesheet" href="./src/styles/restree.css?v=1.33.60">
|
| 32 |
+
<link rel="stylesheet" href="./src/styles/intro.css?v=1.33.60">
|
| 33 |
<style>
|
| 34 |
/* CRITICAL — inline so it holds on the FIRST painted frame even if the linked
|
| 35 |
intro.css above is slow or fails. The launch reveal (src/intro.js, z-index 950)
|
|
|
|
| 73 |
<div id="goalBar" role="status" aria-live="polite"></div>
|
| 74 |
</div>
|
| 75 |
|
| 76 |
+
<!-- The upper-left commander locator is both identity and navigation: it uses
|
| 77 |
+
the chosen commander's canonical portrait/callsign, exposes live HP/XP, and
|
| 78 |
+
a tap always selects and recentres that commander. -->
|
|
|
|
|
|
|
| 79 |
<div id="heroBar" role="button" tabindex="0" aria-label="Select and center the Commander">
|
| 80 |
+
<span id="heroPortrait" aria-hidden="true"><img id="heroPortraitImg" alt=""><i id="heroPortraitFallback">C</i><b id="heroRankEm">🎗</b></span>
|
| 81 |
+
<span class="heroProfileBody">
|
| 82 |
+
<span class="heroProfileHead"><strong id="heroNameTxt">COMMANDER</strong><b id="heroLvlTxt">LV 1</b></span>
|
| 83 |
+
<span id="heroCallsignTxt">FIELD COMMAND</span>
|
| 84 |
+
<span class="heroVital"><small>HP</small><span class="heroBarOuter"><i id="heroHpFill"></i></span><b id="heroHpTxt">100%</b></span>
|
| 85 |
+
<span class="heroVital heroXp"><small>XP</small><span id="xpOuter"><i id="xpFill"></i></span><b id="heroXpTxt">0%</b></span>
|
| 86 |
+
</span>
|
| 87 |
</div>
|
| 88 |
|
| 89 |
<div id="toast"></div>
|
|
|
|
| 108 |
</span>
|
| 109 |
</div>
|
| 110 |
</div>
|
| 111 |
+
<div id="hudDeckTabs" role="tablist" aria-label="Command dock">
|
| 112 |
+
<button class="hudDeckBtn on" role="tab" aria-selected="true" data-deck="orders"><span>⌁</span>ORDERS</button>
|
| 113 |
+
<button class="hudDeckBtn" role="tab" aria-selected="false" data-deck="platoons"><span>Ⅳ</span>PLATOONS</button>
|
| 114 |
+
<button class="hudDeckBtn" role="tab" aria-selected="false" data-deck="buildings"><span>⌂</span>BUILDINGS</button>
|
| 115 |
+
<button class="hudDeckBtn" role="tab" aria-selected="false" data-deck="abilities" disabled aria-disabled="true"><span>✦</span>ABILITIES</button>
|
| 116 |
+
<button class="hudDeckBtn" role="tab" aria-selected="false" data-deck="view"><span>◇</span>VIEW</button>
|
| 117 |
</div>
|
| 118 |
<div class="btnrow" id="camRow">
|
| 119 |
<button class="cbtn camBtn" id="rotL"><span class="em">⟲</span><span>Rot</span></button>
|
|
|
|
| 160 |
<button class="cbtn" id="boxBtn"><span class="em">⬚</span><span>Select</span></button>
|
| 161 |
<button class="cbtn" id="stopBtn"><span class="em">✋</span><span>Stop</span></button>
|
| 162 |
<button class="cbtn" id="buildBtn"><span class="em">🏗</span><span>Build</span></button>
|
|
|
|
| 163 |
</div>
|
| 164 |
</div>
|
| 165 |
|
|
|
|
| 529 |
<!-- The version block used to sit permanently at the bottom of the menu. It is
|
| 530 |
now a status dot in the header — grey unchecked, amber checking or update
|
| 531 |
waiting, green current, red failed — and this is where the dot leads. -->
|
| 532 |
+
<!-- GAME LAUNCHER / VERSION CENTER
|
| 533 |
+
One surface owns both cold-launch readiness and the menu's version detail.
|
| 534 |
+
The updater keeps its established IDs and authority; src/launcher.js reads
|
| 535 |
+
explicit updater/auth events and never infers state from the labels below. -->
|
| 536 |
+
<div class="overlay mfLauncher" id="updScr" aria-label="MASSFRONT launcher and game version">
|
| 537 |
+
<div class="mfLauncherBackdrop" aria-hidden="true"><i></i><i></i><i></i></div>
|
| 538 |
+
<header class="mfLauncherTop">
|
| 539 |
+
<div class="mfLauncherTopBrand"><b>MASSFRONT</b><span>COMMAND NETWORK</span></div>
|
| 540 |
+
<div class="mfLauncherIdentity" id="mfLaunchIdentity" data-state="pending">
|
| 541 |
+
<i aria-hidden="true"></i><span id="mfLaunchIdentityText">IDENTITY LINK</span>
|
| 542 |
+
</div>
|
| 543 |
+
</header>
|
| 544 |
+
<div class="updScroll mfLauncherScroll" id="mfLauncherScroll">
|
| 545 |
+
<section class="mfLauncherHero" aria-labelledby="mfLaunchHeroTitle">
|
| 546 |
+
<div class="mfLauncherHeroArt" aria-hidden="true">
|
| 547 |
+
<img src="./assets/icons/splash-2732.png?v=1.33.60" alt="" decoding="async">
|
| 548 |
+
<span class="mfLauncherOrbit"></span><span class="mfLauncherSweep"></span>
|
| 549 |
</div>
|
| 550 |
+
<div class="mfLauncherHeroCopy">
|
| 551 |
+
<span class="mfLauncherEyebrow">◈ MASSFRONT LAUNCH CONTROL</span>
|
| 552 |
+
<h2 id="mfLaunchHeroTitle">YOUR COMMAND AWAITS</h2>
|
| 553 |
+
<p id="mfLaunchHeroSummary">Verifying the installed battlefield and command link.</p>
|
| 554 |
+
<div class="mfLauncherHeroChips">
|
| 555 |
+
<span id="mfLaunchVersion">v—</span><span id="mfLaunchCategory">SYSTEM</span><span id="mfLaunchNetwork">CHECKING LINK</span>
|
| 556 |
+
</div>
|
| 557 |
</div>
|
| 558 |
+
</section>
|
| 559 |
+
|
| 560 |
+
<section class="mfLauncherConsole" aria-labelledby="mfLaunchUpdateTitle">
|
| 561 |
+
<div class="mfLauncherSectionHead">
|
| 562 |
+
<div><span>01</span><h3 id="mfLaunchUpdateTitle">GAME READINESS</h3></div>
|
| 563 |
+
<b id="mfLaunchPhase">INITIALIZING</b>
|
| 564 |
+
</div>
|
| 565 |
+
<div id="updPanel" class="mini">
|
| 566 |
+
<div class="updHead">
|
| 567 |
+
<div>
|
| 568 |
+
<div id="updTxt">GAME VERSION</div>
|
| 569 |
+
<div id="updSub">Preparing local runtime</div>
|
| 570 |
+
</div>
|
| 571 |
+
<div class="updBtns">
|
| 572 |
+
<button id="updCancel" type="button" aria-label="Cancel update" style="display:none">✕</button>
|
| 573 |
+
<button id="updBtn" type="button">CHECK</button>
|
| 574 |
+
</div>
|
| 575 |
+
</div>
|
| 576 |
+
<div id="updBarO" role="progressbar" aria-label="Update progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><div id="updBarF"></div></div>
|
| 577 |
+
<div class="mfLauncherTelemetry" aria-live="polite">
|
| 578 |
+
<span><small>TRANSFER</small><b id="mfLaunchTransfer">—</b></span>
|
| 579 |
+
<span><small>SPEED</small><b id="mfLaunchSpeed">—</b></span>
|
| 580 |
+
<span><small>INTEGRITY</small><b id="mfLaunchIntegrity">LOCAL</b></span>
|
| 581 |
+
</div>
|
| 582 |
+
<div id="updFeed" style="display:none"></div>
|
| 583 |
+
<div id="updNotes" style="display:none"></div>
|
| 584 |
+
<button id="updRoll" type="button">↺ Revert to the packaged build</button>
|
| 585 |
+
</div>
|
| 586 |
+
</section>
|
| 587 |
+
|
| 588 |
+
<section class="mfLauncherRelease" aria-labelledby="mfLaunchReleaseTitle">
|
| 589 |
+
<div class="mfLauncherSectionHead">
|
| 590 |
+
<div><span>02</span><h3 id="mfLaunchReleaseTitle">LATEST RELEASE</h3></div>
|
| 591 |
+
<b id="mfLaunchReleaseVersion">v—</b>
|
| 592 |
+
</div>
|
| 593 |
+
<article class="mfLauncherReleaseCard">
|
| 594 |
+
<div class="mfLauncherReleaseBanner">
|
| 595 |
+
<span id="mfLaunchReleaseKind">SYSTEM</span>
|
| 596 |
+
<div><h4 id="mfLaunchReleaseName">COMMAND SYSTEM</h4><p id="mfLaunchReleaseSummary">Release information is loading.</p></div>
|
| 597 |
+
</div>
|
| 598 |
+
<div class="mfLauncherTabs" role="tablist" aria-label="Release note sections">
|
| 599 |
+
<button type="button" class="on" role="tab" aria-selected="true" data-launch-notes="features">FEATURES</button>
|
| 600 |
+
<button type="button" role="tab" aria-selected="false" data-launch-notes="fixes">BUG FIXES</button>
|
| 601 |
+
<button type="button" role="tab" aria-selected="false" data-launch-notes="upcoming">UPCOMING</button>
|
| 602 |
+
</div>
|
| 603 |
+
<ul id="mfLaunchNoteList" class="mfLauncherNoteList"><li>Preparing release briefing…</li></ul>
|
| 604 |
+
</article>
|
| 605 |
+
</section>
|
| 606 |
+
|
| 607 |
+
<section class="mfLauncherContent" aria-labelledby="mfLaunchContentTitle">
|
| 608 |
+
<div class="mfLauncherSectionHead">
|
| 609 |
+
<div><span>03</span><h3 id="mfLaunchContentTitle">OPTIONAL DOWNLOADS</h3></div>
|
| 610 |
+
<b>CORE PLAY UNAFFECTED</b>
|
| 611 |
+
</div>
|
| 612 |
+
<div class="mfLauncherPackRail">
|
| 613 |
+
<article class="mfLauncherPack" data-launch-pack="audio">
|
| 614 |
+
<img src="./assets/icons/icon-512.png?v=1.33.60" alt="MASSFRONT faction command insignia" loading="lazy" decoding="async">
|
| 615 |
+
<div><span>COMMAND AUDIO</span><h4>VOICES + MUSIC</h4><p>Faction radio, UGA KEEL guidance and the full battle score.</p><small id="mfLaunchPackAudioState">CHECKING</small></div>
|
| 616 |
+
<button id="mfLaunchPackAudio" type="button">MANAGE</button>
|
| 617 |
+
</article>
|
| 618 |
+
<article class="mfLauncherPack onlineOnly" data-launch-pack="galactic">
|
| 619 |
+
<img src="./assets/textures/planets/war-table/aelos-basecolor-v1.png?v=1.33.60" alt="Aelos planetary surface" loading="lazy" decoding="async">
|
| 620 |
+
<div><span>EXPANSION MODULE</span><h4>GALACTIC EXPLORATION</h4><p>Optional ship, orbital and planetary campaign content.</p><small id="mfLaunchPackGalacticState">OPTIONAL</small></div>
|
| 621 |
+
<button id="mfLaunchPackGalactic" type="button">INSTALL</button>
|
| 622 |
+
<i class="mfRequiresNet">☁ REQUIRES INTERNET</i>
|
| 623 |
+
</article>
|
| 624 |
+
</div>
|
| 625 |
+
<div id="mfLaunchPackProgress"></div>
|
| 626 |
+
</section>
|
| 627 |
+
|
| 628 |
+
<section class="mfLauncherHistory" aria-labelledby="mfLaunchHistoryTitle">
|
| 629 |
+
<div class="mfLauncherSectionHead">
|
| 630 |
+
<div><span>04</span><h3 id="mfLaunchHistoryTitle">VERSION HISTORY</h3></div>
|
| 631 |
+
<b>EVIDENCE-BACKED</b>
|
| 632 |
+
</div>
|
| 633 |
+
<div class="mfLauncherTabs mfLauncherHistoryTabs" role="tablist" aria-label="Version history source">
|
| 634 |
+
<button type="button" class="on" role="tab" aria-selected="true" data-launch-history="published">PUBLISHED</button>
|
| 635 |
+
<button type="button" role="tab" aria-selected="false" data-launch-history="device">THIS DEVICE</button>
|
| 636 |
+
</div>
|
| 637 |
+
<ol id="mfLaunchHistoryList" class="mfLauncherTimeline"><li><b>BUILDING CATALOG</b><span>Verified records are loading.</span></li></ol>
|
| 638 |
+
</section>
|
| 639 |
+
|
| 640 |
+
<details class="mfLauncherDetails" id="mfLaunchDetails">
|
| 641 |
+
<summary>TECHNICAL DETAILS <span>storage · channel · rollback</span></summary>
|
| 642 |
+
<div class="mfLauncherDetailGrid">
|
| 643 |
+
<span><small>INSTALLED</small><b id="mfLaunchTechInstalled">v—</b></span>
|
| 644 |
+
<span><small>CHANNEL</small><b id="mfLaunchTechChannel">STABLE</b></span>
|
| 645 |
+
<span><small>NETWORK</small><b id="mfLaunchTechNetwork">CHECKING</b></span>
|
| 646 |
+
<span><small>AVAILABLE STORAGE</small><b id="mfLaunchTechStorage">—</b></span>
|
| 647 |
+
</div>
|
| 648 |
+
</details>
|
| 649 |
</div>
|
| 650 |
+
<footer class="warFoot mfLauncherFoot">
|
| 651 |
+
<button class="mbtn alt mfLauncherOfflineAction" id="mfLaunchOffline" type="button">PLAY OFFLINE</button>
|
| 652 |
+
<button class="accBtn mfLauncherPlay" id="mfLaunchPlay" type="button" data-front-focus disabled>VERIFYING GAME…</button>
|
| 653 |
+
<button class="mbtn alt" id="updBack" type="button">◀ BACK</button>
|
| 654 |
+
</footer>
|
| 655 |
</div>
|
| 656 |
|
| 657 |
<div class="overlay" id="inboxScr">
|
|
|
|
| 809 |
<button class="mbtn alt" id="pauseSettings">⚙ SETTINGS</button>
|
| 810 |
<button class="mbtn alt" id="quitBtn">✦ ABANDON MATCH</button>
|
| 811 |
</div>
|
| 812 |
+
<script src="./boot.js?v=1.33.60-boot7"></script>
|
| 813 |
</body>
|
| 814 |
</html>
|
src/assetpack.js
CHANGED
|
@@ -30,42 +30,186 @@
|
|
| 30 |
size; a pack that has not changed costs one small JSON request.
|
| 31 |
============================================================================ */
|
| 32 |
|
| 33 |
-
const PACK = {
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
/* The endpoint is the same one the updater resolves — one server, one setting,
|
| 71 |
configured in exactly one place. */
|
|
@@ -80,79 +224,645 @@ function packBytes(n){
|
|
| 80 |
return n < 1048576 ? (n / 1024).toFixed(0) + ' KB' : (n / 1048576).toFixed(1) + ' MB';
|
| 81 |
}
|
| 82 |
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
if(!
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
for(const
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
|
| 142 |
/* Hand back a playable URL for a pack file, or null if it is not stored. The
|
| 143 |
object URL is cached per file — creating a new one on every play would leak a
|
| 144 |
blob reference for the lifetime of the document. */
|
| 145 |
const packURLs = {};
|
| 146 |
-
async function packURL(pack, name){
|
| 147 |
-
const k = pack + '/' + name;
|
| 148 |
-
if(
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
|
| 157 |
/* ---- UI -------------------------------------------------------------------
|
| 158 |
A single line on the start screen, in the same register as the updater panel:
|
|
@@ -248,15 +958,19 @@ async function packStartInner(manual){
|
|
| 248 |
if(!want.length){ PACK.state = 'idle'; packRenderBar(); return; }
|
| 249 |
let bytes = 0;
|
| 250 |
const need = [];
|
| 251 |
-
for(const p of want){
|
| 252 |
-
const m = await packMissing(p);
|
| 253 |
-
if(m.files.length){ need.push(p); bytes += m.bytes; }
|
| 254 |
-
}
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
PACK.
|
| 258 |
-
|
| 259 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
}
|
| 261 |
let pref = 'ask';
|
| 262 |
try{ pref = localStorage.getItem(PACK_PREF) || 'ask'; }catch(e){}
|
|
@@ -274,8 +988,10 @@ async function packStartInner(manual){
|
|
| 274 |
IndexedDB and never hear a note of it, permanently, with no control in
|
| 275 |
the UI that would fix it. Re-derived from storage rather than from the
|
| 276 |
download results so a pack completed on an EARLIER launch also counts. */
|
| 277 |
-
let haveOne = false;
|
| 278 |
-
for(const p of want)
|
|
|
|
|
|
|
| 279 |
if(haveOne && typeof audAttachPack === 'function') audAttachPack();
|
| 280 |
/* A later pack succeeding must not bury an earlier one's failure: PACK.state
|
| 281 |
is what the panel reads, and 'ready' hides the panel outright — so voice
|
|
@@ -296,25 +1012,30 @@ function initAssetPacks(){
|
|
| 296 |
|
| 297 |
/* Galactic Exploration ships in player APK/www from the signed runtime pack.
|
| 298 |
Older installs (notably 1.33.51) omitted it. When the HEAD probe misses, this
|
| 299 |
-
fetches the remote stub
|
| 300 |
-
|
| 301 |
-
|
|
|
|
| 302 |
ticket lives in this document's sessionStorage, and a blob: document is a
|
| 303 |
different origin, so the ticket and the return bridge would vanish. Cache
|
| 304 |
here; mfOpenExploration keeps the War Room fallback. Opening still needs the
|
| 305 |
files on the same origin (packaged www), which is why player packs include
|
| 306 |
the module instead of relying on this download alone. */
|
| 307 |
-
const EXP_PACK_DB='massfront-exploration-pack', EXP_PACK_STORE='files';
|
| 308 |
-
const
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
r.
|
| 317 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
}
|
| 319 |
async function expPackGet(k){
|
| 320 |
const db = await expPackDb();
|
|
@@ -323,14 +1044,6 @@ async function expPackGet(k){
|
|
| 323 |
q.onsuccess = () => res(q.result); q.onerror = () => rej(q.error);
|
| 324 |
});
|
| 325 |
}
|
| 326 |
-
async function expPackPut(k, v){
|
| 327 |
-
const db = await expPackDb();
|
| 328 |
-
return new Promise((res, rej) => {
|
| 329 |
-
const tx = db.transaction(EXP_PACK_STORE, 'readwrite');
|
| 330 |
-
tx.objectStore(EXP_PACK_STORE).put(v, k);
|
| 331 |
-
tx.oncomplete = () => res(); tx.onerror = () => rej(tx.error);
|
| 332 |
-
});
|
| 333 |
-
}
|
| 334 |
function expPackJoin(base, rel){
|
| 335 |
const root=String(base||'').replace(/\/?$/,'/');
|
| 336 |
const path=String(rel||'').replace(/^\/+/,'');
|
|
@@ -351,38 +1064,69 @@ async function mfExplorationRemoteSpec(){
|
|
| 351 |
const q=String(stub&&stub.downloadQuery||'');
|
| 352 |
return {base,manifest,downloadQuery:q};
|
| 353 |
}
|
| 354 |
-
async function mfInstallExplorationPack(){
|
| 355 |
-
if(expPackBusy) return {ok:false,reason:'busy'};
|
| 356 |
-
if(typeof indexedDB==='undefined') return {ok:false,reason:'no-idb'};
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
if(!spec.manifest&&!spec.base) return {ok:false,reason:'no-endpoint'};
|
| 363 |
let manUrl=spec.manifest||expPackJoin(spec.base,'exploration-content-manifest-v1.json');
|
| 364 |
if(spec.downloadQuery&&manUrl.indexOf('?')<0) manUrl+=spec.downloadQuery;
|
| 365 |
-
const mr=await fetch(manUrl,{cache:'no-store'});
|
| 366 |
-
if(!mr.ok) return {ok:false,reason:'manifest'};
|
| 367 |
-
const man=await mr.json();
|
| 368 |
-
if(!man||man.kind!=='ExplorationContentManifestV1'||!Array.isArray(man.files)||!man.files.length)
|
| 369 |
-
return {ok:false,reason:'manifest'};
|
| 370 |
-
const
|
| 371 |
-
for(const entry of man.files){
|
| 372 |
-
const rel=String(entry.path||'').replace(/\\/g,'/');
|
| 373 |
-
if(!rel||rel.startsWith('/')||rel.includes('..')) return {ok:false,reason:'path'};
|
| 374 |
-
const
|
| 375 |
-
if(
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
}finally{
|
| 387 |
expPackBusy=false;
|
| 388 |
}
|
|
|
|
| 30 |
size; a pack that has not changed costs one small JSON request.
|
| 31 |
============================================================================ */
|
| 32 |
|
| 33 |
+
const PACK = {
|
| 34 |
+
/* idx/rawIndex describe what the server currently offers. activeIdx/rawActive
|
| 35 |
+
describe the last fully verified set the game is allowed to mount. Keeping
|
| 36 |
+
those identities separate is what lets a player keep using v1 while a v2
|
| 37 |
+
download is declined, interrupted, corrupt or too large for free storage. */
|
| 38 |
+
idx:null, rawIndex:null, activeIdx:Object.create(null), rawActive:{version:2,packs:{}},
|
| 39 |
+
have:{}, busy:false, got:0, total:0, state:'idle', err:'', storage:null
|
| 40 |
+
};
|
| 41 |
+
const PACK_DB = 'massfront-packs', PACK_STORE = 'files';
|
| 42 |
+
const PACK_CHUNK_STORE = 'chunks', PACK_META_STORE = 'meta', PACK_DB_VERSION = 2;
|
| 43 |
+
const PACK_PREF = 'massfront_pack_pref'; // 'auto' | 'ask' | 'off'
|
| 44 |
+
/* Two MiB keeps a killed phone from repeating a large transfer, but does not
|
| 45 |
+
turn IndexedDB into tens of thousands of tiny transactions. Publishers may
|
| 46 |
+
choose smaller chunks; anything larger than eight MiB is rejected so a bad
|
| 47 |
+
manifest cannot silently defeat the bounded-memory contract. A single file
|
| 48 |
+
is capped too: very large products must be sectioned into independent files
|
| 49 |
+
and packs instead of recreating the old monolithic download problem. */
|
| 50 |
+
const PACK_DEFAULT_CHUNK_BYTES = 2 * 1024 * 1024;
|
| 51 |
+
const PACK_MAX_CHUNK_BYTES = 8 * 1024 * 1024;
|
| 52 |
+
const PACK_MAX_FILE_BYTES = 256 * 1024 * 1024;
|
| 53 |
+
const PACK_MAX_PACKS = 512, PACK_MAX_FILES = 4096, PACK_MAX_CHUNKS_PER_FILE = 4096;
|
| 54 |
+
const PACK_STORAGE_MARGIN_BYTES = 16 * 1024 * 1024;
|
| 55 |
+
|
| 56 |
+
const PACK_SHA256_K = new Uint32Array([
|
| 57 |
+
0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
|
| 58 |
+
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
|
| 59 |
+
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
|
| 60 |
+
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
|
| 61 |
+
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
|
| 62 |
+
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
|
| 63 |
+
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
|
| 64 |
+
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2
|
| 65 |
+
]);
|
| 66 |
+
|
| 67 |
+
/* WebCrypto has no streaming digest. Reading a 200+ MiB GLB into one
|
| 68 |
+
ArrayBuffer merely to verify it would negate chunked delivery, so this small
|
| 69 |
+
incremental SHA-256 keeps verification bounded to one chunk at a time. */
|
| 70 |
+
class PackSha256State{
|
| 71 |
+
constructor(){
|
| 72 |
+
this.h = new Uint32Array([0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19]);
|
| 73 |
+
this.w = new Uint32Array(64);
|
| 74 |
+
this.buf = new Uint8Array(64);
|
| 75 |
+
this.bufLen = 0;
|
| 76 |
+
this.bytes = 0;
|
| 77 |
+
}
|
| 78 |
+
block(data, off){
|
| 79 |
+
const w=this.w;
|
| 80 |
+
for(let i=0;i<16;i++){
|
| 81 |
+
const p=off+i*4;
|
| 82 |
+
w[i]=((data[p]<<24)|(data[p+1]<<16)|(data[p+2]<<8)|data[p+3])>>>0;
|
| 83 |
+
}
|
| 84 |
+
for(let i=16;i<64;i++){
|
| 85 |
+
const x=w[i-15],y=w[i-2];
|
| 86 |
+
const s0=((x>>>7)|(x<<25))^((x>>>18)|(x<<14))^(x>>>3);
|
| 87 |
+
const s1=((y>>>17)|(y<<15))^((y>>>19)|(y<<13))^(y>>>10);
|
| 88 |
+
w[i]=(w[i-16]+s0+w[i-7]+s1)>>>0;
|
| 89 |
+
}
|
| 90 |
+
let a=this.h[0],b=this.h[1],c=this.h[2],d=this.h[3];
|
| 91 |
+
let e=this.h[4],f=this.h[5],g=this.h[6],h=this.h[7];
|
| 92 |
+
for(let i=0;i<64;i++){
|
| 93 |
+
const s1=((e>>>6)|(e<<26))^((e>>>11)|(e<<21))^((e>>>25)|(e<<7));
|
| 94 |
+
const ch=(e&f)^(~e&g);
|
| 95 |
+
const t1=(h+s1+ch+PACK_SHA256_K[i]+w[i])>>>0;
|
| 96 |
+
const s0=((a>>>2)|(a<<30))^((a>>>13)|(a<<19))^((a>>>22)|(a<<10));
|
| 97 |
+
const maj=(a&b)^(a&c)^(b&c);
|
| 98 |
+
const t2=(s0+maj)>>>0;
|
| 99 |
+
h=g;g=f;f=e;e=(d+t1)>>>0;d=c;c=b;b=a;a=(t1+t2)>>>0;
|
| 100 |
+
}
|
| 101 |
+
this.h[0]=(this.h[0]+a)>>>0;this.h[1]=(this.h[1]+b)>>>0;
|
| 102 |
+
this.h[2]=(this.h[2]+c)>>>0;this.h[3]=(this.h[3]+d)>>>0;
|
| 103 |
+
this.h[4]=(this.h[4]+e)>>>0;this.h[5]=(this.h[5]+f)>>>0;
|
| 104 |
+
this.h[6]=(this.h[6]+g)>>>0;this.h[7]=(this.h[7]+h)>>>0;
|
| 105 |
+
}
|
| 106 |
+
update(input){
|
| 107 |
+
const data=input instanceof Uint8Array?input:new Uint8Array(input);
|
| 108 |
+
this.bytes+=data.length;
|
| 109 |
+
let off=0;
|
| 110 |
+
if(this.bufLen){
|
| 111 |
+
const n=Math.min(64-this.bufLen,data.length);
|
| 112 |
+
this.buf.set(data.subarray(0,n),this.bufLen);this.bufLen+=n;off=n;
|
| 113 |
+
if(this.bufLen===64){this.block(this.buf,0);this.bufLen=0;}
|
| 114 |
+
}
|
| 115 |
+
while(off+64<=data.length){this.block(data,off);off+=64;}
|
| 116 |
+
if(off<data.length){this.buf.set(data.subarray(off),0);this.bufLen=data.length-off;}
|
| 117 |
+
return this;
|
| 118 |
+
}
|
| 119 |
+
hex(){
|
| 120 |
+
const bitHi=Math.floor(this.bytes/0x20000000),bitLo=(this.bytes*8)>>>0;
|
| 121 |
+
this.buf[this.bufLen++]=0x80;
|
| 122 |
+
if(this.bufLen>56){this.buf.fill(0,this.bufLen);this.block(this.buf,0);this.bufLen=0;}
|
| 123 |
+
this.buf.fill(0,this.bufLen,56);
|
| 124 |
+
this.buf[56]=(bitHi>>>24)&255;this.buf[57]=(bitHi>>>16)&255;
|
| 125 |
+
this.buf[58]=(bitHi>>>8)&255;this.buf[59]=bitHi&255;
|
| 126 |
+
this.buf[60]=(bitLo>>>24)&255;this.buf[61]=(bitLo>>>16)&255;
|
| 127 |
+
this.buf[62]=(bitLo>>>8)&255;this.buf[63]=bitLo&255;
|
| 128 |
+
this.block(this.buf,0);
|
| 129 |
+
return [...this.h].map(n=>n.toString(16).padStart(8,'0')).join('');
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
async function packHashBlob(blob, sliceBytes=PACK_DEFAULT_CHUNK_BYTES){
|
| 134 |
+
const hash=new PackSha256State();
|
| 135 |
+
for(let off=0;off<blob.size;off+=sliceBytes)
|
| 136 |
+
hash.update(new Uint8Array(await blob.slice(off,Math.min(blob.size,off+sliceBytes)).arrayBuffer()));
|
| 137 |
+
return hash.hex();
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
let packDbPromise=null;
|
| 141 |
+
function packDb(){
|
| 142 |
+
if(packDbPromise) return packDbPromise;
|
| 143 |
+
packDbPromise=new Promise((res, rej) => {
|
| 144 |
+
const r = indexedDB.open(PACK_DB, PACK_DB_VERSION);
|
| 145 |
+
r.onupgradeneeded = () => { const d = r.result;
|
| 146 |
+
if(!d.objectStoreNames.contains(PACK_STORE)) d.createObjectStore(PACK_STORE);
|
| 147 |
+
if(!d.objectStoreNames.contains(PACK_CHUNK_STORE)) d.createObjectStore(PACK_CHUNK_STORE);
|
| 148 |
+
if(!d.objectStoreNames.contains(PACK_META_STORE)) d.createObjectStore(PACK_META_STORE); };
|
| 149 |
+
r.onsuccess = () => {
|
| 150 |
+
r.result.onversionchange=()=>{r.result.close();packDbPromise=null;};
|
| 151 |
+
res(r.result);
|
| 152 |
+
};
|
| 153 |
+
r.onerror = () => {packDbPromise=null;rej(r.error);};
|
| 154 |
+
r.onblocked=()=>{packDbPromise=null;rej(new Error('Asset-pack storage upgrade is blocked by another tab'));};
|
| 155 |
+
});
|
| 156 |
+
return packDbPromise;
|
| 157 |
+
}
|
| 158 |
+
async function packStoreGet(store,k){
|
| 159 |
+
const db = await packDb();
|
| 160 |
+
return new Promise((res, rej) => {
|
| 161 |
+
const tx = db.transaction(store, 'readonly');
|
| 162 |
+
const q = tx.objectStore(store).get(k);
|
| 163 |
+
q.onsuccess = () => res(q.result); q.onerror = () => rej(q.error);
|
| 164 |
+
});
|
| 165 |
+
}
|
| 166 |
+
async function packStorePut(store,k,v){
|
| 167 |
+
const db = await packDb();
|
| 168 |
+
return new Promise((res, rej) => {
|
| 169 |
+
const tx = db.transaction(store, 'readwrite');
|
| 170 |
+
tx.objectStore(store).put(v, k);
|
| 171 |
+
tx.oncomplete = () => res(); tx.onerror = () => rej(tx.error);tx.onabort=()=>rej(tx.error);
|
| 172 |
+
});
|
| 173 |
+
}
|
| 174 |
+
async function packStoreDelete(store,k){
|
| 175 |
+
const db=await packDb();
|
| 176 |
+
return new Promise((res,rej)=>{
|
| 177 |
+
const tx=db.transaction(store,'readwrite');
|
| 178 |
+
tx.objectStore(store).delete(k);
|
| 179 |
+
tx.oncomplete=()=>res();tx.onerror=()=>rej(tx.error);tx.onabort=()=>rej(tx.error);
|
| 180 |
+
});
|
| 181 |
+
}
|
| 182 |
+
async function packKeys(store=PACK_STORE){
|
| 183 |
+
const db = await packDb();
|
| 184 |
+
return new Promise((res, rej) => {
|
| 185 |
+
const tx = db.transaction(store, 'readonly');
|
| 186 |
+
const q = tx.objectStore(store).getAllKeys();
|
| 187 |
+
q.onsuccess = () => res(q.result || []); q.onerror = () => rej(q.error);
|
| 188 |
+
});
|
| 189 |
+
}
|
| 190 |
+
async function packDeletePrefix(store,prefix){
|
| 191 |
+
const keys=(await packKeys(store)).filter(k=>String(k).startsWith(prefix));
|
| 192 |
+
if(!keys.length) return 0;
|
| 193 |
+
const db=await packDb();
|
| 194 |
+
await new Promise((res,rej)=>{
|
| 195 |
+
const tx=db.transaction(store,'readwrite'),target=tx.objectStore(store);
|
| 196 |
+
for(const k of keys) target.delete(k);
|
| 197 |
+
tx.oncomplete=()=>res();tx.onerror=()=>rej(tx.error);tx.onabort=()=>rej(tx.error);
|
| 198 |
+
});
|
| 199 |
+
return keys.length;
|
| 200 |
+
}
|
| 201 |
+
async function packDeleteKeys(store,keys){
|
| 202 |
+
if(!keys.length) return 0;
|
| 203 |
+
const db=await packDb();
|
| 204 |
+
await new Promise((res,rej)=>{
|
| 205 |
+
const tx=db.transaction(store,'readwrite'),target=tx.objectStore(store);
|
| 206 |
+
for(const key of keys) target.delete(key);
|
| 207 |
+
tx.oncomplete=()=>res();tx.onerror=()=>rej(tx.error);tx.onabort=()=>rej(tx.error);
|
| 208 |
+
});
|
| 209 |
+
return keys.length;
|
| 210 |
+
}
|
| 211 |
+
const packGet=k=>packStoreGet(PACK_STORE,k);
|
| 212 |
+
const packPut=(k,v)=>packStorePut(PACK_STORE,k,v);
|
| 213 |
|
| 214 |
/* The endpoint is the same one the updater resolves — one server, one setting,
|
| 215 |
configured in exactly one place. */
|
|
|
|
| 224 |
return n < 1048576 ? (n / 1024).toFixed(0) + ' KB' : (n / 1048576).toFixed(1) + ' MB';
|
| 225 |
}
|
| 226 |
|
| 227 |
+
const PACK_INDEX_META_KEY='index:active';
|
| 228 |
+
const PACK_OFFER_META_KEY='index:offered';
|
| 229 |
+
const PACK_ACTIVE_ROLE='active-v2';
|
| 230 |
+
function packIndexRecord(saved){return saved&&saved.index?saved.index:saved;}
|
| 231 |
+
async function packPersistOffer(raw){
|
| 232 |
+
await packStorePut(PACK_META_STORE,PACK_OFFER_META_KEY,{role:'offered-v2',index:raw,savedAt:Date.now()});
|
| 233 |
+
}
|
| 234 |
+
async function packPersistActive(raw){
|
| 235 |
+
const normalized=packNormalizeIndex(raw);
|
| 236 |
+
/* One IndexedDB record is the activation pointer. Payload Blobs may arrive
|
| 237 |
+
over many launches, but the game sees all of the new dependency set or
|
| 238 |
+
none of it because this transaction completes before the in-memory pointer
|
| 239 |
+
moves. */
|
| 240 |
+
await packStorePut(PACK_META_STORE,PACK_INDEX_META_KEY,{
|
| 241 |
+
role:PACK_ACTIVE_ROLE,index:raw,savedAt:Date.now()
|
| 242 |
+
});
|
| 243 |
+
PACK.rawActive=raw;PACK.activeIdx=normalized;
|
| 244 |
+
return normalized;
|
| 245 |
+
}
|
| 246 |
+
function packPackIdentity(pack){
|
| 247 |
+
if(!pack) return '';
|
| 248 |
+
return JSON.stringify({
|
| 249 |
+
format:pack.format,dependencies:pack.dependencies,
|
| 250 |
+
files:pack.files.map(file=>({
|
| 251 |
+
name:file.name,size:file.size,sha256:file.sha256,
|
| 252 |
+
chunks:file.chunks.map(chunk=>[chunk.offset,chunk.size,chunk.sha256])
|
| 253 |
+
}))
|
| 254 |
+
});
|
| 255 |
+
}
|
| 256 |
+
async function packOwnFilesReady(index,id){
|
| 257 |
+
const pack=index&&index[id];
|
| 258 |
+
if(!pack) return false;
|
| 259 |
+
for(const file of pack.files) if(!(await packFileReady(id,file))) return false;
|
| 260 |
+
return true;
|
| 261 |
+
}
|
| 262 |
+
async function packRecoverLegacyActive(raw,index){
|
| 263 |
+
const packs={},accepted=new Set(),order=[];
|
| 264 |
+
const seen=new Set();
|
| 265 |
+
function add(id){if(seen.has(id)) return;seen.add(id);for(const dep of index[id].dependencies)add(dep);order.push(id);}
|
| 266 |
+
for(const id of Object.keys(index)) add(id);
|
| 267 |
+
for(const id of order){
|
| 268 |
+
if(index[id].dependencies.some(dep=>!accepted.has(dep))) continue;
|
| 269 |
+
if(await packOwnFilesReady(index,id)){packs[id]=raw.packs[id];accepted.add(id);}
|
| 270 |
+
}
|
| 271 |
+
return {version:Number(raw.version)||2,packs};
|
| 272 |
+
}
|
| 273 |
+
async function packLoadCachedIndex(){
|
| 274 |
+
try{
|
| 275 |
+
const offeredSaved=await packStoreGet(PACK_META_STORE,PACK_OFFER_META_KEY);
|
| 276 |
+
const activeSaved=await packStoreGet(PACK_META_STORE,PACK_INDEX_META_KEY);
|
| 277 |
+
const taggedActive=!!(activeSaved&&activeSaved.role===PACK_ACTIVE_ROLE);
|
| 278 |
+
const legacyRaw=!taggedActive?packIndexRecord(activeSaved):null;
|
| 279 |
+
let offeredRaw=packIndexRecord(offeredSaved)||legacyRaw;
|
| 280 |
+
let activeRaw=taggedActive?packIndexRecord(activeSaved):null;
|
| 281 |
+
if(!offeredRaw&&activeRaw) offeredRaw=activeRaw;
|
| 282 |
+
if(!offeredRaw) return null;
|
| 283 |
+
const offered=packNormalizeIndex(offeredRaw);
|
| 284 |
+
if(!activeRaw){
|
| 285 |
+
/* v1 stored a server offer under the misleading `index:active` key.
|
| 286 |
+
Promote only entries whose final Blobs already verify; a merely cached
|
| 287 |
+
manifest must never become executable authority during migration. */
|
| 288 |
+
activeRaw=await packRecoverLegacyActive(offeredRaw,offered);
|
| 289 |
+
try{await packPersistOffer(offeredRaw);}catch(e){}
|
| 290 |
+
await packPersistActive(activeRaw);
|
| 291 |
+
}else{
|
| 292 |
+
PACK.rawActive=activeRaw;PACK.activeIdx=packNormalizeIndex(activeRaw);
|
| 293 |
+
}
|
| 294 |
+
PACK.idx=offered;PACK.rawIndex=offeredRaw;
|
| 295 |
+
return PACK.idx;
|
| 296 |
+
}catch(e){return null;}
|
| 297 |
+
}
|
| 298 |
+
async function packAdoptInstalledOffers(){
|
| 299 |
+
if(!PACK.idx||!PACK.rawIndex) return false;
|
| 300 |
+
const raw={...PACK.rawActive,packs:{...(PACK.rawActive&&PACK.rawActive.packs||{})}};
|
| 301 |
+
const activeNext={...PACK.activeIdx};
|
| 302 |
+
let changed=false;
|
| 303 |
+
const order=[],seen=new Set();
|
| 304 |
+
function add(id){if(seen.has(id))return;seen.add(id);for(const dep of PACK.idx[id].dependencies)add(dep);order.push(id);}
|
| 305 |
+
for(const id of Object.keys(PACK.idx)) add(id);
|
| 306 |
+
for(const id of order){
|
| 307 |
+
if(activeNext[id]) continue;
|
| 308 |
+
let depsReady=true;
|
| 309 |
+
for(const dep of PACK.idx[id].dependencies){
|
| 310 |
+
const active=activeNext[dep];
|
| 311 |
+
if(!active||packPackIdentity(active)!==packPackIdentity(PACK.idx[dep])){depsReady=false;break;}
|
| 312 |
+
}
|
| 313 |
+
if(!depsReady||!(await packOwnFilesReady(PACK.idx,id))) continue;
|
| 314 |
+
raw.packs[id]=PACK.rawIndex.packs[id];
|
| 315 |
+
/* Make this entry visible to later nodes in the same topological pass, but
|
| 316 |
+
do not move the live pointer before the IDB activation record commits. */
|
| 317 |
+
activeNext[id]=PACK.idx[id];
|
| 318 |
+
changed=true;
|
| 319 |
+
}
|
| 320 |
+
if(changed) await packPersistActive(raw);
|
| 321 |
+
return changed;
|
| 322 |
+
}
|
| 323 |
+
async function packLoadIndex(){
|
| 324 |
+
if(PACK.busy&&PACK.idx) return PACK.idx;
|
| 325 |
+
const cached=await packLoadCachedIndex();
|
| 326 |
+
if(typeof netAllowed==='function' && !netAllowed()) return cached;
|
| 327 |
+
const base = packEndpoint();
|
| 328 |
+
if(!base) return cached;
|
| 329 |
+
try{
|
| 330 |
+
const r = await fetch(base + '/packs.json?t=' + Date.now(), {cache:'no-store'});
|
| 331 |
+
if(!r.ok) return cached;
|
| 332 |
+
const j = await r.json();
|
| 333 |
+
let merged=j;
|
| 334 |
+
try{
|
| 335 |
+
const prior=PACK.rawIndex&&PACK.rawIndex.packs?PACK.rawIndex.packs:{};
|
| 336 |
+
const local=Object.fromEntries(Object.entries(prior).filter(([,pack])=>pack&&pack.localRegistration));
|
| 337 |
+
merged={...j,packs:{...local,...j.packs}}; // an official endpoint entry supersedes a local registration.
|
| 338 |
+
}catch(e){}
|
| 339 |
+
PACK.idx = packNormalizeIndex(merged);PACK.rawIndex=merged;
|
| 340 |
+
/* The offer is durable for interrupted downloads, but it is not the mount
|
| 341 |
+
pointer. packPersistActive() is the sole authority transition. */
|
| 342 |
+
try{await packPersistOffer(merged);}catch(e){}
|
| 343 |
+
try{await packAdoptInstalledOffers();}catch(e){}
|
| 344 |
+
return PACK.idx;
|
| 345 |
+
}catch(e){
|
| 346 |
+
if(cached) return cached;
|
| 347 |
+
PACK.err='Optional content manifest is invalid';return null;
|
| 348 |
+
}
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
function packValidId(id){return /^[a-z0-9][a-z0-9._-]{0,63}$/i.test(String(id||''));}
|
| 352 |
+
function packValidPath(name){
|
| 353 |
+
const p=String(name||'').replace(/\\/g,'/');
|
| 354 |
+
return p&&!p.startsWith('/')&&!p.split('/').some(s=>!s||s==='.'||s==='..')?p:'';
|
| 355 |
+
}
|
| 356 |
+
function packHash(value){
|
| 357 |
+
const h=String(value||'').toLowerCase();
|
| 358 |
+
return /^[a-f0-9]{64}$/.test(h)?h:'';
|
| 359 |
+
}
|
| 360 |
+
function packNormalizeIndex(index){
|
| 361 |
+
if(!index||typeof index!=='object'||!index.packs||typeof index.packs!=='object') throw new Error('packs');
|
| 362 |
+
if(Object.keys(index.packs).length>PACK_MAX_PACKS) throw new Error('packs');
|
| 363 |
+
const out=Object.create(null);
|
| 364 |
+
for(const [id,raw] of Object.entries(index.packs)){
|
| 365 |
+
if(!packValidId(id)||!raw||!Array.isArray(raw.files)||raw.files.length>PACK_MAX_FILES) throw new Error('pack');
|
| 366 |
+
const format=Math.max(1,Number(raw.format||raw.manifestVersion||1)|0);
|
| 367 |
+
const chunkSize=Number(raw.chunkSize||PACK_DEFAULT_CHUNK_BYTES);
|
| 368 |
+
if(!Number.isSafeInteger(chunkSize)||chunkSize<1||chunkSize>PACK_MAX_CHUNK_BYTES) throw new Error('chunk-size');
|
| 369 |
+
const dependencies=Array.isArray(raw.dependencies)?raw.dependencies.map(String):[];
|
| 370 |
+
if(new Set(dependencies).size!==dependencies.length||dependencies.some(dep=>!packValidId(dep))) throw new Error('dependencies');
|
| 371 |
+
const seen=new Set(),files=[];
|
| 372 |
+
let bytes=0;
|
| 373 |
+
for(const source of raw.files){
|
| 374 |
+
const name=packValidPath(source&&source.name);
|
| 375 |
+
const size=Number(source&&source.size);
|
| 376 |
+
const sha256=packHash(source&&source.sha256);
|
| 377 |
+
if(!name||seen.has(name)||!Number.isSafeInteger(size)||size<1||size>PACK_MAX_FILE_BYTES) throw new Error('file');
|
| 378 |
+
if(format>=2&&!sha256) throw new Error('whole-hash');
|
| 379 |
+
seen.add(name);bytes+=size;
|
| 380 |
+
let chunks=[];
|
| 381 |
+
if(Array.isArray(source.chunks)){
|
| 382 |
+
if(source.chunks.length>PACK_MAX_CHUNKS_PER_FILE) throw new Error('chunks');
|
| 383 |
+
let offset=0;
|
| 384 |
+
chunks=source.chunks.map((chunk,index)=>{
|
| 385 |
+
const at=Number(chunk&&chunk.offset),n=Number(chunk&&chunk.size),hash=packHash(chunk&&chunk.sha256);
|
| 386 |
+
if(!Number.isSafeInteger(at)||at!==offset||!Number.isSafeInteger(n)||n<1||n>PACK_MAX_CHUNK_BYTES||at+n>size||!hash)
|
| 387 |
+
throw new Error('chunk');
|
| 388 |
+
offset+=n;
|
| 389 |
+
return {index,offset:at,size:n,sha256:hash};
|
| 390 |
+
});
|
| 391 |
+
if(offset!==size) throw new Error('chunk-coverage');
|
| 392 |
+
}else{
|
| 393 |
+
if(format>=2) throw new Error('chunks');
|
| 394 |
+
if(Math.ceil(size/chunkSize)>PACK_MAX_CHUNKS_PER_FILE) throw new Error('chunks');
|
| 395 |
+
for(let offset=0,index=0;offset<size;offset+=chunkSize,index++)
|
| 396 |
+
chunks.push({index,offset,size:Math.min(chunkSize,size-offset),sha256:''});
|
| 397 |
+
}
|
| 398 |
+
files.push({...source,name,size,sha256,chunks});
|
| 399 |
+
}
|
| 400 |
+
if(raw.bytes!==undefined&&Number(raw.bytes)!==bytes) throw new Error('pack-bytes');
|
| 401 |
+
out[id]={...raw,id,format,chunkSize,bytes,files,dependencies};
|
| 402 |
+
}
|
| 403 |
+
/* Reject missing edges and cycles at the trust boundary. Install order can
|
| 404 |
+
then be a small deterministic DFS, and a malformed remote manifest can
|
| 405 |
+
never recurse forever or silently omit a prerequisite. */
|
| 406 |
+
const visiting=new Set(),visited=new Set();
|
| 407 |
+
function visit(id){
|
| 408 |
+
if(visited.has(id)) return;
|
| 409 |
+
if(visiting.has(id)) throw new Error('dependency-cycle');
|
| 410 |
+
visiting.add(id);
|
| 411 |
+
for(const dep of out[id].dependencies){if(!out[dep]) throw new Error('dependency-missing');visit(dep);}
|
| 412 |
+
visiting.delete(id);visited.add(id);
|
| 413 |
+
}
|
| 414 |
+
for(const id of Object.keys(out)) visit(id);
|
| 415 |
+
return out;
|
| 416 |
+
}
|
| 417 |
+
async function packRegisterPack(id,raw){
|
| 418 |
+
if(!packValidId(id)||!raw) throw new Error('pack');
|
| 419 |
+
const base=PACK.rawIndex&&PACK.rawIndex.packs?PACK.rawIndex:{version:2,packs:{}};
|
| 420 |
+
const merged={...base,packs:{...base.packs,[id]:{...raw,localRegistration:true}}};
|
| 421 |
+
const normalized=packNormalizeIndex(merged);
|
| 422 |
+
PACK.rawIndex=merged;PACK.idx=normalized;
|
| 423 |
+
await packPersistOffer(merged);
|
| 424 |
+
return normalized[id];
|
| 425 |
+
}
|
| 426 |
+
function packLegacyFileKey(pack,file){return pack+'/'+file.name+':'+file.size;}
|
| 427 |
+
function packFileKey(pack,file){
|
| 428 |
+
const legacy=packLegacyFileKey(pack,file);
|
| 429 |
+
return file.sha256?legacy+':sha256:'+file.sha256:legacy;
|
| 430 |
+
}
|
| 431 |
+
function packMetaKey(pack,file,storageKey=packFileKey(pack,file)){return 'file:'+storageKey;}
|
| 432 |
+
function packChunkPrefix(pack,file){return pack+'/'+file.name+':'+file.size+':'+(file.sha256||'legacy')+':chunk:';}
|
| 433 |
+
function packChunkKey(pack,file,chunk){return packChunkPrefix(pack,file)+chunk.index;}
|
| 434 |
+
function packStoredBlob(value){return value instanceof Blob?value:(value&&value.blob instanceof Blob?value.blob:null);}
|
| 435 |
+
async function packStoredFinal(pack,file){
|
| 436 |
+
const key=packFileKey(pack,file);
|
| 437 |
+
let blob=packStoredBlob(await packGet(key));
|
| 438 |
+
if(blob) return {key,blob};
|
| 439 |
+
const legacyKey=packLegacyFileKey(pack,file);
|
| 440 |
+
if(legacyKey!==key){
|
| 441 |
+
blob=packStoredBlob(await packGet(legacyKey));
|
| 442 |
+
if(blob) return {key:legacyKey,blob};
|
| 443 |
+
}
|
| 444 |
+
return {key,blob:null};
|
| 445 |
+
}
|
| 446 |
+
async function packFileReady(pack,file,{forceHash=false}={}){
|
| 447 |
+
const stored=await packStoredFinal(pack,file),blob=stored.blob;
|
| 448 |
+
if(!blob||blob.size!==file.size) return false;
|
| 449 |
+
if(!file.sha256) return true; // v1 manifests and their legacy Blobs remain playable.
|
| 450 |
+
const metaKey=packMetaKey(pack,file,stored.key);
|
| 451 |
+
const meta=await packStoreGet(PACK_META_STORE,metaKey);
|
| 452 |
+
if(!forceHash&&meta&&meta.sha256===file.sha256&&meta.size===file.size) return true;
|
| 453 |
+
const actual=await packHashBlob(blob,file.chunkSize||PACK_DEFAULT_CHUNK_BYTES);
|
| 454 |
+
if(actual!==file.sha256) return false;
|
| 455 |
+
await packStorePut(PACK_META_STORE,metaKey,{sha256:actual,size:file.size,verifiedAt:Date.now()});
|
| 456 |
+
return true;
|
| 457 |
+
}
|
| 458 |
+
async function packStoredChunk(pack,file,chunk){
|
| 459 |
+
const value=await packStoreGet(PACK_CHUNK_STORE,packChunkKey(pack,file,chunk));
|
| 460 |
+
const blob=packStoredBlob(value);
|
| 461 |
+
if(!blob||blob.size!==chunk.size) return null;
|
| 462 |
+
if(chunk.sha256){
|
| 463 |
+
/* IDB is durable, not an integrity oracle. A killed or externally damaged
|
| 464 |
+
record can retain its old metadata, so always re-hash persisted chunks
|
| 465 |
+
before resuming instead of trusting value.sha256. */
|
| 466 |
+
if(await packHashBlob(blob,chunk.size)!==chunk.sha256) return null;
|
| 467 |
+
}
|
| 468 |
+
return blob;
|
| 469 |
+
}
|
| 470 |
+
async function packResumeState(pack,file){
|
| 471 |
+
const present=new Map();let bytes=0;
|
| 472 |
+
for(const chunk of file.chunks){
|
| 473 |
+
const blob=await packStoredChunk(pack,file,chunk);
|
| 474 |
+
if(blob){present.set(chunk.index,blob);bytes+=chunk.size;}
|
| 475 |
+
}
|
| 476 |
+
return {present,bytes,remaining:file.size-bytes};
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
/* What is missing, including already-persisted partial chunks. Hashed finals
|
| 480 |
+
use content identities so a same-size replacement cannot overwrite the live
|
| 481 |
+
version before activation; packStoredFinal keeps the v1 name+size key as a
|
| 482 |
+
read-compatible fallback for existing voice/music installs. */
|
| 483 |
+
async function packMissing(pack,options={},index=PACK.idx){
|
| 484 |
+
if(!index || !index[pack]) return {files:[], bytes:0};
|
| 485 |
+
const out = [];
|
| 486 |
+
let bytes = 0,storedBytes=0;
|
| 487 |
+
for(const f of index[pack].files){
|
| 488 |
+
if(await packFileReady(pack,f,{forceHash:!!options.forceHash})){storedBytes+=f.size;continue;}
|
| 489 |
+
const resume=await packResumeState(pack,f);
|
| 490 |
+
out.push({...f,key:packFileKey(pack,f),remainingBytes:resume.remaining});
|
| 491 |
+
bytes+=resume.remaining;storedBytes+=resume.bytes;
|
| 492 |
+
}
|
| 493 |
+
return {files:out,bytes,storedBytes,totalBytes:index[pack].bytes};
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
function packFail(code,message){const e=new Error(message||code);e.code=code;throw e;}
|
| 497 |
+
function packFileUrl(base,pack,file,meta){
|
| 498 |
+
meta=meta||(PACK.idx&&PACK.idx[pack]);
|
| 499 |
+
if(meta&&meta.baseUrl){
|
| 500 |
+
const root=String(meta.baseUrl).replace(/\/?$/,'/');
|
| 501 |
+
const url=root+file.name.split('/').map(encodeURIComponent).join('/');
|
| 502 |
+
const query=String(meta.downloadQuery||'');
|
| 503 |
+
return url+(query&&url.indexOf('?')<0?(query.startsWith('?')?query:'?'+query):'');
|
| 504 |
+
}
|
| 505 |
+
return String(base||'').replace(/\/$/,'')+'/pack/'+encodeURIComponent(pack)+'/'
|
| 506 |
+
+file.name.split('/').map(encodeURIComponent).join('/');
|
| 507 |
+
}
|
| 508 |
+
async function packStoragePreflight(pack,missing){
|
| 509 |
+
const largest=missing.files.reduce((n,f)=>Math.max(n,f.size),0);
|
| 510 |
+
const margin=missing.bytes?Math.max(PACK_STORAGE_MARGIN_BYTES,Math.ceil(missing.bytes*.08)):0;
|
| 511 |
+
const required=missing.bytes+largest+margin;
|
| 512 |
+
const storage=typeof navigator!=='undefined'&&navigator.storage;
|
| 513 |
+
if(!storage||typeof storage.estimate!=='function')
|
| 514 |
+
return {ok:true,supported:false,pack,required,available:null,quota:null,usage:null};
|
| 515 |
+
try{
|
| 516 |
+
const estimate=await storage.estimate();
|
| 517 |
+
const quota=Number(estimate.quota),usage=Number(estimate.usage||0);
|
| 518 |
+
const available=Number.isFinite(quota)?Math.max(0,quota-usage):null;
|
| 519 |
+
return {ok:available===null||available>=required,supported:true,pack,required,available,quota,usage};
|
| 520 |
+
}catch(e){return {ok:true,supported:false,pack,required,available:null,quota:null,usage:null};}
|
| 521 |
+
}
|
| 522 |
+
async function packClearChunks(pack,file){await packDeletePrefix(PACK_CHUNK_STORE,packChunkPrefix(pack,file));}
|
| 523 |
+
function packDropURL(pack,name){
|
| 524 |
+
const key=pack+'/'+name,entry=packURLs[key],url=entry&&entry.url||entry;
|
| 525 |
+
if(url&&typeof URL.revokeObjectURL==='function') try{URL.revokeObjectURL(url);}catch(e){}
|
| 526 |
+
delete packURLs[key];
|
| 527 |
+
}
|
| 528 |
+
async function packCommitFullResponse(pack,file,response,onBytes,resume){
|
| 529 |
+
/* A 200 response means the origin ignored Range. Consume its body as a
|
| 530 |
+
stream and commit only complete manifest chunks; response.blob() would
|
| 531 |
+
recreate the exact monolithic allocation this subsystem exists to avoid.
|
| 532 |
+
Completed chunks survive a short response or killed tab and are reused on
|
| 533 |
+
the next attempt. Browsers without response streaming fail closed here;
|
| 534 |
+
the origin must honor Range for them. */
|
| 535 |
+
const reader=response.body&&typeof response.body.getReader==='function'?response.body.getReader():null;
|
| 536 |
+
if(!reader) packFail('range-stream','Range was ignored and bounded response streaming is unavailable');
|
| 537 |
+
const whole=new PackSha256State();
|
| 538 |
+
let total=0,index=0,chunk=file.chunks[0],filled=0;
|
| 539 |
+
let buffer=chunk?new Uint8Array(chunk.size):null;
|
| 540 |
+
try{
|
| 541 |
+
while(true){
|
| 542 |
+
const read=await reader.read();
|
| 543 |
+
if(read.done) break;
|
| 544 |
+
const data=read.value instanceof Uint8Array?read.value:new Uint8Array(read.value||0);
|
| 545 |
+
if(total+data.length>file.size) packFail('range-ignored-size','Ignored Range response exceeds manifest size');
|
| 546 |
+
whole.update(data);total+=data.length;
|
| 547 |
+
let at=0;
|
| 548 |
+
while(at<data.length){
|
| 549 |
+
if(!chunk) packFail('range-ignored-size','Ignored Range response exceeds manifest chunks');
|
| 550 |
+
const n=Math.min(chunk.size-filled,data.length-at);
|
| 551 |
+
buffer.set(data.subarray(at,at+n),filled);filled+=n;at+=n;
|
| 552 |
+
if(filled===chunk.size){
|
| 553 |
+
const actual=new PackSha256State().update(buffer).hex();
|
| 554 |
+
if(chunk.sha256&&actual!==chunk.sha256) packFail('chunk-hash','Full response chunk hash failed');
|
| 555 |
+
const reusable=!!chunk.sha256&&resume.present.has(chunk.index);
|
| 556 |
+
if(!reusable){
|
| 557 |
+
await packStorePut(PACK_CHUNK_STORE,packChunkKey(pack,file,chunk),{
|
| 558 |
+
blob:new Blob([buffer],{type:file.type||'application/octet-stream'}),
|
| 559 |
+
size:chunk.size,sha256:chunk.sha256||actual
|
| 560 |
+
});
|
| 561 |
+
if(onBytes&&!resume.present.has(chunk.index)) onBytes(chunk.size);
|
| 562 |
+
}
|
| 563 |
+
chunk=file.chunks[++index];filled=0;
|
| 564 |
+
buffer=chunk?new Uint8Array(chunk.size):null;
|
| 565 |
+
}
|
| 566 |
+
}
|
| 567 |
+
}
|
| 568 |
+
}catch(e){try{await reader.cancel();}catch(ignore){}throw e;}
|
| 569 |
+
if(total!==file.size||index!==file.chunks.length||filled)
|
| 570 |
+
packFail('range-ignored-size','Ignored Range response ended before manifest size');
|
| 571 |
+
const actual=whole.hex();
|
| 572 |
+
if(file.sha256&&actual!==file.sha256){
|
| 573 |
+
/* Synthetic legacy chunks carry no individual hashes. Keeping them after
|
| 574 |
+
a whole-file failure poisons every retry because size alone would make
|
| 575 |
+
the bad records look resumable. Clear the identity as one unit. */
|
| 576 |
+
await packClearChunks(pack,file);
|
| 577 |
+
packFail('file-hash','Whole-file hash failed');
|
| 578 |
+
}
|
| 579 |
+
await packAssembleFile(pack,file);
|
| 580 |
+
}
|
| 581 |
+
async function packFetchChunk(base,pack,file,chunk,packMeta){
|
| 582 |
+
const end=chunk.offset+chunk.size-1;
|
| 583 |
+
const response=await fetch(packFileUrl(base,pack,file,packMeta),{
|
| 584 |
+
cache:'no-store',headers:{Range:'bytes='+chunk.offset+'-'+end}
|
| 585 |
+
});
|
| 586 |
+
if(!response.ok) packFail('http','HTTP '+response.status);
|
| 587 |
+
if(response.status===200){
|
| 588 |
+
/* A CDN may legally ignore Range. Treat that as one explicit full-file
|
| 589 |
+
fallback, never as the requested chunk, or each loop iteration would
|
| 590 |
+
download and append the entire object again. */
|
| 591 |
+
return {fullResponse:response};
|
| 592 |
+
}
|
| 593 |
+
if(response.status!==206) packFail('range-status','Unexpected Range response');
|
| 594 |
+
const blob=await response.blob();
|
| 595 |
+
const contentRange=response.headers&&response.headers.get&&response.headers.get('content-range');
|
| 596 |
+
const match=/^bytes\s+(\d+)-(\d+)\/(\d+)$/i.exec(String(contentRange||''));
|
| 597 |
+
if(!match||Number(match[1])!==chunk.offset||Number(match[2])!==end||Number(match[3])!==file.size)
|
| 598 |
+
packFail('content-range','Content-Range does not match request');
|
| 599 |
+
if(blob.size!==chunk.size) packFail('chunk-size','Chunk size does not match manifest');
|
| 600 |
+
const hash=await packHashBlob(blob,chunk.size);
|
| 601 |
+
if(chunk.sha256&&hash!==chunk.sha256) packFail('chunk-hash','Chunk hash failed');
|
| 602 |
+
return {chunk:blob,sha256:hash};
|
| 603 |
+
}
|
| 604 |
+
async function packAssembleFile(pack,file){
|
| 605 |
+
const parts=[],hash=new PackSha256State();let size=0;
|
| 606 |
+
for(const chunk of file.chunks){
|
| 607 |
+
const blob=await packStoredChunk(pack,file,chunk);
|
| 608 |
+
if(!blob) packFail('chunk-missing','Verified chunk disappeared before assembly');
|
| 609 |
+
const bytes=new Uint8Array(await blob.arrayBuffer());
|
| 610 |
+
hash.update(bytes);parts.push(blob);size+=blob.size;
|
| 611 |
+
}
|
| 612 |
+
if(size!==file.size) packFail('size','Assembled size does not match manifest');
|
| 613 |
+
const actual=hash.hex();
|
| 614 |
+
if(file.sha256&&actual!==file.sha256){
|
| 615 |
+
await packClearChunks(pack,file);
|
| 616 |
+
packFail('file-hash','Whole-file hash failed');
|
| 617 |
+
}
|
| 618 |
+
const blob=new Blob(parts,{type:file.type||'application/octet-stream'});
|
| 619 |
+
packDropURL(pack,file.name);
|
| 620 |
+
await packPut(packFileKey(pack,file),blob);
|
| 621 |
+
if(file.sha256) await packStorePut(PACK_META_STORE,packMetaKey(pack,file),{sha256:actual,size:file.size,verifiedAt:Date.now()});
|
| 622 |
+
await packClearChunks(pack,file);
|
| 623 |
+
}
|
| 624 |
+
async function packDownloadFile(base,pack,file,onBytes,packMeta){
|
| 625 |
+
/* Keep the prior final Blob until the replacement has passed every chunk and
|
| 626 |
+
whole-file hash. packPut() is the atomic identity swap; deleting first made
|
| 627 |
+
an interrupted Repair destroy the last-known-good offline copy. */
|
| 628 |
+
const resume=await packResumeState(pack,file);
|
| 629 |
+
for(const chunk of file.chunks){
|
| 630 |
+
if(resume.present.has(chunk.index)) continue;
|
| 631 |
+
const received=await packFetchChunk(base,pack,file,chunk,packMeta);
|
| 632 |
+
if(received.fullResponse){
|
| 633 |
+
await packCommitFullResponse(pack,file,received.fullResponse,onBytes,resume);
|
| 634 |
+
return;
|
| 635 |
+
}
|
| 636 |
+
await packStorePut(PACK_CHUNK_STORE,packChunkKey(pack,file,chunk),{
|
| 637 |
+
blob:received.chunk,size:chunk.size,sha256:chunk.sha256||received.sha256
|
| 638 |
+
});
|
| 639 |
+
if(onBytes) onBytes(chunk.size);
|
| 640 |
+
}
|
| 641 |
+
await packAssembleFile(pack,file);
|
| 642 |
+
}
|
| 643 |
+
function packDependencyOrder(pack,index=PACK.idx){
|
| 644 |
+
const order=[],seen=new Set();
|
| 645 |
+
function add(id){if(seen.has(id)) return;seen.add(id);for(const dep of index[id].dependencies) add(dep);order.push(id);}
|
| 646 |
+
add(pack);return order;
|
| 647 |
+
}
|
| 648 |
+
async function packPlan(pack,{forceHash=false,index=PACK.idx}={}){
|
| 649 |
+
const order=packDependencyOrder(pack,index),byPack=[],files=[];
|
| 650 |
+
let bytes=0,storedBytes=0;
|
| 651 |
+
for(const id of order){
|
| 652 |
+
const missing=await packMissing(id,{forceHash},index);
|
| 653 |
+
byPack.push({pack:id,meta:index[id],missing});bytes+=missing.bytes;storedBytes+=missing.storedBytes;
|
| 654 |
+
for(const file of missing.files) files.push(file);
|
| 655 |
+
}
|
| 656 |
+
return {order,byPack,files,bytes,storedBytes};
|
| 657 |
+
}
|
| 658 |
+
async function packGcPromotedPack(pack){
|
| 659 |
+
const active=PACK.activeIdx&&PACK.activeIdx[pack];
|
| 660 |
+
if(!active) return {files:0,chunks:0,metadata:0};
|
| 661 |
+
const filePrefix=pack+'/',metaPrefix='file:'+filePrefix;
|
| 662 |
+
const keepFiles=new Set(),keepMeta=new Set();
|
| 663 |
+
for(const file of active.files){
|
| 664 |
+
const stored=await packStoredFinal(pack,file);
|
| 665 |
+
if(stored.blob){keepFiles.add(stored.key);keepMeta.add(packMetaKey(pack,file,stored.key));}
|
| 666 |
+
}
|
| 667 |
+
const staleFiles=(await packKeys(PACK_STORE)).filter(key=>{
|
| 668 |
+
const value=String(key);return value.startsWith(filePrefix)&&!keepFiles.has(value);
|
| 669 |
+
});
|
| 670 |
+
const staleMeta=(await packKeys(PACK_META_STORE)).filter(key=>{
|
| 671 |
+
const value=String(key);return value.startsWith(metaPrefix)&&!keepMeta.has(value);
|
| 672 |
+
});
|
| 673 |
+
/* Promotion proves every active file has a verified final Blob. No chunk is
|
| 674 |
+
still needed by that identity, and any older hash/size identity is now
|
| 675 |
+
unreachable. Clearing only this pack namespace preserves dependencies and
|
| 676 |
+
every independently installed optional pack. */
|
| 677 |
+
const staleChunks=(await packKeys(PACK_CHUNK_STORE)).filter(key=>String(key).startsWith(filePrefix));
|
| 678 |
+
return {
|
| 679 |
+
files:await packDeleteKeys(PACK_STORE,staleFiles),
|
| 680 |
+
chunks:await packDeleteKeys(PACK_CHUNK_STORE,staleChunks),
|
| 681 |
+
metadata:await packDeleteKeys(PACK_META_STORE,staleMeta)
|
| 682 |
+
};
|
| 683 |
+
}
|
| 684 |
+
async function packPromotePlan(plan,index,rawIndex){
|
| 685 |
+
for(const id of plan.order){
|
| 686 |
+
const remaining=await packMissing(id,{},index);
|
| 687 |
+
if(remaining.files.length) packFail('verify','Pack cannot activate before every file verifies');
|
| 688 |
+
}
|
| 689 |
+
const packs={...(PACK.rawActive&&PACK.rawActive.packs||{})};
|
| 690 |
+
for(const id of plan.order){
|
| 691 |
+
if(!rawIndex||!rawIndex.packs||!rawIndex.packs[id]) packFail('manifest','Pack offer changed before activation');
|
| 692 |
+
packs[id]=rawIndex.packs[id];
|
| 693 |
+
}
|
| 694 |
+
const next={...(PACK.rawActive||{}),version:Number(rawIndex&&rawIndex.version)||2,packs};
|
| 695 |
+
await packPersistActive(next);
|
| 696 |
+
const gc={};
|
| 697 |
+
for(const id of plan.order){
|
| 698 |
+
try{gc[id]=await packGcPromotedPack(id);}
|
| 699 |
+
catch(e){gc[id]={error:e&&e.message||String(e)};}
|
| 700 |
+
}
|
| 701 |
+
return gc;
|
| 702 |
+
}
|
| 703 |
+
function packJournalKey(pack){return 'journal:'+pack;}
|
| 704 |
+
async function packInstallPack(pack,options={}){
|
| 705 |
+
if(!packValidId(pack)) return {ok:false,reason:'pack-id'};
|
| 706 |
+
if(PACK.busy) return {ok:false,reason:'busy'};
|
| 707 |
+
PACK.busy=true;PACK.state='downloading';PACK.err='';
|
| 708 |
+
try{
|
| 709 |
+
/* Own the operation before the first await. Otherwise two callers that
|
| 710 |
+
both need packs.json can pass the busy check and begin parallel writes. */
|
| 711 |
+
const idx=PACK.idx||await packLoadIndex();
|
| 712 |
+
if(!idx||!idx[pack]){PACK.state='error';PACK.err='Optional content manifest is unavailable';return {ok:false,reason:'manifest'};}
|
| 713 |
+
const offerIndex=idx,offerRaw=PACK.rawIndex;
|
| 714 |
+
const plan=await packPlan(pack,{forceHash:!!options.repair,index:offerIndex});
|
| 715 |
+
if(!plan.files.length){
|
| 716 |
+
const gc=await packPromotePlan(plan,offerIndex,offerRaw);
|
| 717 |
+
PACK.state='ready';return {ok:true,pack,packs:plan.order,installed:true,downloaded:0,gc};
|
| 718 |
+
}
|
| 719 |
+
if(typeof netAllowed==='function'&&!netAllowed()){
|
| 720 |
+
PACK.state='error';PACK.err='Optional content is unavailable while offline';
|
| 721 |
+
return {ok:false,reason:'offline'};
|
| 722 |
+
}
|
| 723 |
+
const storage=await packStoragePreflight(pack,plan);PACK.storage=storage;
|
| 724 |
+
if(!storage.ok){
|
| 725 |
+
PACK.state='error';PACK.err='Not enough storage for this optional pack';
|
| 726 |
+
return {ok:false,reason:'storage',storage};
|
| 727 |
+
}
|
| 728 |
+
const base=packEndpoint();
|
| 729 |
+
if(!base&&plan.byPack.some(item=>item.missing.files.length&&!offerIndex[item.pack].baseUrl)){
|
| 730 |
+
PACK.state='error';PACK.err='Optional content server is unavailable';return {ok:false,reason:'endpoint'};
|
| 731 |
+
}
|
| 732 |
+
PACK.total=plan.bytes;PACK.got=0;
|
| 733 |
+
await packStorePut(PACK_META_STORE,packJournalKey(pack),{
|
| 734 |
+
state:'installing',pack,dependencies:plan.order,bytes:plan.bytes,startedAt:Date.now()
|
| 735 |
+
});
|
| 736 |
+
for(const item of plan.byPack){
|
| 737 |
+
for(const file of item.missing.files){
|
| 738 |
+
await packDownloadFile(base,item.pack,file,n=>{
|
| 739 |
+
PACK.got+=n;
|
| 740 |
+
if(options.onProgress) options.onProgress(PACK.got,PACK.total,{pack:item.pack,file:file.name});
|
| 741 |
+
if(options.ui) packRenderBar();
|
| 742 |
+
},item.meta);
|
| 743 |
+
await packStorePut(PACK_META_STORE,packJournalKey(pack),{
|
| 744 |
+
state:'installing',pack,dependencies:plan.order,bytes:plan.bytes,downloaded:PACK.got,
|
| 745 |
+
completedPack:item.pack,completedFile:file.name,updatedAt:Date.now()
|
| 746 |
+
});
|
| 747 |
+
}
|
| 748 |
+
if((await packMissing(item.pack,{},offerIndex)).files.length) packFail('verify','Pack remained incomplete after install');
|
| 749 |
+
}
|
| 750 |
+
const gc=await packPromotePlan(plan,offerIndex,offerRaw);
|
| 751 |
+
await packStorePut(PACK_META_STORE,packJournalKey(pack),{
|
| 752 |
+
state:'ready',pack,dependencies:plan.order,bytes:plan.bytes,completedAt:Date.now()
|
| 753 |
+
});
|
| 754 |
+
PACK.state='ready';
|
| 755 |
+
return {ok:true,pack,packs:plan.order,installed:true,downloaded:plan.bytes,storage,gc};
|
| 756 |
+
}catch(e){
|
| 757 |
+
PACK.state='error';PACK.err='Download failed — the game runs without it';
|
| 758 |
+
try{await packStorePut(PACK_META_STORE,packJournalKey(pack),{
|
| 759 |
+
state:'failed',pack,reason:e&&e.code||'download',updatedAt:Date.now()
|
| 760 |
+
});}catch(ignore){}
|
| 761 |
+
return {ok:false,reason:e&&e.code||'download',message:e&&e.message||String(e)};
|
| 762 |
+
}finally{
|
| 763 |
+
PACK.busy=false;
|
| 764 |
+
if(options.ui) packRenderBar();
|
| 765 |
+
}
|
| 766 |
+
}
|
| 767 |
+
async function packDownload(pack,onProgress){
|
| 768 |
+
const result=await packInstallPack(pack,{onProgress,ui:true});
|
| 769 |
+
return !!result.ok;
|
| 770 |
+
}
|
| 771 |
|
| 772 |
/* Hand back a playable URL for a pack file, or null if it is not stored. The
|
| 773 |
object URL is cached per file — creating a new one on every play would leak a
|
| 774 |
blob reference for the lifetime of the document. */
|
| 775 |
const packURLs = {};
|
| 776 |
+
async function packURL(pack, name){
|
| 777 |
+
const k = pack + '/' + name;
|
| 778 |
+
if(!PACK.idx) await packLoadIndex();
|
| 779 |
+
const active=PACK.activeIdx&&PACK.activeIdx[pack];
|
| 780 |
+
if(!active) return null;
|
| 781 |
+
const meta = active.files.find(f => f.name === name);
|
| 782 |
+
if(!meta) return null;
|
| 783 |
+
const identity=packFileKey(pack,meta)+':'+(meta.sha256||'legacy');
|
| 784 |
+
if(packURLs[k]&&packURLs[k].identity===identity) return packURLs[k].url;
|
| 785 |
+
if(packURLs[k]) packDropURL(pack,name);
|
| 786 |
+
if(!(await packFileReady(pack,meta))) return null;
|
| 787 |
+
const blob = (await packStoredFinal(pack,meta)).blob;
|
| 788 |
+
if(!blob) return null;
|
| 789 |
+
const url=URL.createObjectURL(blob);
|
| 790 |
+
packURLs[k]={identity,url};return url;
|
| 791 |
+
}
|
| 792 |
+
|
| 793 |
+
async function packList(){
|
| 794 |
+
const idx=PACK.idx||await packLoadIndex();
|
| 795 |
+
const ids=new Set([...Object.keys(PACK.activeIdx||{}),...Object.keys(idx||{})]);
|
| 796 |
+
return [...ids].map(id=>{
|
| 797 |
+
const offer=idx&&idx[id],active=PACK.activeIdx&&PACK.activeIdx[id],pack=offer||active;
|
| 798 |
+
return {
|
| 799 |
+
id,label:String(pack.label||id),bytes:pack.bytes,files:pack.files.length,
|
| 800 |
+
installed:!!active,updateAvailable:!!(active&&offer&&packPackIdentity(active)!==packPackIdentity(offer)),
|
| 801 |
+
dependencies:Array.isArray(pack.dependencies)?[...pack.dependencies]:[]
|
| 802 |
+
};
|
| 803 |
+
});
|
| 804 |
+
}
|
| 805 |
+
async function packStatus(pack,options={}){
|
| 806 |
+
const idx=PACK.idx||await packLoadIndex();
|
| 807 |
+
if(!idx||!idx[pack]) return {ok:false,reason:'manifest',pack};
|
| 808 |
+
const plan=await packPlan(pack,{forceHash:!!options.verify,index:idx});
|
| 809 |
+
const missing=plan.byPack.find(item=>item.pack===pack).missing;
|
| 810 |
+
const active=PACK.activeIdx&&PACK.activeIdx[pack];
|
| 811 |
+
const activePlan=active?await packPlan(pack,{forceHash:!!options.verify,index:PACK.activeIdx}):null;
|
| 812 |
+
const installed=!!(activePlan&&!activePlan.files.length);
|
| 813 |
+
return {
|
| 814 |
+
ok:true,pack,installed,partial:plan.storedBytes>0&&plan.files.length>0,
|
| 815 |
+
updateAvailable:!!(active&&packPackIdentity(active)!==packPackIdentity(idx[pack])),
|
| 816 |
+
bytes:idx[pack].bytes,storedBytes:missing.storedBytes,remainingBytes:missing.bytes,
|
| 817 |
+
missingFiles:missing.files.map(f=>f.name),dependencies:plan.order.filter(id=>id!==pack),
|
| 818 |
+
missingDependencies:plan.byPack.filter(item=>item.pack!==pack&&item.missing.files.length).map(item=>item.pack)
|
| 819 |
+
};
|
| 820 |
+
}
|
| 821 |
+
async function packPreflight(pack){
|
| 822 |
+
const idx=PACK.idx||await packLoadIndex();
|
| 823 |
+
if(!idx||!idx[pack]) return {ok:false,reason:'manifest',pack};
|
| 824 |
+
return packStoragePreflight(pack,await packPlan(pack));
|
| 825 |
+
}
|
| 826 |
+
async function packRemove(pack,options={}){
|
| 827 |
+
if(!packValidId(pack)) return {ok:false,reason:'pack-id'};
|
| 828 |
+
if(PACK.busy) return {ok:false,reason:'busy'};
|
| 829 |
+
PACK.busy=true;
|
| 830 |
+
try{
|
| 831 |
+
const idx=PACK.idx||await packLoadIndex();
|
| 832 |
+
const active=PACK.activeIdx||Object.create(null),dependents=[];
|
| 833 |
+
if(active[pack]){
|
| 834 |
+
for(const id of Object.keys(active)){
|
| 835 |
+
if(id!==pack&&packDependencyOrder(id,active).includes(pack)) dependents.push(id);
|
| 836 |
+
}
|
| 837 |
+
if(dependents.length&&!options.force) return {ok:false,reason:'dependency',pack,dependents};
|
| 838 |
+
const deactivate=new Set([pack,...(options.force?dependents:[])]);
|
| 839 |
+
const packs=Object.fromEntries(Object.entries(PACK.rawActive.packs||{}).filter(([id])=>!deactivate.has(id)));
|
| 840 |
+
await packPersistActive({...PACK.rawActive,packs});
|
| 841 |
+
}
|
| 842 |
+
const files=await packDeletePrefix(PACK_STORE,pack+'/');
|
| 843 |
+
const chunks=await packDeletePrefix(PACK_CHUNK_STORE,pack+'/');
|
| 844 |
+
const metadata=await packDeletePrefix(PACK_META_STORE,'file:'+pack+'/');
|
| 845 |
+
await packStoreDelete(PACK_META_STORE,packJournalKey(pack));
|
| 846 |
+
for(const key of Object.keys(packURLs)) if(key.startsWith(pack+'/')) packDropURL(pack,key.slice(pack.length+1));
|
| 847 |
+
return {ok:true,pack,removed:true,files,chunks,metadata};
|
| 848 |
+
}catch(e){return {ok:false,reason:'storage',message:e&&e.message||String(e)};}
|
| 849 |
+
finally{PACK.busy=false;}
|
| 850 |
+
}
|
| 851 |
+
|
| 852 |
+
/* Public, pack-ID-neutral control surface. Audio keeps using packURL() and the
|
| 853 |
+
legacy start-screen prompt; launcher/settings code can install, verify,
|
| 854 |
+
repair or remove any manifest pack without learning its IndexedDB layout. */
|
| 855 |
+
const MASSFRONT_ASSET_PACKS=Object.freeze({
|
| 856 |
+
loadIndex:packLoadIndex,
|
| 857 |
+
list:packList,
|
| 858 |
+
status:packStatus,
|
| 859 |
+
preflight:packPreflight,
|
| 860 |
+
install:(pack,options)=>packInstallPack(pack,options||{}),
|
| 861 |
+
repair:(pack,options)=>packInstallPack(pack,{...(options||{}),repair:true}),
|
| 862 |
+
remove:(pack,options)=>packRemove(pack,options||{}),
|
| 863 |
+
url:packURL
|
| 864 |
+
});
|
| 865 |
+
if(typeof window!=='undefined') window.MASSFRONT_ASSET_PACKS=MASSFRONT_ASSET_PACKS;
|
| 866 |
|
| 867 |
/* ---- UI -------------------------------------------------------------------
|
| 868 |
A single line on the start screen, in the same register as the updater panel:
|
|
|
|
| 958 |
if(!want.length){ PACK.state = 'idle'; packRenderBar(); return; }
|
| 959 |
let bytes = 0;
|
| 960 |
const need = [];
|
| 961 |
+
for(const p of want){
|
| 962 |
+
const m = await packMissing(p);
|
| 963 |
+
if(m.files.length){ need.push(p); bytes += m.bytes; }
|
| 964 |
+
}
|
| 965 |
+
let activeOne=false;
|
| 966 |
+
for(const p of want){
|
| 967 |
+
if(PACK.activeIdx[p]&&!(await packPlan(p,{index:PACK.activeIdx})).files.length){activeOne=true;break;}
|
| 968 |
+
}
|
| 969 |
+
if(activeOne&&typeof audAttachPack==='function') audAttachPack();
|
| 970 |
+
PACK.total = bytes;
|
| 971 |
+
if(!need.length){
|
| 972 |
+
PACK.state = 'ready'; packRenderBar();
|
| 973 |
+
return;
|
| 974 |
}
|
| 975 |
let pref = 'ask';
|
| 976 |
try{ pref = localStorage.getItem(PACK_PREF) || 'ask'; }catch(e){}
|
|
|
|
| 988 |
IndexedDB and never hear a note of it, permanently, with no control in
|
| 989 |
the UI that would fix it. Re-derived from storage rather than from the
|
| 990 |
download results so a pack completed on an EARLIER launch also counts. */
|
| 991 |
+
let haveOne = false;
|
| 992 |
+
for(const p of want){
|
| 993 |
+
if(PACK.activeIdx[p]&&!(await packPlan(p,{index:PACK.activeIdx})).files.length){haveOne=true;break;}
|
| 994 |
+
}
|
| 995 |
if(haveOne && typeof audAttachPack === 'function') audAttachPack();
|
| 996 |
/* A later pack succeeding must not bury an earlier one's failure: PACK.state
|
| 997 |
is what the panel reads, and 'ready' hides the panel outright — so voice
|
|
|
|
| 1012 |
|
| 1013 |
/* Galactic Exploration ships in player APK/www from the signed runtime pack.
|
| 1014 |
Older installs (notably 1.33.51) omitted it. When the HEAD probe misses, this
|
| 1015 |
+
fetches the remote stub and signed manifest, then registers that manifest
|
| 1016 |
+
with the same bounded pack engine used by audio. The former dedicated DB is
|
| 1017 |
+
read only for verified migration. A blob: URL for index.html was considered
|
| 1018 |
+
for navigation and rejected: the Galactic entry
|
| 1019 |
ticket lives in this document's sessionStorage, and a blob: document is a
|
| 1020 |
different origin, so the ticket and the return bridge would vanish. Cache
|
| 1021 |
here; mfOpenExploration keeps the War Room fallback. Opening still needs the
|
| 1022 |
files on the same origin (packaged www), which is why player packs include
|
| 1023 |
the module instead of relying on this download alone. */
|
| 1024 |
+
const EXP_PACK_DB='massfront-exploration-pack', EXP_PACK_STORE='files';
|
| 1025 |
+
const EXP_PACK_ID='galactic-exploration';
|
| 1026 |
+
const EXP_PACK_STUB='assets/data/exploration-pack-remote.json';
|
| 1027 |
+
let expPackBusy=false,expPackDbPromise=null;
|
| 1028 |
+
|
| 1029 |
+
function expPackDb(){
|
| 1030 |
+
if(expPackDbPromise) return expPackDbPromise;
|
| 1031 |
+
expPackDbPromise=new Promise((res, rej) => {
|
| 1032 |
+
const r = indexedDB.open(EXP_PACK_DB, 1);
|
| 1033 |
+
r.onupgradeneeded = () => { const d = r.result;
|
| 1034 |
+
if(!d.objectStoreNames.contains(EXP_PACK_STORE)) d.createObjectStore(EXP_PACK_STORE); };
|
| 1035 |
+
r.onsuccess = () => {r.result.onversionchange=()=>{r.result.close();expPackDbPromise=null;};res(r.result);};
|
| 1036 |
+
r.onerror = () => {expPackDbPromise=null;rej(r.error);};
|
| 1037 |
+
});
|
| 1038 |
+
return expPackDbPromise;
|
| 1039 |
}
|
| 1040 |
async function expPackGet(k){
|
| 1041 |
const db = await expPackDb();
|
|
|
|
| 1044 |
q.onsuccess = () => res(q.result); q.onerror = () => rej(q.error);
|
| 1045 |
});
|
| 1046 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1047 |
function expPackJoin(base, rel){
|
| 1048 |
const root=String(base||'').replace(/\/?$/,'/');
|
| 1049 |
const path=String(rel||'').replace(/^\/+/,'');
|
|
|
|
| 1064 |
const q=String(stub&&stub.downloadQuery||'');
|
| 1065 |
return {base,manifest,downloadQuery:q};
|
| 1066 |
}
|
| 1067 |
+
async function mfInstallExplorationPack(){
|
| 1068 |
+
if(expPackBusy) return {ok:false,reason:'busy'};
|
| 1069 |
+
if(typeof indexedDB==='undefined') return {ok:false,reason:'no-idb'};
|
| 1070 |
+
expPackBusy=true;
|
| 1071 |
+
if(typeof toast==='function') toast('Galactic pack downloading...');
|
| 1072 |
+
try{
|
| 1073 |
+
/* A completed generic pack remains discoverable from the cached manifest
|
| 1074 |
+
after an offline restart. Do not require the remote special manifest in
|
| 1075 |
+
that case. */
|
| 1076 |
+
const cached=PACK.idx||await packLoadCachedIndex();
|
| 1077 |
+
if(cached&&cached[EXP_PACK_ID]){
|
| 1078 |
+
const ready=await packStatus(EXP_PACK_ID);
|
| 1079 |
+
if(ready.installed) return {ok:true,cached:true,openUrl:null,pack:EXP_PACK_ID};
|
| 1080 |
+
}
|
| 1081 |
+
if(typeof netAllowed==='function'&&!netAllowed()) return {ok:false,reason:'offline'};
|
| 1082 |
+
const spec=await mfExplorationRemoteSpec();
|
| 1083 |
if(!spec.manifest&&!spec.base) return {ok:false,reason:'no-endpoint'};
|
| 1084 |
let manUrl=spec.manifest||expPackJoin(spec.base,'exploration-content-manifest-v1.json');
|
| 1085 |
if(spec.downloadQuery&&manUrl.indexOf('?')<0) manUrl+=spec.downloadQuery;
|
| 1086 |
+
const mr=await fetch(manUrl,{cache:'no-store'});
|
| 1087 |
+
if(!mr.ok) return {ok:false,reason:'manifest'};
|
| 1088 |
+
const man=await mr.json();
|
| 1089 |
+
if(!man||man.kind!=='ExplorationContentManifestV1'||!Array.isArray(man.files)||!man.files.length)
|
| 1090 |
+
return {ok:false,reason:'manifest'};
|
| 1091 |
+
const files=[];
|
| 1092 |
+
for(const entry of man.files){
|
| 1093 |
+
const rel=String(entry.path||'').replace(/\\/g,'/');
|
| 1094 |
+
if(!rel||rel.startsWith('/')||rel.includes('..')) return {ok:false,reason:'path'};
|
| 1095 |
+
const match=/^sha256-([a-f0-9]{64})$/i.exec(String(entry.hash||''));
|
| 1096 |
+
if(!match) return {ok:false,reason:'hash'};
|
| 1097 |
+
files.push({name:rel,size:Number(entry.bytes),sha256:match[1].toLowerCase()});
|
| 1098 |
+
}
|
| 1099 |
+
const bytes=files.reduce((n,file)=>n+file.size,0);
|
| 1100 |
+
if(!Number.isSafeInteger(bytes)||Number(man.totalBytes)!==bytes) return {ok:false,reason:'size'};
|
| 1101 |
+
if(!PACK.idx) await packLoadIndex();
|
| 1102 |
+
const registered=await packRegisterPack(EXP_PACK_ID,{
|
| 1103 |
+
format:1,label:'Galactic Exploration',bytes,chunkSize:PACK_DEFAULT_CHUNK_BYTES,
|
| 1104 |
+
baseUrl:spec.base,downloadQuery:spec.downloadQuery||'',files
|
| 1105 |
+
});
|
| 1106 |
+
const migrationPlan=await packPlan(EXP_PACK_ID);
|
| 1107 |
+
const migrationStorage=await packStoragePreflight(EXP_PACK_ID,migrationPlan);
|
| 1108 |
+
if(!migrationStorage.ok) return {ok:false,reason:'storage',storage:migrationStorage};
|
| 1109 |
+
|
| 1110 |
+
/* 1.33.51 wrote whole, size-only Blobs into a separate database. Import
|
| 1111 |
+
only those that pass the signed manifest's whole-file SHA-256; the old
|
| 1112 |
+
store is otherwise read-only and the generic engine owns all new data,
|
| 1113 |
+
repair, removal, quota checks, Range chunks and offline mounting. */
|
| 1114 |
+
for(const file of registered.files){
|
| 1115 |
+
if(await packFileReady(EXP_PACK_ID,file)) continue;
|
| 1116 |
+
let legacy=null;
|
| 1117 |
+
try{legacy=packStoredBlob(await expPackGet(file.name));}catch(e){}
|
| 1118 |
+
if(!legacy||legacy.size!==file.size) continue;
|
| 1119 |
+
const actual=await packHashBlob(legacy,file.chunkSize);
|
| 1120 |
+
if(actual!==file.sha256) continue;
|
| 1121 |
+
await packPut(packFileKey(EXP_PACK_ID,file),legacy);
|
| 1122 |
+
await packStorePut(PACK_META_STORE,packMetaKey(EXP_PACK_ID,file),{
|
| 1123 |
+
sha256:actual,size:file.size,verifiedAt:Date.now(),migratedFrom:EXP_PACK_DB
|
| 1124 |
+
});
|
| 1125 |
+
}
|
| 1126 |
+
const result=await packInstallPack(EXP_PACK_ID);
|
| 1127 |
+
return {...result,cached:!!result.ok,openUrl:null};
|
| 1128 |
+
}catch(e){
|
| 1129 |
+
return {ok:false,reason:e&&e.code||'fetch',message:e&&e.message||String(e)};
|
| 1130 |
}finally{
|
| 1131 |
expPackBusy=false;
|
| 1132 |
}
|
src/authportal.js
CHANGED
|
@@ -74,6 +74,45 @@ let AP_CONFIRM_FOCUS_TOKEN = 0;
|
|
| 74 |
let AP_SYNC_KIND = 'idle'; // idle | busy | success | error
|
| 75 |
let AP_SYNC_MESSAGE = '';
|
| 76 |
let AP_SESSION_EPOCH = 0; // invalidates authenticated work across account switches
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
|
| 78 |
const AP_SESSION_KEY = 'massfront_authp_session_v1';
|
| 79 |
const AP_URL_KEY = 'massfront_authp_url';
|
|
@@ -142,6 +181,7 @@ function apSaveSession(){
|
|
| 142 |
}catch(e){}
|
| 143 |
}
|
| 144 |
function apLoadSession(){
|
|
|
|
| 145 |
try{
|
| 146 |
const s = localStorage.getItem(AP_SESSION_KEY);
|
| 147 |
if (s){
|
|
@@ -173,6 +213,7 @@ function apLoadSession(){
|
|
| 173 |
function apClearSession(){
|
| 174 |
AP_SESSION_EPOCH++;
|
| 175 |
AP_SESSION = null;
|
|
|
|
| 176 |
apSocialResetCapabilities();
|
| 177 |
AP_SYNC_KIND = 'idle'; AP_SYNC_MESSAGE = '';
|
| 178 |
apSaveSession();
|
|
@@ -182,6 +223,7 @@ function apClearSession(){
|
|
| 182 |
left with no launch prompt at all. Signing out is exactly the moment the
|
| 183 |
gate should ask again. */
|
| 184 |
try{ localStorage.removeItem(AP_GATE_KEY); }catch(e){}
|
|
|
|
| 185 |
if(typeof renderAccount==='function')renderAccount();
|
| 186 |
}
|
| 187 |
|
|
@@ -304,11 +346,14 @@ function apSetSessionFrom(data, email){
|
|
| 304 |
paths into the same field disagreed. */
|
| 305 |
ageOk: (u && 'ageOk' in u) ? !!u.ageOk : null,
|
| 306 |
expiresAt: data.expiresAt || 0 };
|
|
|
|
|
|
|
|
|
|
| 307 |
AP_SYNC_KIND = 'idle';
|
| 308 |
AP_SYNC_MESSAGE = '';
|
| 309 |
apSaveSession();
|
| 310 |
if(typeof renderAccount==='function')renderAccount();
|
| 311 |
-
apGateSatisfied();
|
| 312 |
apGreet();
|
| 313 |
}
|
| 314 |
/* "Welcome Commander <name>" — shown once per sign-in, on the menu the player
|
|
@@ -342,6 +387,29 @@ async function apDeleteAccount(){
|
|
| 342 |
async function apLogin(email, password){
|
| 343 |
const data = await apRequest('POST', '/login', { email, password }, false);
|
| 344 |
apSetSessionFrom(data, email);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
}
|
| 346 |
async function apLogout(){
|
| 347 |
try{ await apRequest('POST', '/logout', undefined, true); }catch(e){ /* best-effort — clear locally regardless */ }
|
|
@@ -354,6 +422,8 @@ async function apLogout(){
|
|
| 354 |
async function apVerifySession(){
|
| 355 |
if (!AP_SESSION) return;
|
| 356 |
const token=AP_SESSION.token, epoch=AP_SESSION_EPOCH;
|
|
|
|
|
|
|
| 357 |
try{
|
| 358 |
const data = await apRequest('GET', '/me', undefined, true);
|
| 359 |
if(!AP_SESSION||AP_SESSION_EPOCH!==epoch||AP_SESSION.token!==token) return;
|
|
@@ -361,7 +431,9 @@ async function apVerifySession(){
|
|
| 361 |
if (data.user && 'username' in data.user) AP_SESSION.username = data.user.username;
|
| 362 |
if (data.user && 'ageOk' in data.user) AP_SESSION.ageOk = !!data.user.ageOk;
|
| 363 |
AP_SESSION.offline = false;
|
|
|
|
| 364 |
apSaveSession();
|
|
|
|
| 365 |
}catch(e){
|
| 366 |
if(e.kind==='stale_session') return;
|
| 367 |
if (e.status === 401){
|
|
@@ -369,7 +441,12 @@ async function apVerifySession(){
|
|
| 369 |
synchronous sign-in callback may already have installed a new one. */
|
| 370 |
if(AP_SESSION&&AP_SESSION_EPOCH===epoch&&AP_SESSION.token===token) apClearSession();
|
| 371 |
apToast('Your session expired — sign in again.');
|
| 372 |
-
}else if (AP_SESSION&&AP_SESSION_EPOCH===epoch&&AP_SESSION.token===token)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 373 |
}
|
| 374 |
if(typeof renderAccount==='function')renderAccount();
|
| 375 |
apRender();
|
|
@@ -904,6 +981,7 @@ async function apDoClaimUsername(){
|
|
| 904 |
try{
|
| 905 |
const d = await apRequest('POST', '/username', { username: v }, true);
|
| 906 |
if (AP_SESSION) { AP_SESSION.username = d.username || v; apSaveSession(); }
|
|
|
|
| 907 |
sfx('level'); apToast('✓ Username set to ' + (d.username || v));
|
| 908 |
}catch(e){
|
| 909 |
sfx('alarm');
|
|
@@ -1104,14 +1182,17 @@ function apClose(){
|
|
| 1104 |
if (!ov) return;
|
| 1105 |
/* Dismissing the gate by ✕ counts as answering it — otherwise the same modal
|
| 1106 |
returns on the next launch and reads as a bug. */
|
|
|
|
| 1107 |
if (typeof AP_GATE_OPEN !== 'undefined' && AP_GATE_OPEN){
|
| 1108 |
AP_GATE_OPEN = false;
|
|
|
|
| 1109 |
try{ localStorage.setItem(AP_GATE_KEY, '1'); }catch(e){}
|
| 1110 |
}
|
| 1111 |
if (typeof apGateFoot === 'function') apGateFoot(false);
|
| 1112 |
ov.style.display = 'none';
|
| 1113 |
document.removeEventListener('keydown', apKeyHandler, true);
|
| 1114 |
if (AP_LAST_FOCUS && AP_LAST_FOCUS.focus) AP_LAST_FOCUS.focus();
|
|
|
|
| 1115 |
}
|
| 1116 |
|
| 1117 |
/* ---- build the modal DOM once (createElement/innerHTML, appended to body) --------- */
|
|
@@ -1225,9 +1306,14 @@ let AP_GATE_OPEN = false;
|
|
| 1225 |
function apGateSeen(){
|
| 1226 |
try{ return localStorage.getItem(AP_GATE_KEY) === '1'; }catch(e){ return true; }
|
| 1227 |
}
|
| 1228 |
-
function apGateSatisfied(){
|
| 1229 |
try{ localStorage.setItem(AP_GATE_KEY, '1'); }catch(e){}
|
| 1230 |
if (AP_GATE_OPEN){ AP_GATE_OPEN = false; apClose(); }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1231 |
}
|
| 1232 |
function apGateFoot(on){
|
| 1233 |
const box = document.querySelector('#apOverlay .apBox');
|
|
@@ -1242,22 +1328,32 @@ function apGateFoot(on){
|
|
| 1242 |
box.appendChild(f);
|
| 1243 |
apTapBind(document.getElementById('apOfflineBtn'), () => {
|
| 1244 |
if (typeof sfx === 'function'){ try{ sfx('ui'); }catch(e){} }
|
| 1245 |
-
apGateSatisfied();
|
| 1246 |
});
|
| 1247 |
}
|
| 1248 |
/* Called by intro.js the moment the launch title hands off to the front end. */
|
| 1249 |
function mfAuthGate(){
|
| 1250 |
try{
|
| 1251 |
-
|
| 1252 |
-
|
| 1253 |
-
if (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1254 |
AP_GATE_OPEN = true;
|
|
|
|
| 1255 |
apGateFoot(true);
|
| 1256 |
/* On the gate this modal is a welcome, not a settings panel. */
|
| 1257 |
const t = document.getElementById('apTitleTx');
|
| 1258 |
if (t) t.textContent = '\u2b21 SIGN IN OR REGISTER';
|
| 1259 |
apOpen(null);
|
| 1260 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1261 |
}
|
| 1262 |
|
| 1263 |
/* ---- boot -------------------------------------------------------------------------- */
|
|
@@ -1265,6 +1361,7 @@ function initAuthPortal(){
|
|
| 1265 |
apBuildUI();
|
| 1266 |
apInjectMenuButton();
|
| 1267 |
apLoadSession();
|
|
|
|
| 1268 |
apResolveEndpoint().then(() => {
|
| 1269 |
apRender();
|
| 1270 |
if (AP_SESSION) apVerifySession();
|
|
@@ -1274,7 +1371,7 @@ function initAuthPortal(){
|
|
| 1274 |
/* ============================================================================
|
| 1275 |
SOCIAL CLIENT — thin, NON-THROWING wrappers over apRequest
|
| 1276 |
============================================================================
|
| 1277 |
-
The
|
| 1278 |
|
| 1279 |
GET /social/friends -> {friends:[…]} (server pre-filters blocks)
|
| 1280 |
GET /social/requests -> {requests:[…]}
|
|
@@ -1286,10 +1383,9 @@ function initAuthPortal(){
|
|
| 1286 |
POST /social/online/heartbeat aggregate count only
|
| 1287 |
GET /social/online aggregate count only
|
| 1288 |
|
| 1289 |
-
and refuses an account that may not use social
|
| 1290 |
-
|
| 1291 |
-
|
| 1292 |
-
those three arrive here intact.
|
| 1293 |
|
| 1294 |
Everything below RESOLVES. Not one of these functions rejects, because the
|
| 1295 |
caller is a mailbox section that repaints mid-match: an unhandled rejection
|
|
@@ -1354,11 +1450,14 @@ function apSocialFail(e){
|
|
| 1354 |
return { ok:false, code:'signed_out',
|
| 1355 |
message:'Your session expired — sign in again to use friends.' };
|
| 1356 |
if (kind === 'unverified')
|
| 1357 |
-
return { ok:false, code:'
|
| 1358 |
-
message:
|
| 1359 |
if (kind === 'age_restricted')
|
| 1360 |
return { ok:false, code:'age_restricted',
|
| 1361 |
message: raw || 'Friends are not available on this account.' };
|
|
|
|
|
|
|
|
|
|
| 1362 |
if (kind === 'social_banned')
|
| 1363 |
return { ok:false, code:'social_banned',
|
| 1364 |
message: raw || 'Friends have been disabled on this account.' };
|
|
@@ -1380,7 +1479,36 @@ function apSocialUsername(raw){
|
|
| 1380 |
}
|
| 1381 |
return {ok:true,value};
|
| 1382 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1383 |
async function apSocialOnce(key,work){
|
|
|
|
|
|
|
| 1384 |
const scoped=(AP_SESSION?AP_SESSION_EPOCH:'guest')+':'+key;
|
| 1385 |
if(AP_SOCIAL_PENDING.has(scoped)){
|
| 1386 |
AP_SOCIAL_PROBE.coalesced++;
|
|
@@ -1513,6 +1641,7 @@ async function socialReport(u,reason,context){
|
|
| 1513 |
bound to AP_SESSION_EPOCH, so it cannot carry across sign-out/sign-in. */
|
| 1514 |
async function socialHandshake(force){
|
| 1515 |
if(!mfSocialSignedIn()) return {ok:false,code:'signed_out',message:'Sign in to check social capabilities.'};
|
|
|
|
| 1516 |
if(!force&&AP_SOCIAL_CAPS.handshake&&AP_SOCIAL_CAPS.sessionEpoch===AP_SESSION_EPOCH&&
|
| 1517 |
Date.now()-AP_SOCIAL_CAPS.checkedAt<AP_SOCIAL_CAP_TTL_MS)
|
| 1518 |
return {ok:true,capabilities:mfSocialCapabilities()};
|
|
@@ -1885,24 +2014,15 @@ async function socialMatchCredential(match,rules,handoff){
|
|
| 1885 |
Accepts either a wrapper result or a bare code string. */
|
| 1886 |
function mfSocialGate(x){
|
| 1887 |
const code = (x && typeof x === 'object') ? (x.code || x.kind || '') : String(x || '');
|
| 1888 |
-
if (code === 'unverified' || code === 'email_unverified') return 'unverified';
|
| 1889 |
if (code === 'age_restricted' || code === 'age') return 'age';
|
| 1890 |
if (code === 'social_banned' || code === 'banned') return 'banned';
|
|
|
|
| 1891 |
return 'ok';
|
| 1892 |
}
|
| 1893 |
-
/*
|
| 1894 |
-
|
| 1895 |
-
is why this lives in the file that owns the portal rather than in the
|
| 1896 |
-
mailbox. Age and ban are stated plainly and offer no button, because
|
| 1897 |
-
offering a button that cannot help is worse than offering none. */
|
| 1898 |
function mfSocialNudge(gate){
|
| 1899 |
const g = mfSocialGate(gate);
|
| 1900 |
-
if (g === 'unverified')
|
| 1901 |
-
return { gate:g,
|
| 1902 |
-
text:'Verify your email address to send and accept friend requests. '+
|
| 1903 |
-
'Everything else in the game keeps working.',
|
| 1904 |
-
cta:'OPEN ACCOUNT',
|
| 1905 |
-
act:(trigger)=>{ try{ if (typeof apOpen === 'function') apOpen(trigger || null); }catch(e){} } };
|
| 1906 |
if (g === 'age')
|
| 1907 |
return { gate:g, text:'Friends are not available on this account.', cta:'', act:null };
|
| 1908 |
if (g === 'banned')
|
|
@@ -1963,6 +2083,7 @@ if(typeof window!=='undefined') window.MFSocial={
|
|
| 1963 |
lobbyInvites:socialLobbyInvites,respondLobbyInvite:socialLobbyInviteRespond,
|
| 1964 |
verifyLobbyCompatibility:socialLobbyCompatibility,launchLobby:socialLobbyLaunch,
|
| 1965 |
claimMatchCredential:socialMatchCredential,rulesHash:apSocialRulesHash,matchSocketUrl:socialMatchSocketUrl,
|
| 1966 |
-
signedIn:mfSocialSignedIn,
|
|
|
|
| 1967 |
};
|
| 1968 |
/* ---- end social client ---- */
|
|
|
|
| 74 |
let AP_SYNC_KIND = 'idle'; // idle | busy | success | error
|
| 75 |
let AP_SYNC_MESSAGE = '';
|
| 76 |
let AP_SESSION_EPOCH = 0; // invalidates authenticated work across account switches
|
| 77 |
+
let AP_SESSION_VERIFIED = false; // cached tokens stay pending until /me confirms this process
|
| 78 |
+
|
| 79 |
+
/* The launcher consumes identity as state, never by inspecting portal text or
|
| 80 |
+
account fields. Keep this payload deliberately small: identity readiness is
|
| 81 |
+
useful to every front-end surface, while an email, username or bearer token
|
| 82 |
+
in a broadcast event would be needless exposure to every script on page. */
|
| 83 |
+
let AP_IDENTITY_REVISION = 0;
|
| 84 |
+
let AP_IDENTITY_STATE = Object.freeze({
|
| 85 |
+
state:'pending', signedIn:false, verified:false, source:'boot', revision:0
|
| 86 |
+
});
|
| 87 |
+
function mfIdentitySnapshot(){
|
| 88 |
+
return {
|
| 89 |
+
state:AP_IDENTITY_STATE.state,
|
| 90 |
+
signedIn:AP_IDENTITY_STATE.signedIn,
|
| 91 |
+
verified:AP_IDENTITY_STATE.verified,
|
| 92 |
+
source:AP_IDENTITY_STATE.source,
|
| 93 |
+
revision:AP_IDENTITY_STATE.revision
|
| 94 |
+
};
|
| 95 |
+
}
|
| 96 |
+
function apIdentitySet(state, source){
|
| 97 |
+
const cleanState=state==='connected'||state==='offline'?state:'pending';
|
| 98 |
+
const cleanSource=String(source||'unknown').replace(/[^a-z0-9_-]/gi,'').slice(0,40)||'unknown';
|
| 99 |
+
AP_IDENTITY_STATE=Object.freeze({
|
| 100 |
+
state:cleanState,
|
| 101 |
+
signedIn:!!(AP_SESSION&&AP_SESSION.token),
|
| 102 |
+
verified:cleanState==='connected'&&!!AP_SESSION_VERIFIED,
|
| 103 |
+
source:cleanSource,
|
| 104 |
+
revision:++AP_IDENTITY_REVISION
|
| 105 |
+
});
|
| 106 |
+
const detail=mfIdentitySnapshot();
|
| 107 |
+
try{
|
| 108 |
+
if(typeof window!=='undefined'&&typeof window.dispatchEvent==='function'&&typeof CustomEvent==='function')
|
| 109 |
+
window.dispatchEvent(new CustomEvent('massfront:identity-state',{detail}));
|
| 110 |
+
}catch(e){}
|
| 111 |
+
return detail;
|
| 112 |
+
}
|
| 113 |
+
if(typeof window!=='undefined') window.MFIdentity=Object.freeze({
|
| 114 |
+
event:'massfront:identity-state', snapshot:mfIdentitySnapshot
|
| 115 |
+
});
|
| 116 |
|
| 117 |
const AP_SESSION_KEY = 'massfront_authp_session_v1';
|
| 118 |
const AP_URL_KEY = 'massfront_authp_url';
|
|
|
|
| 181 |
}catch(e){}
|
| 182 |
}
|
| 183 |
function apLoadSession(){
|
| 184 |
+
AP_SESSION_VERIFIED = false;
|
| 185 |
try{
|
| 186 |
const s = localStorage.getItem(AP_SESSION_KEY);
|
| 187 |
if (s){
|
|
|
|
| 213 |
function apClearSession(){
|
| 214 |
AP_SESSION_EPOCH++;
|
| 215 |
AP_SESSION = null;
|
| 216 |
+
AP_SESSION_VERIFIED = false;
|
| 217 |
apSocialResetCapabilities();
|
| 218 |
AP_SYNC_KIND = 'idle'; AP_SYNC_MESSAGE = '';
|
| 219 |
apSaveSession();
|
|
|
|
| 223 |
left with no launch prompt at all. Signing out is exactly the moment the
|
| 224 |
gate should ask again. */
|
| 225 |
try{ localStorage.removeItem(AP_GATE_KEY); }catch(e){}
|
| 226 |
+
apIdentitySet('pending','signed-out');
|
| 227 |
if(typeof renderAccount==='function')renderAccount();
|
| 228 |
}
|
| 229 |
|
|
|
|
| 346 |
paths into the same field disagreed. */
|
| 347 |
ageOk: (u && 'ageOk' in u) ? !!u.ageOk : null,
|
| 348 |
expiresAt: data.expiresAt || 0 };
|
| 349 |
+
/* register/login just returned this token from the account server, so this
|
| 350 |
+
is the one path that may become connected without a separate /me call. */
|
| 351 |
+
AP_SESSION_VERIFIED = true;
|
| 352 |
AP_SYNC_KIND = 'idle';
|
| 353 |
AP_SYNC_MESSAGE = '';
|
| 354 |
apSaveSession();
|
| 355 |
if(typeof renderAccount==='function')renderAccount();
|
| 356 |
+
apGateSatisfied('connected');
|
| 357 |
apGreet();
|
| 358 |
}
|
| 359 |
/* "Welcome Commander <name>" — shown once per sign-in, on the menu the player
|
|
|
|
| 387 |
async function apLogin(email, password){
|
| 388 |
const data = await apRequest('POST', '/login', { email, password }, false);
|
| 389 |
apSetSessionFrom(data, email);
|
| 390 |
+
/* Backward-compatible identity hydration: older deployed Workers returned a
|
| 391 |
+
valid login token but omitted the account's persisted username. /me has
|
| 392 |
+
always returned the canonical row, so query it before the sign-in flow
|
| 393 |
+
completes. A transient second-request failure does not undo a successful
|
| 394 |
+
login; boot/session verification will retry it later. */
|
| 395 |
+
const token=AP_SESSION&&AP_SESSION.token,epoch=AP_SESSION_EPOCH;
|
| 396 |
+
try{
|
| 397 |
+
const me=await apRequest('GET','/me',undefined,true);
|
| 398 |
+
if(AP_SESSION&&AP_SESSION_EPOCH===epoch&&AP_SESSION.token===token&&me&&me.user){
|
| 399 |
+
if('username' in me.user){
|
| 400 |
+
const value=String(me.user.username==null?'':me.user.username).trim();
|
| 401 |
+
AP_SESSION.username=/^[a-z0-9_]{3,16}$/i.test(value)?value:null;
|
| 402 |
+
}
|
| 403 |
+
if('ageOk' in me.user)AP_SESSION.ageOk=!!me.user.ageOk;
|
| 404 |
+
AP_SESSION.email=String(me.user.email||AP_SESSION.email).trim().toLowerCase();
|
| 405 |
+
apSaveSession();
|
| 406 |
+
}
|
| 407 |
+
}catch(e){
|
| 408 |
+
/* apRequest already clears an actually invalid 401 session. Network and
|
| 409 |
+
old-server failures leave the authenticated session available, and the
|
| 410 |
+
next /me verification retries without asking the player to sign in. */
|
| 411 |
+
if(e&&e.status===401)throw e;
|
| 412 |
+
}
|
| 413 |
}
|
| 414 |
async function apLogout(){
|
| 415 |
try{ await apRequest('POST', '/logout', undefined, true); }catch(e){ /* best-effort — clear locally regardless */ }
|
|
|
|
| 422 |
async function apVerifySession(){
|
| 423 |
if (!AP_SESSION) return;
|
| 424 |
const token=AP_SESSION.token, epoch=AP_SESSION_EPOCH;
|
| 425 |
+
AP_SESSION_VERIFIED = false;
|
| 426 |
+
apIdentitySet('pending','session-verification');
|
| 427 |
try{
|
| 428 |
const data = await apRequest('GET', '/me', undefined, true);
|
| 429 |
if(!AP_SESSION||AP_SESSION_EPOCH!==epoch||AP_SESSION.token!==token) return;
|
|
|
|
| 431 |
if (data.user && 'username' in data.user) AP_SESSION.username = data.user.username;
|
| 432 |
if (data.user && 'ageOk' in data.user) AP_SESSION.ageOk = !!data.user.ageOk;
|
| 433 |
AP_SESSION.offline = false;
|
| 434 |
+
AP_SESSION_VERIFIED = true;
|
| 435 |
apSaveSession();
|
| 436 |
+
apIdentitySet('connected','session-verified');
|
| 437 |
}catch(e){
|
| 438 |
if(e.kind==='stale_session') return;
|
| 439 |
if (e.status === 401){
|
|
|
|
| 441 |
synchronous sign-in callback may already have installed a new one. */
|
| 442 |
if(AP_SESSION&&AP_SESSION_EPOCH===epoch&&AP_SESSION.token===token) apClearSession();
|
| 443 |
apToast('Your session expired — sign in again.');
|
| 444 |
+
}else if (AP_SESSION&&AP_SESSION_EPOCH===epoch&&AP_SESSION.token===token){
|
| 445 |
+
AP_SESSION.offline = true;
|
| 446 |
+
AP_SESSION_VERIFIED = false;
|
| 447 |
+
apSaveSession();
|
| 448 |
+
apIdentitySet('offline','session-unverified');
|
| 449 |
+
}
|
| 450 |
}
|
| 451 |
if(typeof renderAccount==='function')renderAccount();
|
| 452 |
apRender();
|
|
|
|
| 981 |
try{
|
| 982 |
const d = await apRequest('POST', '/username', { username: v }, true);
|
| 983 |
if (AP_SESSION) { AP_SESSION.username = d.username || v; apSaveSession(); }
|
| 984 |
+
apSocialResetCapabilities();
|
| 985 |
sfx('level'); apToast('✓ Username set to ' + (d.username || v));
|
| 986 |
}catch(e){
|
| 987 |
sfx('alarm');
|
|
|
|
| 1182 |
if (!ov) return;
|
| 1183 |
/* Dismissing the gate by ✕ counts as answering it — otherwise the same modal
|
| 1184 |
returns on the next launch and reads as a bug. */
|
| 1185 |
+
let gateDismissed=false;
|
| 1186 |
if (typeof AP_GATE_OPEN !== 'undefined' && AP_GATE_OPEN){
|
| 1187 |
AP_GATE_OPEN = false;
|
| 1188 |
+
gateDismissed=true;
|
| 1189 |
try{ localStorage.setItem(AP_GATE_KEY, '1'); }catch(e){}
|
| 1190 |
}
|
| 1191 |
if (typeof apGateFoot === 'function') apGateFoot(false);
|
| 1192 |
ov.style.display = 'none';
|
| 1193 |
document.removeEventListener('keydown', apKeyHandler, true);
|
| 1194 |
if (AP_LAST_FOCUS && AP_LAST_FOCUS.focus) AP_LAST_FOCUS.focus();
|
| 1195 |
+
if(gateDismissed) apIdentitySet('offline','gate-dismissed');
|
| 1196 |
}
|
| 1197 |
|
| 1198 |
/* ---- build the modal DOM once (createElement/innerHTML, appended to body) --------- */
|
|
|
|
| 1306 |
function apGateSeen(){
|
| 1307 |
try{ return localStorage.getItem(AP_GATE_KEY) === '1'; }catch(e){ return true; }
|
| 1308 |
}
|
| 1309 |
+
function apGateSatisfied(choice){
|
| 1310 |
try{ localStorage.setItem(AP_GATE_KEY, '1'); }catch(e){}
|
| 1311 |
if (AP_GATE_OPEN){ AP_GATE_OPEN = false; apClose(); }
|
| 1312 |
+
if(choice==='offline') return apIdentitySet('offline','offline-choice');
|
| 1313 |
+
if(choice==='connected'&&AP_SESSION_VERIFIED) return apIdentitySet('connected','session-server');
|
| 1314 |
+
if(AP_SESSION&&AP_SESSION_VERIFIED) return apIdentitySet('connected','session-server');
|
| 1315 |
+
if(AP_SESSION) return apIdentitySet('pending','session-verification');
|
| 1316 |
+
return apIdentitySet('offline','offline-choice');
|
| 1317 |
}
|
| 1318 |
function apGateFoot(on){
|
| 1319 |
const box = document.querySelector('#apOverlay .apBox');
|
|
|
|
| 1328 |
box.appendChild(f);
|
| 1329 |
apTapBind(document.getElementById('apOfflineBtn'), () => {
|
| 1330 |
if (typeof sfx === 'function'){ try{ sfx('ui'); }catch(e){} }
|
| 1331 |
+
apGateSatisfied('offline');
|
| 1332 |
});
|
| 1333 |
}
|
| 1334 |
/* Called by intro.js the moment the launch title hands off to the front end. */
|
| 1335 |
function mfAuthGate(){
|
| 1336 |
try{
|
| 1337 |
+
/* A cached token is not connected merely because it survived a restart.
|
| 1338 |
+
apVerifySession will advance pending to connected or offline. */
|
| 1339 |
+
if (AP_SESSION){
|
| 1340 |
+
if(AP_SESSION_VERIFIED) return apIdentitySet('connected','session-verified');
|
| 1341 |
+
return apIdentitySet('pending','session-verification');
|
| 1342 |
+
}
|
| 1343 |
+
if (apGateSeen()) return apIdentitySet('offline','remembered-offline');
|
| 1344 |
+
if (!document.getElementById('apOverlay')) return apIdentitySet('pending','gate-unavailable');
|
| 1345 |
AP_GATE_OPEN = true;
|
| 1346 |
+
apIdentitySet('pending','awaiting-choice');
|
| 1347 |
apGateFoot(true);
|
| 1348 |
/* On the gate this modal is a welcome, not a settings panel. */
|
| 1349 |
const t = document.getElementById('apTitleTx');
|
| 1350 |
if (t) t.textContent = '\u2b21 SIGN IN OR REGISTER';
|
| 1351 |
apOpen(null);
|
| 1352 |
+
return mfIdentitySnapshot();
|
| 1353 |
+
}catch(e){
|
| 1354 |
+
AP_GATE_OPEN = false;
|
| 1355 |
+
return apIdentitySet('pending','gate-error');
|
| 1356 |
+
}
|
| 1357 |
}
|
| 1358 |
|
| 1359 |
/* ---- boot -------------------------------------------------------------------------- */
|
|
|
|
| 1361 |
apBuildUI();
|
| 1362 |
apInjectMenuButton();
|
| 1363 |
apLoadSession();
|
| 1364 |
+
if(AP_SESSION) apIdentitySet('pending','session-verification');
|
| 1365 |
apResolveEndpoint().then(() => {
|
| 1366 |
apRender();
|
| 1367 |
if (AP_SESSION) apVerifySession();
|
|
|
|
| 1371 |
/* ============================================================================
|
| 1372 |
SOCIAL CLIENT — thin, NON-THROWING wrappers over apRequest
|
| 1373 |
============================================================================
|
| 1374 |
+
The authenticated Social server exposes:
|
| 1375 |
|
| 1376 |
GET /social/friends -> {friends:[…]} (server pre-filters blocks)
|
| 1377 |
GET /social/requests -> {requests:[…]}
|
|
|
|
| 1383 |
POST /social/online/heartbeat aggregate count only
|
| 1384 |
GET /social/online aggregate count only
|
| 1385 |
|
| 1386 |
+
and refuses an account that may not use social with age, username,
|
| 1387 |
+
moderation or ban codes. E-mail verification is optional account metadata
|
| 1388 |
+
and is permanently outside Social and multiplayer authorization.
|
|
|
|
| 1389 |
|
| 1390 |
Everything below RESOLVES. Not one of these functions rejects, because the
|
| 1391 |
caller is a mailbox section that repaints mid-match: an unhandled rejection
|
|
|
|
| 1450 |
return { ok:false, code:'signed_out',
|
| 1451 |
message:'Your session expired — sign in again to use friends.' };
|
| 1452 |
if (kind === 'unverified')
|
| 1453 |
+
return { ok:false, code:'service_policy',
|
| 1454 |
+
message:'The Social service is using an obsolete access policy — retry after it updates.' };
|
| 1455 |
if (kind === 'age_restricted')
|
| 1456 |
return { ok:false, code:'age_restricted',
|
| 1457 |
message: raw || 'Friends are not available on this account.' };
|
| 1458 |
+
if (kind === 'username_required')
|
| 1459 |
+
return { ok:false, code:'username_required',
|
| 1460 |
+
message: raw || 'Choose your commander username before using Social Command.' };
|
| 1461 |
if (kind === 'social_banned')
|
| 1462 |
return { ok:false, code:'social_banned',
|
| 1463 |
message: raw || 'Friends have been disabled on this account.' };
|
|
|
|
| 1479 |
}
|
| 1480 |
return {ok:true,value};
|
| 1481 |
}
|
| 1482 |
+
function mfSocialIdentity(){
|
| 1483 |
+
let value='';
|
| 1484 |
+
try{value=String(AP_SESSION&&AP_SESSION.username||'').trim();}catch(e){}
|
| 1485 |
+
return {username:AP_SOCIAL_USERNAME_RE.test(value)?value:'',hasUsername:AP_SOCIAL_USERNAME_RE.test(value)};
|
| 1486 |
+
}
|
| 1487 |
+
function apSocialUsernameGate(){
|
| 1488 |
+
if(!mfSocialSignedIn())return null;
|
| 1489 |
+
return mfSocialIdentity().hasUsername?null:{ok:false,code:'username_required',
|
| 1490 |
+
message:'Choose your commander username before using Social Command.'};
|
| 1491 |
+
}
|
| 1492 |
+
/* The Social chooser writes the same canonical account field as Account. It
|
| 1493 |
+
does not create a second display name, and it never substitutes a local
|
| 1494 |
+
profile label for server identity. */
|
| 1495 |
+
async function socialClaimUsername(raw){
|
| 1496 |
+
const uv=apSocialUsername(raw);if(!uv.ok)return uv.result;
|
| 1497 |
+
if(!mfSocialSignedIn())return {ok:false,code:'signed_out',message:'Sign in before choosing a commander username.'};
|
| 1498 |
+
try{
|
| 1499 |
+
const d=await apRequest('POST','/username',{username:uv.value},true);
|
| 1500 |
+
const value=String(d&&d.username||uv.value).trim();
|
| 1501 |
+
if(!AP_SOCIAL_USERNAME_RE.test(value))return {ok:false,code:'bad_response',message:'The account server returned an invalid username.'};
|
| 1502 |
+
if(AP_SESSION){AP_SESSION.username=value;apSaveSession();}
|
| 1503 |
+
apSocialResetCapabilities();
|
| 1504 |
+
if(typeof renderAccount==='function')try{renderAccount();}catch(e){}
|
| 1505 |
+
if(typeof renderMetaHead==='function')try{renderMetaHead();}catch(e){}
|
| 1506 |
+
return {ok:true,username:value};
|
| 1507 |
+
}catch(e){return apSocialFail(e);}
|
| 1508 |
+
}
|
| 1509 |
async function apSocialOnce(key,work){
|
| 1510 |
+
const usernameGate=apSocialUsernameGate();
|
| 1511 |
+
if(usernameGate)return usernameGate;
|
| 1512 |
const scoped=(AP_SESSION?AP_SESSION_EPOCH:'guest')+':'+key;
|
| 1513 |
if(AP_SOCIAL_PENDING.has(scoped)){
|
| 1514 |
AP_SOCIAL_PROBE.coalesced++;
|
|
|
|
| 1641 |
bound to AP_SESSION_EPOCH, so it cannot carry across sign-out/sign-in. */
|
| 1642 |
async function socialHandshake(force){
|
| 1643 |
if(!mfSocialSignedIn()) return {ok:false,code:'signed_out',message:'Sign in to check social capabilities.'};
|
| 1644 |
+
const usernameGate=apSocialUsernameGate();if(usernameGate)return usernameGate;
|
| 1645 |
if(!force&&AP_SOCIAL_CAPS.handshake&&AP_SOCIAL_CAPS.sessionEpoch===AP_SESSION_EPOCH&&
|
| 1646 |
Date.now()-AP_SOCIAL_CAPS.checkedAt<AP_SOCIAL_CAP_TTL_MS)
|
| 1647 |
return {ok:true,capabilities:mfSocialCapabilities()};
|
|
|
|
| 2014 |
Accepts either a wrapper result or a bare code string. */
|
| 2015 |
function mfSocialGate(x){
|
| 2016 |
const code = (x && typeof x === 'object') ? (x.code || x.kind || '') : String(x || '');
|
|
|
|
| 2017 |
if (code === 'age_restricted' || code === 'age') return 'age';
|
| 2018 |
if (code === 'social_banned' || code === 'banned') return 'banned';
|
| 2019 |
+
if (code === 'username_required' || code === 'username') return 'username';
|
| 2020 |
return 'ok';
|
| 2021 |
}
|
| 2022 |
+
/* Age and ban are stated plainly and offer no button because account e-mail
|
| 2023 |
+
verification is not, and must never become, a Social access action. */
|
|
|
|
|
|
|
|
|
|
| 2024 |
function mfSocialNudge(gate){
|
| 2025 |
const g = mfSocialGate(gate);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2026 |
if (g === 'age')
|
| 2027 |
return { gate:g, text:'Friends are not available on this account.', cta:'', act:null };
|
| 2028 |
if (g === 'banned')
|
|
|
|
| 2083 |
lobbyInvites:socialLobbyInvites,respondLobbyInvite:socialLobbyInviteRespond,
|
| 2084 |
verifyLobbyCompatibility:socialLobbyCompatibility,launchLobby:socialLobbyLaunch,
|
| 2085 |
claimMatchCredential:socialMatchCredential,rulesHash:apSocialRulesHash,matchSocketUrl:socialMatchSocketUrl,
|
| 2086 |
+
signedIn:mfSocialSignedIn,identity:mfSocialIdentity,claimUsername:socialClaimUsername,
|
| 2087 |
+
capabilities:mfSocialCapabilities,probe:mfSocialProbe
|
| 2088 |
};
|
| 2089 |
/* ---- end social client ---- */
|